Skip to main content

manasight_parser/parsers/draft/
bot.rs

1//! Bot draft parser for Quick Draft (`BotDraftDraftStatus` and
2//! `BotDraftDraftPick`) events.
3//!
4//! In Quick Draft (bot draft), the player drafts against AI opponents.
5//! Two API method names capture the draft flow:
6//!
7//! | Direction | Signature | Meaning |
8//! |-----------|-----------|---------|
9//! | Response (`<==`) | `BotDraftDraftStatus` | Initial pack (Pack 0, Pick 0) |
10//! | Request (`==>`) | `BotDraftDraftPick`   | Card selected |
11//! | Response (`<==`) | `BotDraftDraftPick`   | Card selected and next pack |
12//!
13//! **Key Fields:**
14//!
15//! - `BotDraftDraftStatus`: `Payload` { `EventName`, `DraftStatus`,
16//!   `PackNumber`, `PickNumber`, `DraftPack` }
17//! - `BotDraftDraftPick` (Request): `request` { `EventName`, `PickInfo` {
18//!   `EventName`, `CardIds`, `PackNumber`, `PickNumber` } }
19//! - `BotDraftDraftPick` (Response): `Payload` { `EventName`, `DraftStatus`,
20//!   `PackNumber`, `PickNumber`, `DraftPack`, `PickedCards` }
21//!
22//! Quick Draft has 3 packs of 14 picks each (42 total picks). Pack and
23//! pick numbers are zero-indexed in the log.
24//!
25//! Both events are Class 2 (Durable Per-Event) -- each pick is
26//! independently valuable and must survive crashes.
27
28use crate::events::{DraftBotEvent, EventMetadata, GameEvent};
29use crate::log::entry::LogEntry;
30use crate::parsers::api_common;
31
32/// Marker for bot draft status events.
33///
34/// This marker is used for the initial pack presentation (Pack 0, Pick 0).
35const DRAFT_STATUS_MARKER: &str = "BotDraftDraftStatus";
36
37/// Marker for bot draft pick events.
38///
39/// This marker is used both for the player's pick (request) and for presenting
40/// subsequent packs (response).
41const DRAFT_PICK_MARKER: &str = "BotDraftDraftPick";
42
43/// Attempts to parse a [`LogEntry`] as a bot draft event.
44///
45/// Returns `Some(GameEvent::DraftBot(_))` if the entry matches either:
46/// - A pack presentation (`BotDraftDraftStatus` or `BotDraftDraftPick` response).
47/// - A pick confirmation (`BotDraftDraftPick` request).
48///
49/// Returns `None` if the entry does not match either signature.
50///
51/// The `timestamp` is `None` when the log entry header did not contain a
52/// parseable timestamp. It is passed through to [`EventMetadata`] so
53/// downstream consumers can distinguish real vs missing timestamps.
54pub fn try_parse(
55    entry: &LogEntry,
56    timestamp: Option<chrono::DateTime<chrono::Utc>>,
57) -> Option<GameEvent> {
58    let body = &entry.body;
59
60    // Try bot draft pack presentation first.
61    if let Some(payload) = try_parse_pack_presentation(body) {
62        let metadata = EventMetadata::new(timestamp, body.as_bytes().to_vec());
63        return Some(GameEvent::DraftBot(DraftBotEvent::new(metadata, payload)));
64    }
65
66    // Try bot draft pick confirmation.
67    if let Some(payload) = try_parse_draft_pick(body) {
68        let metadata = EventMetadata::new(timestamp, body.as_bytes().to_vec());
69        return Some(GameEvent::DraftBot(DraftBotEvent::new(metadata, payload)));
70    }
71
72    None
73}
74
75/// Attempts to parse a `BotDraftDraftStatus` or `BotDraftDraftPick` pack
76/// presentation response.
77///
78/// Returns `Some(serde_json::Value)` if parsing succeeds, otherwise `None`.
79///
80/// The log entry body must be an API response whose string-escaped `Payload`
81/// field contains:
82/// - `DraftStatus`: must be `"PickNext"` (required; otherwise returns `None`)
83/// - `DraftPack`: array of card GRP IDs available in the pack
84///
85/// The following are extracted on a best-effort basis and default when absent:
86/// - `PackNumber`: zero-indexed pack number (defaults to `0`)
87/// - `PickNumber`: zero-indexed pick number within the pack (defaults to `0`)
88/// - `EventName`: the Arena event identifier, e.g., `"QuickDraft_MKM_20260201"`
89///   (defaults to `""`)
90fn try_parse_pack_presentation(body: &str) -> Option<serde_json::Value> {
91    let parsed = if api_common::is_api_response(body, DRAFT_STATUS_MARKER) {
92        let top = api_common::parse_json_from_body(body, DRAFT_STATUS_MARKER)?;
93        api_common::parse_nested_json(&top, "Payload", Some(DRAFT_STATUS_MARKER))
94    } else if api_common::is_api_response(body, DRAFT_PICK_MARKER) {
95        let top = api_common::parse_json_from_body(body, DRAFT_PICK_MARKER)?;
96        api_common::parse_nested_json(&top, "Payload", Some(DRAFT_PICK_MARKER))
97    } else {
98        None
99    }?;
100
101    // Check if this is a pack presentation.
102    let status_val = parsed.get("DraftStatus")?;
103    if status_val.as_str() != Some("PickNext") {
104        return None;
105    }
106
107    let pack_idx = parsed
108        .get("PackNumber")
109        .and_then(serde_json::Value::as_i64)
110        .unwrap_or(0);
111
112    let selection_idx = parsed
113        .get("PickNumber")
114        .and_then(serde_json::Value::as_i64)
115        .unwrap_or(0);
116
117    let event_name = api_common::extract_event_name(&parsed);
118
119    // DraftPack is an array of card GRP IDs (integers) as strings.
120    let draft_pack = extract_draft_pack(&parsed);
121
122    Some(serde_json::json!({
123        "type": "draft_bot_pack",
124        "event_name": event_name,
125        "pack_number": pack_idx,
126        "pick_number": selection_idx,
127        "draft_pack": draft_pack,
128        "raw_draft_status": parsed,
129    }))
130}
131
132/// Attempts to parse a `BotDraftDraftPick` pick confirmation.
133///
134/// Returns `Some(serde_json::Value)` if parsing succeeds.
135///
136/// Returns `None` if the entry is not a `BotDraftDraftPick` request, if the
137/// nested `request` / `PickInfo` payload is missing, if `CardIds` is empty
138/// or its first entry is `0` (a sentinel for "no card resolved"), or if
139/// parsing fails.
140///
141/// The log entry body must be an API request whose string-escaped `request`
142/// field contains `PickInfo` with:
143/// - `CardIds`: the first GRP ID is treated as the selected card
144/// - `PackNumber`: zero-indexed pack number
145/// - `PickNumber`: zero-indexed pick number within the pack
146fn try_parse_draft_pick(body: &str) -> Option<serde_json::Value> {
147    let parsed = if api_common::is_api_request(body, DRAFT_PICK_MARKER) {
148        let top = api_common::parse_json_from_body(body, DRAFT_PICK_MARKER)?;
149        api_common::parse_nested_json(&top, "request", Some(DRAFT_PICK_MARKER))
150    } else {
151        None
152    }?;
153
154    let pick_info = parsed.get("PickInfo")?;
155
156    // Ignore envelopes that do not actually carry pick fields.
157    if pick_info.get("CardIds").is_none()
158        && pick_info.get("PackNumber").is_none()
159        && pick_info.get("PickNumber").is_none()
160    {
161        return None;
162    }
163
164    let pack_idx = pick_info
165        .get("PackNumber")
166        .and_then(serde_json::Value::as_i64)
167        .unwrap_or(0);
168
169    let selection_idx = pick_info
170        .get("PickNumber")
171        .and_then(serde_json::Value::as_i64)
172        .unwrap_or(0);
173
174    let card_ids: Vec<i64> = pick_info
175        .get("CardIds")
176        .and_then(|v| v.as_array())
177        .map(|arr| {
178            arr.iter()
179                .filter_map(|v| {
180                    v.as_i64()
181                        .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
182                })
183                .collect()
184        })
185        .unwrap_or_default();
186
187    let card_id = *card_ids.first()?;
188    if card_id == 0 {
189        return None;
190    }
191
192    let event_name = api_common::extract_event_name(&parsed);
193
194    Some(serde_json::json!({
195        "type": "draft_bot_pick",
196        "event_name": event_name,
197        "card_id": card_id,
198        "pack_number": pack_idx,
199        "pick_number": selection_idx,
200        "raw_pick_info": parsed,
201    }))
202}
203
204/// Extracts the `DraftPack` array from a pack presentation payload.
205///
206/// `DraftPack` may contain card GRP IDs as strings (e.g., `["12345", "67890"]`)
207/// or as integers. This function normalizes them to a `Vec<i64>`.
208fn extract_draft_pack(parsed: &serde_json::Value) -> Vec<i64> {
209    let Some(pack) = parsed.get("DraftPack").and_then(|v| v.as_array()) else {
210        return Vec::new();
211    };
212
213    pack.iter()
214        .filter_map(|v| {
215            // Try integer first, then string-encoded integer.
216            v.as_i64()
217                .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
218        })
219        .collect()
220}
221
222// ---------------------------------------------------------------------------
223// Tests
224// ---------------------------------------------------------------------------
225
226#[cfg(test)]
227#[allow(deprecated)]
228mod tests {
229    use super::*;
230    use crate::events::PerformanceClass;
231    use crate::parsers::test_helpers::{
232        draft_bot_payload, test_timestamp, unity_entry, EntryHeader,
233    };
234
235    // -- Pack presentation parsing (BotDraftDraftStatus, BotDraftDraftPick) -
236
237    mod pack_presentation {
238        use super::*;
239
240        #[test]
241        fn test_try_parse_pack_presentation_basic() {
242            let body = "[UnityCrossThreadLogger]2/01/2026 10:23:51 AM\n\
243            <== BotDraftDraftStatus(uuid)\n\
244            {\"Payload\":\"{\\\"EventName\\\":\\\"QuickDraft_MKM_20260201\\\",\\\"DraftStatus\\\":\\\"PickNext\\\",\\\"PackNumber\\\":0,\\\"PickNumber\\\":0,\\\"DraftPack\\\":[\\\"12345\\\",\\\"67890\\\",\\\"11111\\\"]}\"}";
245            let entry = unity_entry(body);
246            let result = try_parse(&entry, Some(test_timestamp()));
247
248            assert!(result.is_some());
249            let event = result.as_ref().unwrap_or_else(|| unreachable!());
250            let payload = draft_bot_payload(event);
251
252            assert_eq!(payload["type"], "draft_bot_pack");
253            assert_eq!(payload["event_name"], "QuickDraft_MKM_20260201");
254            assert_eq!(payload["pack_number"], 0);
255            assert_eq!(payload["pick_number"], 0);
256            assert_eq!(
257                payload["draft_pack"],
258                serde_json::json!([12345, 67890, 11111])
259            );
260        }
261
262        #[test]
263        fn test_try_parse_pack_presentation_second_pack() {
264            let body = "[UnityCrossThreadLogger]1/18/2026 8:42:01 PM\n\
265            <== BotDraftDraftPick(uuid)\n\
266            {\"Payload\":\"{\\\"EventName\\\":\\\"QuickDraft_DSK_20260115\\\",\\\"DraftStatus\\\":\\\"PickNext\\\",\\\"PackNumber\\\":1,\\\"PickNumber\\\":3,\\\"DraftPack\\\":[\\\"22222\\\",\\\"33333\\\"]}\"}";
267            let entry = unity_entry(body);
268            let result = try_parse(&entry, Some(test_timestamp()));
269
270            assert!(result.is_some());
271            let event = result.as_ref().unwrap_or_else(|| unreachable!());
272            let payload = draft_bot_payload(event);
273
274            assert_eq!(payload["pack_number"], 1);
275            assert_eq!(payload["pick_number"], 3);
276            assert_eq!(payload["event_name"], "QuickDraft_DSK_20260115");
277            assert_eq!(payload["draft_pack"], serde_json::json!([22222, 33333]));
278        }
279
280        #[test]
281        fn test_try_parse_pack_presentation_third_pack_last_pick() {
282            let body = "[UnityCrossThreadLogger]2/12/2026 1:11:11 PM\n\
283            <== BotDraftDraftPick(uuid)\n\
284            {\"Payload\":\"{\\\"EventName\\\":\\\"QuickDraft_MKM_20260201\\\",\\\"DraftStatus\\\":\\\"PickNext\\\",\\\"PackNumber\\\":2,\\\"PickNumber\\\":13,\\\"DraftPack\\\":[\\\"44444\\\"]}\"}";
285            let entry = unity_entry(body);
286            let result = try_parse(&entry, Some(test_timestamp()));
287
288            assert!(result.is_some());
289            let event = result.as_ref().unwrap_or_else(|| unreachable!());
290            let payload = draft_bot_payload(event);
291
292            assert_eq!(payload["pack_number"], 2);
293            assert_eq!(payload["pick_number"], 13);
294            assert_eq!(payload["draft_pack"], serde_json::json!([44444]));
295        }
296
297        #[test]
298        fn test_try_parse_pack_presentation_integer_card_ids() {
299            let body = "[UnityCrossThreadLogger]2/01/2026 10:23:51 AM\n\
300            <== BotDraftDraftStatus(uuid)\n\
301            {\"Payload\":\"{\\\"DraftStatus\\\":\\\"PickNext\\\",\\\"PackNumber\\\":0,\\\"PickNumber\\\":0,\\\"DraftPack\\\":[12345, 67890]}\"}";
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_bot_payload(event);
308
309            assert_eq!(payload["draft_pack"], serde_json::json!([12345, 67890]));
310        }
311
312        #[test]
313        fn test_try_parse_pack_presentation_empty_pack() {
314            let body = "[UnityCrossThreadLogger]2/12/2026 1:11:11 PM\n\
315            <== BotDraftDraftPick(uuid)\n\
316            {\"Payload\":\"{\\\"DraftStatus\\\":\\\"PickNext\\\",\\\"PackNumber\\\":0,\\\"PickNumber\\\":0,\\\"DraftPack\\\":[]}\"}";
317            let entry = unity_entry(body);
318            let result = try_parse(&entry, Some(test_timestamp()));
319
320            assert!(result.is_some());
321            let event = result.as_ref().unwrap_or_else(|| unreachable!());
322            let payload = draft_bot_payload(event);
323
324            assert_eq!(payload["draft_pack"], serde_json::json!([]));
325        }
326
327        #[test]
328        fn test_try_parse_pack_presentation_missing_draft_pack() {
329            let body = "[UnityCrossThreadLogger]2/12/2026 1:11:11 PM\n\
330            <== BotDraftDraftPick(uuid)\n\
331            {\"Payload\":\"{\\\"DraftStatus\\\":\\\"PickNext\\\",\\\"PackNumber\\\":0,\\\"PickNumber\\\":0}\"}";
332            let entry = unity_entry(body);
333            let result = try_parse(&entry, Some(test_timestamp()));
334
335            assert!(result.is_some());
336            let event = result.as_ref().unwrap_or_else(|| unreachable!());
337            let payload = draft_bot_payload(event);
338
339            assert_eq!(payload["draft_pack"], serde_json::json!([]));
340        }
341
342        #[test]
343        fn test_try_parse_pack_presentation_preserves_raw_payload() {
344            let body = "[UnityCrossThreadLogger]2/01/2026 10:23:51 AM\n\
345            <== BotDraftDraftStatus(uuid)\n\
346            {\n\
347                \"Payload\":\"{\\\"DraftStatus\\\":\\\"PickNext\\\",\\\"PackNumber\\\":0,\\\"PickNumber\\\":0,\\\"DraftPack\\\":[\\\"11111\\\"],\\\"ExtraField\\\":\\\"preserved\\\"}\"
348            }";
349            let entry = unity_entry(body);
350            let result = try_parse(&entry, Some(test_timestamp()));
351
352            assert!(result.is_some());
353            let event = result.as_ref().unwrap_or_else(|| unreachable!());
354            let payload = draft_bot_payload(event);
355
356            assert_eq!(payload["raw_draft_status"]["ExtraField"], "preserved");
357        }
358    }
359
360    // -- Draft pick parsing (BotDraftDraftPick) -----------------------------
361
362    mod draft_pick {
363        use super::*;
364
365        #[test]
366        fn test_try_parse_draft_pick_returns_pick() {
367            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"EventName\":\"QuickDraft_TMT_20260313\",\"PickInfo\":{\"EventName\":\"QuickDraft_TMT_20260313\",\"CardIds\":[\"12345\"],\"PackNumber\":0,\"PickNumber\":0}}"}"#;
368            let entry = unity_entry(body);
369            let result = try_parse(&entry, Some(test_timestamp()));
370
371            assert!(result.is_some());
372            let event = result.as_ref().unwrap_or_else(|| unreachable!());
373            let payload = draft_bot_payload(event);
374
375            assert_eq!(payload["type"], "draft_bot_pick");
376            assert_eq!(payload["card_id"], 12345);
377            assert_eq!(payload["pack_number"], 0);
378            assert_eq!(payload["pick_number"], 0);
379        }
380
381        #[test]
382        fn test_try_parse_draft_pick_later_in_draft() {
383            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"EventName\":\"QuickDraft_TMT_20260313\",\"PickInfo\":{\"EventName\":\"QuickDraft_TMT_20260313\",\"CardIds\":[\"67890\"],\"PackNumber\":1,\"PickNumber\":7}}"}"#;
384            let entry = unity_entry(body);
385            let result = try_parse(&entry, Some(test_timestamp()));
386
387            assert!(result.is_some());
388            let event = result.as_ref().unwrap_or_else(|| unreachable!());
389            let payload = draft_bot_payload(event);
390
391            assert_eq!(payload["card_id"], 67890);
392            assert_eq!(payload["pack_number"], 1);
393            assert_eq!(payload["pick_number"], 7);
394        }
395
396        #[test]
397        fn test_try_parse_draft_pick_event_name_in_request_root_returns_name() {
398            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"EventName\":\"QuickDraft_SOS_20260430\",\"PickInfo\":{\"CardIds\":[\"12345\"],\"PackNumber\":0,\"PickNumber\":0}}"}"#;
399            let entry = unity_entry(body);
400            let result = try_parse(&entry, Some(test_timestamp()));
401
402            assert!(result.is_some());
403            let event = result.as_ref().unwrap_or_else(|| unreachable!());
404            let payload = draft_bot_payload(event);
405
406            assert_eq!(payload["event_name"], "QuickDraft_SOS_20260430");
407        }
408
409        #[test]
410        fn test_try_parse_draft_pick_event_name_in_pick_info_returns_name() {
411            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"PickInfo\":{\"EventName\":\"QuickDraft_SOS_20260430\",\"CardIds\":[\"12345\"],\"PackNumber\":0,\"PickNumber\":0}}"}"#;
412            let entry = unity_entry(body);
413            let result = try_parse(&entry, Some(test_timestamp()));
414
415            assert!(result.is_some());
416            let event = result.as_ref().unwrap_or_else(|| unreachable!());
417            let payload = draft_bot_payload(event);
418
419            assert_eq!(payload["event_name"], "QuickDraft_SOS_20260430");
420        }
421
422        #[test]
423        fn test_try_parse_draft_pick_missing_event_name_defaults_to_empty() {
424            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"PickInfo\":{\"CardIds\":[\"98546\"],\"PackNumber\":0,\"PickNumber\":0}}"}"#;
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_bot_payload(event);
431
432            assert_eq!(payload["event_name"], "");
433        }
434
435        #[test]
436        fn test_try_parse_draft_pick_missing_card_id_returns_none() {
437            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"PickInfo\":{\"EventName\":\"Test\",\"PackNumber\":0,\"PickNumber\":0}}"}"#;
438            let entry = unity_entry(body);
439            let result = try_parse(&entry, Some(test_timestamp()));
440
441            assert!(result.is_none());
442        }
443
444        #[test]
445        fn test_try_parse_draft_pick_preserves_raw_payload() {
446            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"PickInfo\":{\"EventName\":\"Test\",\"CardIds\":[\"12345\"],\"PackNumber\":0,\"PickNumber\":0},\"ExtraField\":\"kept\"}"}"#;
447            let entry = unity_entry(body);
448            let result = try_parse(&entry, Some(test_timestamp()));
449
450            assert!(result.is_some());
451            let event = result.as_ref().unwrap_or_else(|| unreachable!());
452            let payload = draft_bot_payload(event);
453
454            assert_eq!(payload["raw_pick_info"]["ExtraField"], "kept");
455        }
456    }
457
458    // -- Metadata preservation -----------------------------------------------
459
460    mod metadata {
461        use super::*;
462
463        #[test]
464        fn test_try_parse_preserves_raw_bytes_pack() {
465            let body = "[UnityCrossThreadLogger]2/01/2026 10:23:51 AM\n\
466            <== BotDraftDraftStatus(uuid)\n\
467            {\"Payload\":\"{\\\"DraftStatus\\\":\\\"PickNext\\\",\\\"PackNumber\\\":0,\\\"PickNumber\\\":0,\\\"DraftPack\\\":[]}\"}";
468            let entry = unity_entry(body);
469            let result = try_parse(&entry, Some(test_timestamp()));
470
471            assert!(result.is_some());
472            let event = result.as_ref().unwrap_or_else(|| unreachable!());
473            assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
474        }
475
476        #[test]
477        fn test_try_parse_preserves_raw_bytes_pick() {
478            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"PickInfo\":{\"CardIds\":[\"1\"],\"PackNumber\":0,\"PickNumber\":0}}"}"#;
479            let entry = unity_entry(body);
480            let result = try_parse(&entry, Some(test_timestamp()));
481
482            assert!(result.is_some());
483            let event = result.as_ref().unwrap_or_else(|| unreachable!());
484            assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
485        }
486
487        #[test]
488        fn test_try_parse_stores_timestamp_pack() {
489            let body = "[UnityCrossThreadLogger]2/01/2026 10:23:51 AM\n\
490            <== BotDraftDraftStatus(uuid)\n\
491            {\"Payload\":\"{\\\"DraftStatus\\\":\\\"PickNext\\\",\\\"PackNumber\\\":0,\\\"PickNumber\\\":0,\\\"DraftPack\\\":[]}\"}";
492            let entry = unity_entry(body);
493            let ts = Some(test_timestamp());
494            let result = try_parse(&entry, ts);
495
496            assert!(result.is_some());
497            let event = result.as_ref().unwrap_or_else(|| unreachable!());
498            assert_eq!(event.metadata().timestamp(), ts);
499        }
500
501        #[test]
502        fn test_try_parse_stores_timestamp_pick() {
503            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"PickInfo\":{\"CardIds\":[\"1\"],\"PackNumber\":0,\"PickNumber\":0}}"}"#;
504            let entry = unity_entry(body);
505            let ts = Some(test_timestamp());
506            let result = try_parse(&entry, ts);
507
508            assert!(result.is_some());
509            let event = result.as_ref().unwrap_or_else(|| unreachable!());
510            assert_eq!(event.metadata().timestamp(), ts);
511        }
512    }
513
514    // -- Non-matching entries (should return None) ----------------------------
515
516    mod non_matching {
517        use super::*;
518
519        #[test]
520        fn test_try_parse_unrelated_entry_returns_none() {
521            let body = "[UnityCrossThreadLogger]greToClientEvent\n{\"data\": 1}";
522            let entry = unity_entry(body);
523            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
524        }
525
526        #[test]
527        fn test_try_parse_empty_body_returns_none() {
528            let body = "[UnityCrossThreadLogger]";
529            let entry = unity_entry(body);
530            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
531        }
532
533        #[test]
534        fn test_try_parse_draft_status_not_pick_next_returns_none() {
535            let body = "[UnityCrossThreadLogger]2/26/2026 1:11:11 PM\n\
536                        <== BotDraftDraftStatus(uuid)\n\
537                        {\"Payload\":\"{\\\"DraftStatus\\\":\\\"Completed\\\",\\\"PackNumber\\\":2,\\\"PickNumber\\\":13,\\\"DraftPack\\\":[]}\"}";
538            let entry = unity_entry(body);
539            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
540        }
541
542        #[test]
543        fn test_try_parse_pack_presentation_wrong_status_returns_none() {
544            let body = "[UnityCrossThreadLogger]2/26/2026 1:11:11 PM\n\
545                        <== BotDraftDraftPick(uuid)\n\
546                        {\"Payload\":\"{\\\"DraftStatus\\\":\\\"Completed\\\",\\\"PackNumber\\\":2,\\\"PickNumber\\\":13,\\\"DraftPack\\\":[]}\"}";
547            let entry = unity_entry(body);
548            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
549        }
550
551        #[test]
552        fn test_try_parse_malformed_json_returns_none() {
553            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"PickInfo\":broken!!!}"}"#;
554            let entry = unity_entry(body);
555            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
556        }
557
558        #[test]
559        fn test_try_parse_marker_only_no_json_returns_none() {
560            let body = "[UnityCrossThreadLogger]==> BotDraftDraftPick";
561            let entry = unity_entry(body);
562            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
563        }
564
565        #[test]
566        fn test_try_parse_missing_pick_fields_returns_none() {
567            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"PickInfo\":{}}"}"#;
568            let entry = unity_entry(body);
569            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
570        }
571
572        #[test]
573        fn test_try_parse_empty_card_ids_returns_none() {
574            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"PickInfo\":{\"CardIds\":[],\"PackNumber\":0,\"PickNumber\":0}}"}"#;
575            let entry = unity_entry(body);
576            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
577        }
578
579        #[test]
580        fn test_try_parse_zero_card_id_returns_none() {
581            // GRP ID 0 is a sentinel for "no card resolved" and is never a
582            // valid MTGA card; the parser must drop these envelopes.
583            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"PickInfo\":{\"CardIds\":[\"0\"],\"PackNumber\":0,\"PickNumber\":0}}"}"#;
584            let entry = unity_entry(body);
585            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
586        }
587
588        #[test]
589        fn test_try_parse_draft_status_marker_in_text_only_returns_none() {
590            // The text mentions DraftStatus but no valid JSON payload.
591            let body = "[UnityCrossThreadLogger]2/26/2026 1:11:11 PM\n\
592                        <== BotDraftDraftStatus(uuid)\n\
593                        DraftStatus is PickNext";
594            let entry = unity_entry(body);
595            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
596        }
597
598        #[test]
599        fn test_try_parse_draft_pick_missing_payload_returns_none() {
600            let body = "[UnityCrossThreadLogger]2/26/2026 1:11:11 PM\n\
601                        <== BotDraftDraftPick(uuid)\n\
602                         {\n\
603                           \"Result\": \"Success\"\n\
604                         }";
605            let entry = unity_entry(body);
606            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
607        }
608
609        #[test]
610        fn test_try_parse_draft_pick_malformed_payload_returns_none() {
611            let body = "[UnityCrossThreadLogger]2/26/2026 1:11:11 PM\n\
612                         <== BotDraftDraftPick(uuid)\n\
613                         {\n\
614                           \"Payload\": \"not json\"\n\
615                         }";
616            let entry = unity_entry(body);
617            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
618        }
619
620        #[test]
621        fn test_try_parse_human_draft_entry_returns_none() {
622            // Human draft entries should not be parsed by the bot draft parser.
623            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
624                         {\n\
625                           \"draftId\": \"abc-123\",\n\
626                           \"SelfPack\": 0,\n\
627                           \"SelfPick\": 0,\n\
628                           \"PackCards\": \"12345,67890\"\n\
629                         }";
630            let entry = unity_entry(body);
631            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
632        }
633
634        #[test]
635        fn test_try_parse_connection_manager_header_returns_none() {
636            let entry = LogEntry {
637                header: EntryHeader::ConnectionManager,
638                body: "[ConnectionManager]some connection message".to_owned(),
639            };
640            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
641        }
642    }
643
644    // -- Performance class ---------------------------------------------------
645
646    mod performance_class {
647        use super::*;
648
649        #[test]
650        fn test_draft_bot_event_is_durable_per_event() {
651            let body = "[UnityCrossThreadLogger]2/01/2026 10:23:51 AM\n\
652            <== BotDraftDraftStatus(uuid)\n\
653            {\"Payload\":\"{\\\"DraftStatus\\\":\\\"PickNext\\\",\\\"PackNumber\\\":0,\\\"PickNumber\\\":0,\\\"DraftPack\\\":[]}\"}";
654            let entry = unity_entry(body);
655            let result = try_parse(&entry, Some(test_timestamp()));
656
657            assert!(result.is_some());
658            let event = result.as_ref().unwrap_or_else(|| unreachable!());
659            assert_eq!(event.performance_class(), PerformanceClass::DurablePerEvent);
660        }
661
662        #[test]
663        fn test_draft_bot_pick_event_is_durable_per_event() {
664            let body = r#"[UnityCrossThreadLogger]==> BotDraftDraftPick {"id":"uuid","request":"{\"PickInfo\":{\"CardIds\":[\"1\"],\"PackNumber\":0,\"PickNumber\":0}}"}"#;
665            let entry = unity_entry(body);
666            let result = try_parse(&entry, Some(test_timestamp()));
667
668            assert!(result.is_some());
669            let event = result.as_ref().unwrap_or_else(|| unreachable!());
670            assert_eq!(event.performance_class(), PerformanceClass::DurablePerEvent);
671        }
672    }
673
674    // -- Internal helpers ----------------------------------------------------
675
676    mod helpers {
677        use super::*;
678
679        #[test]
680        fn test_extract_draft_pack_string_ids() {
681            let parsed = serde_json::json!({
682                "DraftPack": ["12345", "67890", "11111"]
683            });
684            let pack = extract_draft_pack(&parsed);
685            assert_eq!(pack, vec![12345, 67890, 11111]);
686        }
687
688        #[test]
689        fn test_extract_draft_pack_integer_ids() {
690            let parsed = serde_json::json!({
691                "DraftPack": [12345, 67890]
692            });
693            let pack = extract_draft_pack(&parsed);
694            assert_eq!(pack, vec![12345, 67890]);
695        }
696
697        #[test]
698        fn test_extract_draft_pack_empty() {
699            let parsed = serde_json::json!({"DraftPack": []});
700            let pack = extract_draft_pack(&parsed);
701            assert!(pack.is_empty());
702        }
703
704        #[test]
705        fn test_extract_draft_pack_missing_field() {
706            let parsed = serde_json::json!({"other": "data"});
707            let pack = extract_draft_pack(&parsed);
708            assert!(pack.is_empty());
709        }
710
711        #[test]
712        fn test_extract_draft_pack_mixed_types() {
713            let parsed = serde_json::json!({
714                "DraftPack": [12345, "67890", "not_a_number", 11111]
715            });
716            let pack = extract_draft_pack(&parsed);
717            // "not_a_number" is silently skipped.
718            assert_eq!(pack, vec![12345, 67890, 11111]);
719        }
720    }
721}