Skip to main content

manasight_parser/parsers/draft/
human.rs

1//! Human draft parser for Premier Draft and Traditional Draft events.
2//!
3//! In human (pod) drafts, the player drafts against other human players.
4//! Two log signatures capture the draft flow:
5//!
6//! | Signature | Meaning | Key Fields |
7//! |-----------|---------|------------|
8//! | `Draft.Notify` | Draft state notification (pack presented) | `draftId`, `SelfPack`, `SelfPick`, `PackCards` |
9//! | `EventPlayerDraftMakePick` | Player's pick selection | `DraftId`, `GrpIds`, `Pack`, `Pick` |
10//!
11//! Human drafts have 3 packs of 14 picks each (42 total picks). Pack and
12//! pick numbers are zero-indexed in the log.
13//!
14//! Both events are Class 2 (Durable Per-Event) -- each pick is independently
15//! valuable and must survive crashes.
16
17use crate::events::{DraftHumanEvent, EventMetadata, GameEvent};
18use crate::log::entry::LogEntry;
19use crate::parsers::api_common;
20
21/// Marker for human draft state notification events.
22///
23/// `Draft.Notify` appears in the log when a new pack is presented to the
24/// player during a Premier or Traditional Draft.
25const DRAFT_NOTIFY_MARKER: &str = "Draft.Notify";
26
27/// Marker for human draft pick selection events.
28///
29/// `EventPlayerDraftMakePick` appears after the player selects a card
30/// from the presented pack.
31const MAKE_PICK_MARKER: &str = "EventPlayerDraftMakePick";
32
33/// Attempts to parse a [`LogEntry`] as a human draft event.
34///
35/// Returns `Some(GameEvent::DraftHuman(_))` if the entry matches any of:
36/// - A `Draft.Notify` pack presentation
37/// - An `EventPlayerDraftMakePick` pick selection
38///
39/// Returns `None` if the entry does not match any human draft signature.
40///
41/// The `timestamp` is `None` when the log entry header did not contain a
42/// parseable timestamp. It is passed through to [`EventMetadata`] so
43/// downstream consumers can distinguish real vs missing timestamps.
44pub fn try_parse(
45    entry: &LogEntry,
46    timestamp: Option<chrono::DateTime<chrono::Utc>>,
47) -> Option<GameEvent> {
48    let body = &entry.body;
49
50    // Try Draft.Notify first (pack presentation -- most common during drafting).
51    if let Some(payload) = try_parse_draft_notify(body) {
52        let metadata = EventMetadata::new(timestamp, body.as_bytes().to_vec());
53        return Some(GameEvent::DraftHuman(DraftHumanEvent::new(
54            metadata, payload,
55        )));
56    }
57
58    // Try EventPlayerDraftMakePick (pick selection).
59    if let Some(payload) = try_parse_make_pick(body) {
60        let metadata = EventMetadata::new(timestamp, body.as_bytes().to_vec());
61        return Some(GameEvent::DraftHuman(DraftHumanEvent::new(
62            metadata, payload,
63        )));
64    }
65
66    None
67}
68
69/// Attempts to parse a `Draft.Notify` pack presentation event.
70///
71/// The log entry body contains a JSON object with:
72/// - `draftId`: unique identifier for this draft session
73/// - `SelfPack`: zero-indexed pack number (0, 1, 2)
74/// - `SelfPick`: zero-indexed pick number within the pack
75/// - `PackCards`: comma-separated string of card GRP IDs in the pack
76fn try_parse_draft_notify(body: &str) -> Option<serde_json::Value> {
77    if !body.contains(DRAFT_NOTIFY_MARKER) {
78        return None;
79    }
80
81    let parsed = api_common::parse_json_from_body(body, "Draft.Notify")?;
82
83    // Verify this is a Draft.Notify by checking for characteristic fields.
84    // PackCards is the hallmark of a Draft.Notify payload.
85    if parsed.get("PackCards").is_none() && parsed.get("SelfPack").is_none() {
86        return None;
87    }
88
89    let draft_id = parsed
90        .get("draftId")
91        .and_then(serde_json::Value::as_str)
92        .unwrap_or("");
93
94    let pack_idx = parsed
95        .get("SelfPack")
96        .and_then(serde_json::Value::as_i64)
97        .unwrap_or(0);
98
99    let selection_idx = parsed
100        .get("SelfPick")
101        .and_then(serde_json::Value::as_i64)
102        .unwrap_or(0);
103
104    let pack_cards = extract_pack_cards_from_string(&parsed);
105
106    Some(serde_json::json!({
107        "type": "draft_human_notify",
108        "draft_id": draft_id,
109        "pack_number": pack_idx,
110        "pick_number": selection_idx,
111        "pack_cards": pack_cards,
112        "raw_draft_notify": parsed,
113    }))
114}
115
116/// Attempts to parse an `EventPlayerDraftMakePick` pick selection event.
117///
118/// The log entry body contains a JSON object with:
119/// - `EventName`: the Arena event identifier
120/// - `PickInfo` (optional wrapper) containing:
121///   - `CardId`: the GRP ID of the selected card
122///   - `PackNumber`: zero-indexed pack number
123///   - `PickNumber`: zero-indexed pick number within the pack
124/// - OR an outbound API request format containing:
125///   - `request` string-escaped JSON with `DraftId`, `GrpIds` (array), `Pack`, and `Pick`
126fn try_parse_make_pick(body: &str) -> Option<serde_json::Value> {
127    if !body.contains(MAKE_PICK_MARKER) {
128        return None;
129    }
130
131    // Ignore paired API responses like `<== EventPlayerDraftMakePick(uuid)`.
132    // They only confirm success and do not contain pack, pick or card IDs.
133    if api_common::is_api_response(body, MAKE_PICK_MARKER) {
134        return None;
135    }
136
137    let parsed = api_common::parse_json_from_body(body, "EventPlayerDraftMakePick")?;
138
139    // Try to extract the string-escaped `request` payload if it exists (outbound API request)
140    let request_payload = parsed
141        .get("request")
142        .and_then(serde_json::Value::as_str)
143        .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok());
144
145    // The pick info may be at top level, nested under a `PickInfo` key, or inside the `request` payload.
146    // We prioritize `PickInfo`, then the parsed `request`, then the top-level `parsed` object.
147    let pick_info = parsed
148        .get("PickInfo")
149        .or(request_payload.as_ref())
150        .unwrap_or(&parsed);
151
152    let card_id = pick_info
153        .get("CardId")
154        .and_then(serde_json::Value::as_i64)
155        .or_else(|| {
156            // Outbound request format has `GrpIds` as an array
157            pick_info
158                .get("GrpIds")
159                .and_then(|v| v.as_array())
160                .and_then(|arr| arr.first())
161                .and_then(serde_json::Value::as_i64)
162        })?;
163
164    let pack_idx = pick_info
165        .get("PackNumber")
166        .or_else(|| pick_info.get("Pack"))
167        .and_then(serde_json::Value::as_i64)
168        .unwrap_or(0);
169
170    let selection_idx = pick_info
171        .get("PickNumber")
172        .or_else(|| pick_info.get("Pick"))
173        .and_then(serde_json::Value::as_i64)
174        .unwrap_or(0);
175
176    let event_name = parsed
177        .get("EventName")
178        .or_else(|| pick_info.get("EventName"))
179        .and_then(serde_json::Value::as_str)
180        .unwrap_or("");
181
182    // Some entries include the full list of card IDs that were in the pack.
183    let card_ids = pick_info
184        .get("CardIds")
185        .and_then(|v| v.as_array())
186        .map(|arr| {
187            arr.iter()
188                .filter_map(serde_json::Value::as_i64)
189                .collect::<Vec<_>>()
190        })
191        .unwrap_or_default();
192
193    let draft_id = pick_info
194        .get("DraftId")
195        .or_else(|| pick_info.get("draftId"))
196        .or_else(|| parsed.get("DraftId"))
197        .or_else(|| parsed.get("draftId"))
198        .and_then(serde_json::Value::as_str)
199        .unwrap_or("");
200
201    Some(serde_json::json!({
202        "type": "draft_human_pick",
203        "draft_id": draft_id,
204        "event_name": event_name,
205        "card_id": card_id,
206        "pack_number": pack_idx,
207        "pick_number": selection_idx,
208        "card_ids": card_ids,
209        "raw_make_pick": parsed,
210    }))
211}
212
213/// Extracts pack card IDs from a `PackCards` field in a `Draft.Notify` payload.
214///
215/// In `Draft.Notify`, `PackCards` is typically a comma-separated string of
216/// GRP IDs (e.g., `"12345,67890,11111"`). This function also handles
217/// array format for robustness.
218fn extract_pack_cards_from_string(parsed: &serde_json::Value) -> Vec<i64> {
219    if let Some(pack_cards) = parsed.get("PackCards") {
220        // Comma-separated string (most common format in Draft.Notify).
221        if let Some(s) = pack_cards.as_str() {
222            return parse_comma_separated_ids(s);
223        }
224
225        // Array format (less common but possible).
226        if let Some(arr) = pack_cards.as_array() {
227            return arr
228                .iter()
229                .filter_map(|v| {
230                    v.as_i64()
231                        .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
232                })
233                .collect();
234        }
235    }
236
237    Vec::new()
238}
239
240/// Parses a comma-separated string of integer IDs into a `Vec<i64>`.
241///
242/// Silently skips any non-numeric segments.
243fn parse_comma_separated_ids(s: &str) -> Vec<i64> {
244    s.split(',')
245        .filter_map(|segment| segment.trim().parse::<i64>().ok())
246        .collect()
247}
248
249// ---------------------------------------------------------------------------
250// Tests
251// ---------------------------------------------------------------------------
252
253#[cfg(test)]
254#[allow(deprecated)]
255mod tests {
256    use super::*;
257    use crate::events::PerformanceClass;
258    use crate::parsers::test_helpers::{
259        draft_human_payload, test_timestamp, unity_entry, EntryHeader,
260    };
261
262    // -- Draft.Notify parsing ------------------------------------------------
263
264    mod draft_notify {
265        use super::*;
266
267        #[test]
268        fn test_try_parse_draft_notify_basic() {
269            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
270                         {\n\
271                           \"draftId\": \"abc-123-def\",\n\
272                           \"SelfPack\": 0,\n\
273                           \"SelfPick\": 0,\n\
274                           \"PackCards\": \"12345,67890,11111\"\n\
275                         }";
276            let entry = unity_entry(body);
277            let result = try_parse(&entry, Some(test_timestamp()));
278
279            assert!(result.is_some());
280            let event = result.as_ref().unwrap_or_else(|| unreachable!());
281            let payload = draft_human_payload(event);
282
283            assert_eq!(payload["type"], "draft_human_notify");
284            assert_eq!(payload["draft_id"], "abc-123-def");
285            assert_eq!(payload["pack_number"], 0);
286            assert_eq!(payload["pick_number"], 0);
287            assert_eq!(
288                payload["pack_cards"],
289                serde_json::json!([12345, 67890, 11111])
290            );
291        }
292
293        #[test]
294        fn test_try_parse_draft_notify_second_pack() {
295            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
296                         {\n\
297                           \"draftId\": \"draft-456\",\n\
298                           \"SelfPack\": 1,\n\
299                           \"SelfPick\": 5,\n\
300                           \"PackCards\": \"22222,33333,44444,55555\"\n\
301                         }";
302            let entry = unity_entry(body);
303            let result = try_parse(&entry, Some(test_timestamp()));
304
305            assert!(result.is_some());
306            let event = result.as_ref().unwrap_or_else(|| unreachable!());
307            let payload = draft_human_payload(event);
308
309            assert_eq!(payload["pack_number"], 1);
310            assert_eq!(payload["pick_number"], 5);
311            assert_eq!(payload["draft_id"], "draft-456");
312            assert_eq!(
313                payload["pack_cards"],
314                serde_json::json!([22222, 33333, 44444, 55555])
315            );
316        }
317
318        #[test]
319        fn test_try_parse_draft_notify_last_pick_single_card() {
320            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
321                         {\n\
322                           \"draftId\": \"draft-789\",\n\
323                           \"SelfPack\": 2,\n\
324                           \"SelfPick\": 13,\n\
325                           \"PackCards\": \"99999\"\n\
326                         }";
327            let entry = unity_entry(body);
328            let result = try_parse(&entry, Some(test_timestamp()));
329
330            assert!(result.is_some());
331            let event = result.as_ref().unwrap_or_else(|| unreachable!());
332            let payload = draft_human_payload(event);
333
334            assert_eq!(payload["pack_number"], 2);
335            assert_eq!(payload["pick_number"], 13);
336            assert_eq!(payload["pack_cards"], serde_json::json!([99999]));
337        }
338
339        #[test]
340        fn test_try_parse_draft_notify_empty_pack_cards() {
341            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
342                         {\n\
343                           \"draftId\": \"draft-empty\",\n\
344                           \"SelfPack\": 0,\n\
345                           \"SelfPick\": 0,\n\
346                           \"PackCards\": \"\"\n\
347                         }";
348            let entry = unity_entry(body);
349            let result = try_parse(&entry, Some(test_timestamp()));
350
351            assert!(result.is_some());
352            let event = result.as_ref().unwrap_or_else(|| unreachable!());
353            let payload = draft_human_payload(event);
354
355            assert_eq!(payload["pack_cards"], serde_json::json!([]));
356        }
357
358        #[test]
359        fn test_try_parse_draft_notify_array_format_pack_cards() {
360            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
361                         {\n\
362                           \"draftId\": \"draft-arr\",\n\
363                           \"SelfPack\": 0,\n\
364                           \"SelfPick\": 0,\n\
365                           \"PackCards\": [12345, 67890]\n\
366                         }";
367            let entry = unity_entry(body);
368            let result = try_parse(&entry, Some(test_timestamp()));
369
370            assert!(result.is_some());
371            let event = result.as_ref().unwrap_or_else(|| unreachable!());
372            let payload = draft_human_payload(event);
373
374            assert_eq!(payload["pack_cards"], serde_json::json!([12345, 67890]));
375        }
376
377        #[test]
378        fn test_try_parse_draft_notify_missing_draft_id() {
379            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
380                         {\n\
381                           \"SelfPack\": 0,\n\
382                           \"SelfPick\": 0,\n\
383                           \"PackCards\": \"12345\"\n\
384                         }";
385            let entry = unity_entry(body);
386            let result = try_parse(&entry, Some(test_timestamp()));
387
388            assert!(result.is_some());
389            let event = result.as_ref().unwrap_or_else(|| unreachable!());
390            let payload = draft_human_payload(event);
391
392            assert_eq!(payload["draft_id"], "");
393        }
394
395        #[test]
396        fn test_try_parse_draft_notify_preserves_raw_payload() {
397            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
398                         {\n\
399                           \"draftId\": \"draft-raw\",\n\
400                           \"SelfPack\": 0,\n\
401                           \"SelfPick\": 0,\n\
402                           \"PackCards\": \"12345\",\n\
403                           \"ExtraField\": \"preserved\"\n\
404                         }";
405            let entry = unity_entry(body);
406            let result = try_parse(&entry, Some(test_timestamp()));
407
408            assert!(result.is_some());
409            let event = result.as_ref().unwrap_or_else(|| unreachable!());
410            let payload = draft_human_payload(event);
411
412            assert_eq!(payload["raw_draft_notify"]["ExtraField"], "preserved");
413        }
414
415        #[test]
416        fn test_try_parse_draft_notify_with_timestamp_in_header() {
417            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
418                         Draft.Notify\n\
419                         {\n\
420                           \"draftId\": \"draft-ts\",\n\
421                           \"SelfPack\": 0,\n\
422                           \"SelfPick\": 0,\n\
423                           \"PackCards\": \"12345\"\n\
424                         }";
425            let entry = unity_entry(body);
426            let result = try_parse(&entry, Some(test_timestamp()));
427
428            assert!(result.is_some());
429            let event = result.as_ref().unwrap_or_else(|| unreachable!());
430            let payload = draft_human_payload(event);
431
432            assert_eq!(payload["type"], "draft_human_notify");
433        }
434    }
435
436    // -- EventPlayerDraftMakePick parsing ------------------------------------
437
438    mod make_pick {
439        use super::*;
440
441        #[test]
442        fn test_try_parse_make_pick_basic() {
443            let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
444                         {\n\
445                           \"EventName\": \"PremierDraft_MKM_20260201\",\n\
446                           \"PickInfo\": {\n\
447                             \"CardId\": 12345,\n\
448                             \"PackNumber\": 0,\n\
449                             \"PickNumber\": 0\n\
450                           }\n\
451                         }";
452            let entry = unity_entry(body);
453            let result = try_parse(&entry, Some(test_timestamp()));
454
455            assert!(result.is_some());
456            let event = result.as_ref().unwrap_or_else(|| unreachable!());
457            let payload = draft_human_payload(event);
458
459            assert_eq!(payload["type"], "draft_human_pick");
460            assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
461            assert_eq!(payload["card_id"], 12345);
462            assert_eq!(payload["pack_number"], 0);
463            assert_eq!(payload["pick_number"], 0);
464        }
465
466        #[test]
467        fn test_try_parse_make_pick_later_in_draft() {
468            let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
469                         {\n\
470                           \"EventName\": \"TradDraft_DSK_20260115\",\n\
471                           \"PickInfo\": {\n\
472                             \"CardId\": 67890,\n\
473                             \"PackNumber\": 1,\n\
474                             \"PickNumber\": 7\n\
475                           }\n\
476                         }";
477            let entry = unity_entry(body);
478            let result = try_parse(&entry, Some(test_timestamp()));
479
480            assert!(result.is_some());
481            let event = result.as_ref().unwrap_or_else(|| unreachable!());
482            let payload = draft_human_payload(event);
483
484            assert_eq!(payload["card_id"], 67890);
485            assert_eq!(payload["pack_number"], 1);
486            assert_eq!(payload["pick_number"], 7);
487            assert_eq!(payload["event_name"], "TradDraft_DSK_20260115");
488        }
489
490        #[test]
491        fn test_try_parse_make_pick_with_card_ids() {
492            let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
493                         {\n\
494                           \"EventName\": \"PremierDraft_MKM_20260201\",\n\
495                           \"PickInfo\": {\n\
496                             \"CardId\": 11111,\n\
497                             \"PackNumber\": 0,\n\
498                             \"PickNumber\": 0,\n\
499                             \"CardIds\": [11111, 22222, 33333]\n\
500                           }\n\
501                         }";
502            let entry = unity_entry(body);
503            let result = try_parse(&entry, Some(test_timestamp()));
504
505            assert!(result.is_some());
506            let event = result.as_ref().unwrap_or_else(|| unreachable!());
507            let payload = draft_human_payload(event);
508
509            assert_eq!(payload["card_id"], 11111);
510            assert_eq!(
511                payload["card_ids"],
512                serde_json::json!([11111, 22222, 33333])
513            );
514        }
515
516        #[test]
517        fn test_try_parse_make_pick_flat_format() {
518            // Some log versions put fields at the top level instead of
519            // nesting under PickInfo.
520            let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
521                         {\n\
522                           \"CardId\": 55555,\n\
523                           \"PackNumber\": 2,\n\
524                           \"PickNumber\": 10\n\
525                         }";
526            let entry = unity_entry(body);
527            let result = try_parse(&entry, Some(test_timestamp()));
528
529            assert!(result.is_some());
530            let event = result.as_ref().unwrap_or_else(|| unreachable!());
531            let payload = draft_human_payload(event);
532
533            assert_eq!(payload["card_id"], 55555);
534            assert_eq!(payload["pack_number"], 2);
535            assert_eq!(payload["pick_number"], 10);
536        }
537
538        #[test]
539        fn test_try_parse_make_pick_missing_card_id_returns_none() {
540            let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
541                         {\n\
542                           \"PickInfo\": {\n\
543                             \"PackNumber\": 0,\n\
544                             \"PickNumber\": 0\n\
545                           }\n\
546                         }";
547            let entry = unity_entry(body);
548            let result = try_parse(&entry, Some(test_timestamp()));
549
550            assert!(result.is_none());
551        }
552
553        #[test]
554        fn test_try_parse_make_pick_preserves_raw_payload() {
555            let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
556                         {\n\
557                           \"PickInfo\": {\n\
558                             \"CardId\": 12345,\n\
559                             \"PackNumber\": 0,\n\
560                             \"PickNumber\": 0,\n\
561                             \"ExtraField\": \"kept\"\n\
562                           }\n\
563                         }";
564            let entry = unity_entry(body);
565            let result = try_parse(&entry, Some(test_timestamp()));
566
567            assert!(result.is_some());
568            let event = result.as_ref().unwrap_or_else(|| unreachable!());
569            let payload = draft_human_payload(event);
570
571            assert_eq!(payload["raw_make_pick"]["PickInfo"]["ExtraField"], "kept");
572        }
573
574        #[test]
575        fn test_try_parse_make_pick_outbound_request_format() {
576            let body = "[UnityCrossThreadLogger]==> EventPlayerDraftMakePick\n\
577                         {\n\
578                           \"id\": \"b0114c5d-0462-4855-a7ab-d06ede720f93\",\n\
579                           \"request\": \"{\\\"DraftId\\\":\\\"0784e646\\\",\\\"GrpIds\\\":[100486],\\\"Pack\\\":1,\\\"Pick\\\":2}\"\n\
580                         }";
581            let entry = unity_entry(body);
582            let result = try_parse(&entry, Some(test_timestamp()));
583
584            assert!(result.is_some());
585            let event = result.as_ref().unwrap_or_else(|| unreachable!());
586            let payload = draft_human_payload(event);
587
588            assert_eq!(payload["type"], "draft_human_pick");
589            assert_eq!(payload["card_id"], 100_486);
590            assert_eq!(payload["pack_number"], 1);
591            assert_eq!(payload["pick_number"], 2);
592            assert_eq!(payload["draft_id"], "0784e646");
593            assert_eq!(payload["event_name"], ""); // No event name in this format
594            assert_eq!(
595                payload["raw_make_pick"]["id"],
596                "b0114c5d-0462-4855-a7ab-d06ede720f93"
597            );
598        }
599
600        #[test]
601        fn test_try_parse_make_pick_ignores_success_response() {
602            let body = "[UnityCrossThreadLogger]3/11/2026 9:44:16 PM\n\
603                         <== EventPlayerDraftMakePick(b0114c5d-0462-4855-a7ab-d06ede720f93)\n\
604                         {\"IsPickSuccessful\":true}";
605            let entry = unity_entry(body);
606            let result = try_parse(&entry, Some(test_timestamp()));
607
608            assert!(result.is_none());
609        }
610
611        #[test]
612        fn test_try_parse_make_pick_with_timestamp_in_header() {
613            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
614                         EventPlayerDraftMakePick\n\
615                         {\n\
616                           \"PickInfo\": {\n\
617                             \"CardId\": 77777,\n\
618                             \"PackNumber\": 0,\n\
619                             \"PickNumber\": 1\n\
620                           }\n\
621                         }";
622            let entry = unity_entry(body);
623            let result = try_parse(&entry, Some(test_timestamp()));
624
625            assert!(result.is_some());
626            let event = result.as_ref().unwrap_or_else(|| unreachable!());
627            let payload = draft_human_payload(event);
628
629            assert_eq!(payload["card_id"], 77777);
630        }
631    }
632
633    // -- Metadata preservation -----------------------------------------------
634
635    mod metadata {
636        use super::*;
637
638        #[test]
639        fn test_try_parse_preserves_raw_bytes_notify() {
640            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
641                         {\"SelfPack\": 0, \"SelfPick\": 0, \
642                          \"PackCards\": \"12345\"}";
643            let entry = unity_entry(body);
644            let result = try_parse(&entry, Some(test_timestamp()));
645
646            assert!(result.is_some());
647            let event = result.as_ref().unwrap_or_else(|| unreachable!());
648            assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
649        }
650
651        #[test]
652        fn test_try_parse_preserves_raw_bytes_make_pick() {
653            let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
654                         {\"PickInfo\": {\"CardId\": 1, \"PackNumber\": 0, \
655                          \"PickNumber\": 0}}";
656            let entry = unity_entry(body);
657            let result = try_parse(&entry, Some(test_timestamp()));
658
659            assert!(result.is_some());
660            let event = result.as_ref().unwrap_or_else(|| unreachable!());
661            assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
662        }
663
664        #[test]
665        fn test_try_parse_stores_timestamp_notify() {
666            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
667                         {\"SelfPack\": 0, \"SelfPick\": 0, \
668                          \"PackCards\": \"12345\"}";
669            let entry = unity_entry(body);
670            let ts = Some(test_timestamp());
671            let result = try_parse(&entry, ts);
672
673            assert!(result.is_some());
674            let event = result.as_ref().unwrap_or_else(|| unreachable!());
675            assert_eq!(event.metadata().timestamp(), ts);
676        }
677
678        #[test]
679        fn test_try_parse_stores_timestamp_make_pick() {
680            let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
681                         {\"PickInfo\": {\"CardId\": 1, \"PackNumber\": 0, \
682                          \"PickNumber\": 0}}";
683            let entry = unity_entry(body);
684            let ts = Some(test_timestamp());
685            let result = try_parse(&entry, ts);
686
687            assert!(result.is_some());
688            let event = result.as_ref().unwrap_or_else(|| unreachable!());
689            assert_eq!(event.metadata().timestamp(), ts);
690        }
691    }
692
693    // -- Non-matching entries (should return None) ----------------------------
694
695    mod non_matching {
696        use super::*;
697
698        #[test]
699        fn test_try_parse_unrelated_entry_returns_none() {
700            let body = "[UnityCrossThreadLogger]greToClientEvent\n{\"data\": 1}";
701            let entry = unity_entry(body);
702            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
703        }
704
705        #[test]
706        fn test_try_parse_empty_body_returns_none() {
707            let body = "[UnityCrossThreadLogger]";
708            let entry = unity_entry(body);
709            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
710        }
711
712        #[test]
713        fn test_try_parse_bot_draft_entry_returns_none() {
714            // Bot draft entries should not be parsed by the human draft parser.
715            let body = "[UnityCrossThreadLogger]BotDraft_DraftPick\n\
716                         {\n\
717                           \"PickInfo\": {\n\
718                             \"CardId\": 12345,\n\
719                             \"PackNumber\": 0,\n\
720                             \"PickNumber\": 0\n\
721                           }\n\
722                         }";
723            let entry = unity_entry(body);
724            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
725        }
726
727        #[test]
728        fn test_try_parse_game_result_business_event_returns_none() {
729            // Game result LogBusinessEvents (with WinningType) should not
730            // match the human draft parser.
731            let body = "[UnityCrossThreadLogger]LogBusinessEvents\n\
732                         {\n\
733                           \"WinningType\": \"WinLoss\",\n\
734                           \"WinningTeamId\": 1\n\
735                         }";
736            let entry = unity_entry(body);
737            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
738        }
739
740        #[test]
741        fn test_try_parse_malformed_json_notify_returns_none() {
742            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
743                         {\"PackCards\": broken!!!}";
744            let entry = unity_entry(body);
745            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
746        }
747
748        #[test]
749        fn test_try_parse_malformed_json_make_pick_returns_none() {
750            let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
751                         {not valid json}";
752            let entry = unity_entry(body);
753            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
754        }
755
756        #[test]
757        fn test_try_parse_malformed_json_business_event_returns_none() {
758            let body = "[UnityCrossThreadLogger]LogBusinessEvents\n\
759                         {\"PickGrpId\": broken!!!}";
760            let entry = unity_entry(body);
761            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
762        }
763
764        #[test]
765        fn test_try_parse_marker_only_no_json_notify_returns_none() {
766            let body = "[UnityCrossThreadLogger]Draft.Notify";
767            let entry = unity_entry(body);
768            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
769        }
770
771        #[test]
772        fn test_try_parse_marker_only_no_json_make_pick_returns_none() {
773            let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick";
774            let entry = unity_entry(body);
775            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
776        }
777
778        #[test]
779        fn test_try_parse_draft_notify_no_pack_or_self_pack_returns_none() {
780            // Draft.Notify marker in text but JSON has no characteristic fields.
781            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
782                         {\"unrelatedField\": \"value\"}";
783            let entry = unity_entry(body);
784            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
785        }
786
787        #[test]
788        fn test_try_parse_connection_manager_header_returns_none() {
789            let entry = LogEntry {
790                header: EntryHeader::ConnectionManager,
791                body: "[ConnectionManager]some connection message".to_owned(),
792            };
793            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
794        }
795    }
796
797    // -- Performance class ---------------------------------------------------
798
799    mod performance_class {
800        use super::*;
801
802        #[test]
803        fn test_draft_human_notify_is_durable_per_event() {
804            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
805                         {\"SelfPack\": 0, \"SelfPick\": 0, \
806                          \"PackCards\": \"12345\"}";
807            let entry = unity_entry(body);
808            let result = try_parse(&entry, Some(test_timestamp()));
809
810            assert!(result.is_some());
811            let event = result.as_ref().unwrap_or_else(|| unreachable!());
812            assert_eq!(event.performance_class(), PerformanceClass::DurablePerEvent);
813        }
814
815        #[test]
816        fn test_draft_human_pick_is_durable_per_event() {
817            let body = "[UnityCrossThreadLogger]EventPlayerDraftMakePick\n\
818                         {\"PickInfo\": {\"CardId\": 1, \"PackNumber\": 0, \
819                          \"PickNumber\": 0}}";
820            let entry = unity_entry(body);
821            let result = try_parse(&entry, Some(test_timestamp()));
822
823            assert!(result.is_some());
824            let event = result.as_ref().unwrap_or_else(|| unreachable!());
825            assert_eq!(event.performance_class(), PerformanceClass::DurablePerEvent);
826        }
827    }
828
829    // -- Internal helpers ----------------------------------------------------
830
831    mod helpers {
832        use super::*;
833
834        #[test]
835        fn test_parse_comma_separated_ids_basic() {
836            assert_eq!(
837                parse_comma_separated_ids("12345,67890,11111"),
838                vec![12345, 67890, 11111]
839            );
840        }
841
842        #[test]
843        fn test_parse_comma_separated_ids_with_spaces() {
844            assert_eq!(
845                parse_comma_separated_ids("12345, 67890, 11111"),
846                vec![12345, 67890, 11111]
847            );
848        }
849
850        #[test]
851        fn test_parse_comma_separated_ids_single() {
852            assert_eq!(parse_comma_separated_ids("12345"), vec![12345]);
853        }
854
855        #[test]
856        fn test_parse_comma_separated_ids_empty() {
857            let result: Vec<i64> = parse_comma_separated_ids("");
858            assert!(result.is_empty());
859        }
860
861        #[test]
862        fn test_parse_comma_separated_ids_with_invalid() {
863            assert_eq!(
864                parse_comma_separated_ids("12345,abc,67890"),
865                vec![12345, 67890]
866            );
867        }
868    }
869}