use crate::events::{DraftHumanEvent, EventMetadata, GameEvent};
use crate::log::entry::LogEntry;
use crate::parsers::api_common;
const DRAFT_NOTIFY_MARKER: &str = "Draft.Notify";
const MAKE_PICK_MARKER: &str = "EventPlayerDraftMakePick";
pub fn try_parse(
entry: &LogEntry,
timestamp: Option<chrono::DateTime<chrono::Utc>>,
) -> Option<GameEvent> {
let body = &entry.body;
if let Some(payload) = try_parse_draft_notify(body) {
let metadata = EventMetadata::new(timestamp, body.as_bytes().to_vec());
return Some(GameEvent::DraftHuman(DraftHumanEvent::new(
metadata, payload,
)));
}
if let Some(payload) = try_parse_make_pick(body) {
let metadata = EventMetadata::new(timestamp, body.as_bytes().to_vec());
return Some(GameEvent::DraftHuman(DraftHumanEvent::new(
metadata, payload,
)));
}
None
}
fn try_parse_draft_notify(body: &str) -> Option<serde_json::Value> {
if !body.contains(DRAFT_NOTIFY_MARKER) {
return None;
}
let parsed = api_common::parse_json_from_body(body, "Draft.Notify")?;
if parsed.get("PackCards").is_none() && parsed.get("SelfPack").is_none() {
return None;
}
let draft_id = parsed
.get("draftId")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let pack_idx = parsed
.get("SelfPack")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let selection_idx = parsed
.get("SelfPick")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let pack_cards = extract_pack_cards_from_string(&parsed);
Some(serde_json::json!({
"type": "draft_human_notify",
"draft_id": draft_id,
"pack_number": pack_idx,
"pick_number": selection_idx,
"pack_cards": pack_cards,
"raw_draft_notify": parsed,
}))
}
fn try_parse_make_pick(body: &str) -> Option<serde_json::Value> {
if !body.contains(MAKE_PICK_MARKER) {
return None;
}
if api_common::is_api_response(body, MAKE_PICK_MARKER) {
return None;
}
let parsed = api_common::parse_json_from_body(body, "EventPlayerDraftMakePick")?;
let request_payload = parsed
.get("request")
.and_then(serde_json::Value::as_str)
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok());
let pick_info = parsed
.get("PickInfo")
.or(request_payload.as_ref())
.unwrap_or(&parsed);
let card_id = pick_info
.get("CardId")
.and_then(serde_json::Value::as_i64)
.or_else(|| {
pick_info
.get("GrpIds")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(serde_json::Value::as_i64)
})?;
let pack_idx = pick_info
.get("PackNumber")
.or_else(|| pick_info.get("Pack"))
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let selection_idx = pick_info
.get("PickNumber")
.or_else(|| pick_info.get("Pick"))
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let event_name = parsed
.get("EventName")
.or_else(|| pick_info.get("EventName"))
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let card_ids = pick_info
.get("CardIds")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(serde_json::Value::as_i64)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let draft_id = pick_info
.get("DraftId")
.or_else(|| pick_info.get("draftId"))
.or_else(|| parsed.get("DraftId"))
.or_else(|| parsed.get("draftId"))
.and_then(serde_json::Value::as_str)
.unwrap_or("");
Some(serde_json::json!({
"type": "draft_human_pick",
"draft_id": draft_id,
"event_name": event_name,
"card_id": card_id,
"pack_number": pack_idx,
"pick_number": selection_idx,
"card_ids": card_ids,
"raw_make_pick": parsed,
}))
}
fn extract_pack_cards_from_string(parsed: &serde_json::Value) -> Vec<i64> {
if let Some(pack_cards) = parsed.get("PackCards") {
if let Some(s) = pack_cards.as_str() {
return parse_comma_separated_ids(s);
}
if let Some(arr) = pack_cards.as_array() {
return arr
.iter()
.filter_map(|v| {
v.as_i64()
.or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
})
.collect();
}
}
Vec::new()
}
fn parse_comma_separated_ids(s: &str) -> Vec<i64> {
s.split(',')
.filter_map(|segment| segment.trim().parse::<i64>().ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::PerformanceClass;
use crate::parsers::test_helpers::{
draft_human_payload, test_timestamp, unity_entry, EntryHeader,
};
mod draft_notify {
use super::*;
#[test]
fn test_try_parse_draft_notify_basic() {
let body = "[UnityCrossThreadLogger]Draft.Notify\n\
{\n\
\"draftId\": \"abc-123-def\",\n\
\"SelfPack\": 0,\n\
\"SelfPick\": 0,\n\
\"PackCards\": \"12345,67890,11111\"\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["type"], "draft_human_notify");
assert_eq!(payload["draft_id"], "abc-123-def");
assert_eq!(payload["pack_number"], 0);
assert_eq!(payload["pick_number"], 0);
assert_eq!(
payload["pack_cards"],
serde_json::json!([12345, 67890, 11111])
);
}
#[test]
fn test_try_parse_draft_notify_second_pack() {
let body = "[UnityCrossThreadLogger]Draft.Notify\n\
{\n\
\"draftId\": \"draft-456\",\n\
\"SelfPack\": 1,\n\
\"SelfPick\": 5,\n\
\"PackCards\": \"22222,33333,44444,55555\"\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["pack_number"], 1);
assert_eq!(payload["pick_number"], 5);
assert_eq!(payload["draft_id"], "draft-456");
assert_eq!(
payload["pack_cards"],
serde_json::json!([22222, 33333, 44444, 55555])
);
}
#[test]
fn test_try_parse_draft_notify_last_pick_single_card() {
let body = "[UnityCrossThreadLogger]Draft.Notify\n\
{\n\
\"draftId\": \"draft-789\",\n\
\"SelfPack\": 2,\n\
\"SelfPick\": 13,\n\
\"PackCards\": \"99999\"\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["pack_number"], 2);
assert_eq!(payload["pick_number"], 13);
assert_eq!(payload["pack_cards"], serde_json::json!([99999]));
}
#[test]
fn test_try_parse_draft_notify_empty_pack_cards() {
let body = "[UnityCrossThreadLogger]Draft.Notify\n\
{\n\
\"draftId\": \"draft-empty\",\n\
\"SelfPack\": 0,\n\
\"SelfPick\": 0,\n\
\"PackCards\": \"\"\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["pack_cards"], serde_json::json!([]));
}
#[test]
fn test_try_parse_draft_notify_array_format_pack_cards() {
let body = "[UnityCrossThreadLogger]Draft.Notify\n\
{\n\
\"draftId\": \"draft-arr\",\n\
\"SelfPack\": 0,\n\
\"SelfPick\": 0,\n\
\"PackCards\": [12345, 67890]\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["pack_cards"], serde_json::json!([12345, 67890]));
}
#[test]
fn test_try_parse_draft_notify_missing_draft_id() {
let body = "[UnityCrossThreadLogger]Draft.Notify\n\
{\n\
\"SelfPack\": 0,\n\
\"SelfPick\": 0,\n\
\"PackCards\": \"12345\"\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["draft_id"], "");
}
#[test]
fn test_try_parse_draft_notify_preserves_raw_payload() {
let body = "[UnityCrossThreadLogger]Draft.Notify\n\
{\n\
\"draftId\": \"draft-raw\",\n\
\"SelfPack\": 0,\n\
\"SelfPick\": 0,\n\
\"PackCards\": \"12345\",\n\
\"ExtraField\": \"preserved\"\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["raw_draft_notify"]["ExtraField"], "preserved");
}
#[test]
fn test_try_parse_draft_notify_with_timestamp_in_header() {
let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
Draft.Notify\n\
{\n\
\"draftId\": \"draft-ts\",\n\
\"SelfPack\": 0,\n\
\"SelfPick\": 0,\n\
\"PackCards\": \"12345\"\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["type"], "draft_human_notify");
}
}
mod make_pick {
use super::*;
#[test]
fn test_try_parse_make_pick_basic() {
let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
{\n\
\"EventName\": \"PremierDraft_MKM_20260201\",\n\
\"PickInfo\": {\n\
\"CardId\": 12345,\n\
\"PackNumber\": 0,\n\
\"PickNumber\": 0\n\
}\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["type"], "draft_human_pick");
assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
assert_eq!(payload["card_id"], 12345);
assert_eq!(payload["pack_number"], 0);
assert_eq!(payload["pick_number"], 0);
}
#[test]
fn test_try_parse_make_pick_later_in_draft() {
let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
{\n\
\"EventName\": \"TradDraft_DSK_20260115\",\n\
\"PickInfo\": {\n\
\"CardId\": 67890,\n\
\"PackNumber\": 1,\n\
\"PickNumber\": 7\n\
}\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["card_id"], 67890);
assert_eq!(payload["pack_number"], 1);
assert_eq!(payload["pick_number"], 7);
assert_eq!(payload["event_name"], "TradDraft_DSK_20260115");
}
#[test]
fn test_try_parse_make_pick_with_card_ids() {
let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
{\n\
\"EventName\": \"PremierDraft_MKM_20260201\",\n\
\"PickInfo\": {\n\
\"CardId\": 11111,\n\
\"PackNumber\": 0,\n\
\"PickNumber\": 0,\n\
\"CardIds\": [11111, 22222, 33333]\n\
}\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["card_id"], 11111);
assert_eq!(
payload["card_ids"],
serde_json::json!([11111, 22222, 33333])
);
}
#[test]
fn test_try_parse_make_pick_flat_format() {
let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
{\n\
\"CardId\": 55555,\n\
\"PackNumber\": 2,\n\
\"PickNumber\": 10\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["card_id"], 55555);
assert_eq!(payload["pack_number"], 2);
assert_eq!(payload["pick_number"], 10);
}
#[test]
fn test_try_parse_make_pick_missing_card_id_returns_none() {
let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
{\n\
\"PickInfo\": {\n\
\"PackNumber\": 0,\n\
\"PickNumber\": 0\n\
}\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_none());
}
#[test]
fn test_try_parse_make_pick_preserves_raw_payload() {
let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
{\n\
\"PickInfo\": {\n\
\"CardId\": 12345,\n\
\"PackNumber\": 0,\n\
\"PickNumber\": 0,\n\
\"ExtraField\": \"kept\"\n\
}\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["raw_make_pick"]["PickInfo"]["ExtraField"], "kept");
}
#[test]
fn test_try_parse_make_pick_outbound_request_format() {
let body = "[UnityCrossThreadLogger]==> EventPlayerDraftMakePick\n\
{\n\
\"id\": \"b0114c5d-0462-4855-a7ab-d06ede720f93\",\n\
\"request\": \"{\\\"DraftId\\\":\\\"0784e646\\\",\\\"GrpIds\\\":[100486],\\\"Pack\\\":1,\\\"Pick\\\":2}\"\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["type"], "draft_human_pick");
assert_eq!(payload["card_id"], 100_486);
assert_eq!(payload["pack_number"], 1);
assert_eq!(payload["pick_number"], 2);
assert_eq!(payload["draft_id"], "0784e646");
assert_eq!(payload["event_name"], ""); assert_eq!(
payload["raw_make_pick"]["id"],
"b0114c5d-0462-4855-a7ab-d06ede720f93"
);
}
#[test]
fn test_try_parse_make_pick_ignores_success_response() {
let body = "[UnityCrossThreadLogger]3/11/2026 9:44:16 PM\n\
<== EventPlayerDraftMakePick(b0114c5d-0462-4855-a7ab-d06ede720f93)\n\
{\"IsPickSuccessful\":true}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_none());
}
#[test]
fn test_try_parse_make_pick_with_timestamp_in_header() {
let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
EventPlayerDraftMakePick\n\
{\n\
\"PickInfo\": {\n\
\"CardId\": 77777,\n\
\"PackNumber\": 0,\n\
\"PickNumber\": 1\n\
}\n\
}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = draft_human_payload(event);
assert_eq!(payload["card_id"], 77777);
}
}
mod metadata {
use super::*;
#[test]
fn test_try_parse_preserves_raw_bytes_notify() {
let body = "[UnityCrossThreadLogger]Draft.Notify\n\
{\"SelfPack\": 0, \"SelfPick\": 0, \
\"PackCards\": \"12345\"}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
}
#[test]
fn test_try_parse_preserves_raw_bytes_make_pick() {
let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
{\"PickInfo\": {\"CardId\": 1, \"PackNumber\": 0, \
\"PickNumber\": 0}}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
}
#[test]
fn test_try_parse_stores_timestamp_notify() {
let body = "[UnityCrossThreadLogger]Draft.Notify\n\
{\"SelfPack\": 0, \"SelfPick\": 0, \
\"PackCards\": \"12345\"}";
let entry = unity_entry(body);
let ts = Some(test_timestamp());
let result = try_parse(&entry, ts);
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert_eq!(event.metadata().timestamp(), ts);
}
#[test]
fn test_try_parse_stores_timestamp_make_pick() {
let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
{\"PickInfo\": {\"CardId\": 1, \"PackNumber\": 0, \
\"PickNumber\": 0}}";
let entry = unity_entry(body);
let ts = Some(test_timestamp());
let result = try_parse(&entry, ts);
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert_eq!(event.metadata().timestamp(), ts);
}
}
mod non_matching {
use super::*;
#[test]
fn test_try_parse_unrelated_entry_returns_none() {
let body = "[UnityCrossThreadLogger]greToClientEvent\n{\"data\": 1}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_empty_body_returns_none() {
let body = "[UnityCrossThreadLogger]";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_bot_draft_entry_returns_none() {
let body = "[UnityCrossThreadLogger]BotDraft_DraftPick\n\
{\n\
\"PickInfo\": {\n\
\"CardId\": 12345,\n\
\"PackNumber\": 0,\n\
\"PickNumber\": 0\n\
}\n\
}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_game_result_business_event_returns_none() {
let body = "[UnityCrossThreadLogger]LogBusinessEvents\n\
{\n\
\"WinningType\": \"WinLoss\",\n\
\"WinningTeamId\": 1\n\
}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_malformed_json_notify_returns_none() {
let body = "[UnityCrossThreadLogger]Draft.Notify\n\
{\"PackCards\": broken!!!}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_malformed_json_make_pick_returns_none() {
let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
{not valid json}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_malformed_json_business_event_returns_none() {
let body = "[UnityCrossThreadLogger]LogBusinessEvents\n\
{\"PickGrpId\": broken!!!}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_marker_only_no_json_notify_returns_none() {
let body = "[UnityCrossThreadLogger]Draft.Notify";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_marker_only_no_json_make_pick_returns_none() {
let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_draft_notify_no_pack_or_self_pack_returns_none() {
let body = "[UnityCrossThreadLogger]Draft.Notify\n\
{\"unrelatedField\": \"value\"}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_connection_manager_header_returns_none() {
let entry = LogEntry {
header: EntryHeader::ConnectionManager,
body: "[ConnectionManager]some connection message".to_owned(),
};
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
}
mod performance_class {
use super::*;
#[test]
fn test_draft_human_notify_is_durable_per_event() {
let body = "[UnityCrossThreadLogger]Draft.Notify\n\
{\"SelfPack\": 0, \"SelfPick\": 0, \
\"PackCards\": \"12345\"}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert_eq!(event.performance_class(), PerformanceClass::DurablePerEvent);
}
#[test]
fn test_draft_human_pick_is_durable_per_event() {
let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
{\"PickInfo\": {\"CardId\": 1, \"PackNumber\": 0, \
\"PickNumber\": 0}}";
let entry = unity_entry(body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert_eq!(event.performance_class(), PerformanceClass::DurablePerEvent);
}
}
mod helpers {
use super::*;
#[test]
fn test_parse_comma_separated_ids_basic() {
assert_eq!(
parse_comma_separated_ids("12345,67890,11111"),
vec![12345, 67890, 11111]
);
}
#[test]
fn test_parse_comma_separated_ids_with_spaces() {
assert_eq!(
parse_comma_separated_ids("12345, 67890, 11111"),
vec![12345, 67890, 11111]
);
}
#[test]
fn test_parse_comma_separated_ids_single() {
assert_eq!(parse_comma_separated_ids("12345"), vec![12345]);
}
#[test]
fn test_parse_comma_separated_ids_empty() {
let result: Vec<i64> = parse_comma_separated_ids("");
assert!(result.is_empty());
}
#[test]
fn test_parse_comma_separated_ids_with_invalid() {
assert_eq!(
parse_comma_separated_ids("12345,abc,67890"),
vec![12345, 67890]
);
}
}
}