Skip to main content

manasight_parser/parsers/draft/
complete.rs

1//! Draft completion parser for `DraftCompleteDraft` events.
2//!
3//! When a draft finishes (all picks made), the log emits a
4//! `DraftCompleteDraft` entry that links the draft ID to the event
5//! and marks the draft as finished. Two entry formats appear:
6//!
7//! | Direction | Format | Key Fields |
8//! |-----------|--------|------------|
9//! | Request (`==>`) | `{"id": "...", "request": "{\"EventName\": \"...\"}"}` | `id`, nested `EventName` |
10//! | Response (`<==`) | `{"CourseId": "...", "InternalEventName": "...", "CardPool": [...]}` | `InternalEventName`, `CardPool` |
11//!
12//! This is a Class 2 (Durable Per-Event) event. The completion signal
13//! must survive crashes to ensure the draft record is finalized.
14
15use crate::events::{DraftCompleteEvent, EventMetadata, GameEvent};
16use crate::log::entry::LogEntry;
17use crate::parsers::api_common;
18
19/// Marker for draft completion events.
20///
21/// `DraftCompleteDraft` appears in the log when the player finishes
22/// drafting all cards (all 42 picks made). Both request (`==>`) and
23/// response (`<==`) entries share this marker.
24const COMPLETE_DRAFT_MARKER: &str = "DraftCompleteDraft";
25
26/// Attempts to parse a [`LogEntry`] as a draft completion event.
27///
28/// Returns `Some(GameEvent::DraftComplete(_))` if the entry matches the
29/// `DraftCompleteDraft` signature, or `None` if it does not match.
30///
31/// The `timestamp` is `None` when the log entry header did not contain a
32/// parseable timestamp. It is passed through to [`EventMetadata`] so
33/// downstream consumers can distinguish real vs missing timestamps.
34pub fn try_parse(
35    entry: &LogEntry,
36    timestamp: Option<chrono::DateTime<chrono::Utc>>,
37) -> Option<GameEvent> {
38    let body = &entry.body;
39
40    if !body.contains(COMPLETE_DRAFT_MARKER) {
41        return None;
42    }
43
44    let parsed = api_common::parse_json_from_body(body, "DraftCompleteDraft")?;
45
46    let draft_id_from_body = extract_draft_id_from_body(body);
47    let payload = build_payload(&parsed, draft_id_from_body.as_deref());
48    let metadata = EventMetadata::new(timestamp, body.as_bytes().to_vec());
49    Some(GameEvent::DraftComplete(DraftCompleteEvent::new(
50        metadata, payload,
51    )))
52}
53
54/// Builds a structured payload from the draft completion event.
55///
56/// Handles three JSON formats:
57/// - **Request**: `{"id": "...", "request": "{\"EventName\": \"...\"}"}` — event name
58///   is extracted from the string-escaped `request` field.
59/// - **Response**: `{"CourseId": "...", "InternalEventName": "..."}` — event name is
60///   a direct field, draft ID comes from the body header.
61/// - **Flat** (legacy): `{"DraftId": "...", "EventName": "..."}` — both fields at
62///   top level.
63fn build_payload(
64    parsed: &serde_json::Value,
65    draft_id_from_body: Option<&str>,
66) -> serde_json::Value {
67    let draft_id = parsed
68        .get("DraftId")
69        .or_else(|| parsed.get("draftId"))
70        .or_else(|| parsed.get("id"))
71        .and_then(serde_json::Value::as_str)
72        .or(draft_id_from_body)
73        .unwrap_or("");
74
75    let event_name = api_common::extract_event_name(parsed);
76
77    serde_json::json!({
78        "type": "draft_complete",
79        "draft_id": draft_id,
80        "event_name": event_name,
81        "raw_complete_draft": parsed.clone(),
82    })
83}
84
85/// Extracts the draft ID from a `DraftCompleteDraft(uuid)` pattern in the body.
86///
87/// The response format includes the draft ID in a parenthesized suffix:
88/// `<== DraftCompleteDraft(2c141a6f-49e9-4b73-8231-47212fc8d577)`
89fn extract_draft_id_from_body(body: &str) -> Option<String> {
90    let marker = "DraftCompleteDraft(";
91    let start = body.find(marker)? + marker.len();
92    let remaining = body.get(start..)?;
93    let end = remaining.find(')')?;
94    Some(remaining.get(..end)?.to_owned())
95}
96
97// ---------------------------------------------------------------------------
98// Tests
99// ---------------------------------------------------------------------------
100
101#[cfg(test)]
102#[allow(deprecated)]
103mod tests {
104    use super::*;
105    use crate::events::PerformanceClass;
106    use crate::parsers::test_helpers::{
107        draft_complete_payload, test_timestamp, unity_entry, EntryHeader,
108    };
109
110    // -- Request format (==>) ------------------------------------------------
111
112    mod request_format {
113        use super::*;
114
115        #[test]
116        fn test_try_parse_request_basic() {
117            let body = r#"[UnityCrossThreadLogger]==> DraftCompleteDraft {"id":"abc-123-def","request":"{\"EventName\":\"PremierDraft_MKM_20260201\",\"IsBotDraft\":false}"}"#;
118            let entry = unity_entry(body);
119            let result = try_parse(&entry, Some(test_timestamp()));
120
121            assert!(result.is_some());
122            let event = result.as_ref().unwrap_or_else(|| unreachable!());
123            let payload = draft_complete_payload(event);
124
125            assert_eq!(payload["type"], "draft_complete");
126            assert_eq!(payload["draft_id"], "abc-123-def");
127            assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
128        }
129
130        #[test]
131        fn test_try_parse_request_traditional_draft() {
132            let body = r#"[UnityCrossThreadLogger]==> DraftCompleteDraft {"id":"trad-456","request":"{\"EventName\":\"TradDraft_DSK_20260115\",\"IsBotDraft\":false}"}"#;
133            let entry = unity_entry(body);
134            let result = try_parse(&entry, Some(test_timestamp()));
135
136            assert!(result.is_some());
137            let event = result.as_ref().unwrap_or_else(|| unreachable!());
138            let payload = draft_complete_payload(event);
139
140            assert_eq!(payload["draft_id"], "trad-456");
141            assert_eq!(payload["event_name"], "TradDraft_DSK_20260115");
142        }
143
144        #[test]
145        fn test_try_parse_request_empty_request_string() {
146            let body = r#"[UnityCrossThreadLogger]==> DraftCompleteDraft {"id":"empty-req","request":"{}"}"#;
147            let entry = unity_entry(body);
148            let result = try_parse(&entry, Some(test_timestamp()));
149
150            assert!(result.is_some());
151            let event = result.as_ref().unwrap_or_else(|| unreachable!());
152            let payload = draft_complete_payload(event);
153
154            assert_eq!(payload["draft_id"], "empty-req");
155            assert_eq!(payload["event_name"], "");
156        }
157    }
158
159    // -- Response format (<==) -----------------------------------------------
160
161    mod response_format {
162        use super::*;
163
164        #[test]
165        fn test_try_parse_response_basic() {
166            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
167                         <== DraftCompleteDraft(abc-123-def)\n\
168                         {\"CourseId\":\"course-456\",\
169                          \"InternalEventName\":\"PremierDraft_MKM_20260201\",\
170                          \"CurrentModule\":\"DeckSelect\",\
171                          \"CardPool\":[98535,98381]}";
172            let entry = unity_entry(body);
173            let result = try_parse(&entry, Some(test_timestamp()));
174
175            assert!(result.is_some());
176            let event = result.as_ref().unwrap_or_else(|| unreachable!());
177            let payload = draft_complete_payload(event);
178
179            assert_eq!(payload["type"], "draft_complete");
180            assert_eq!(payload["draft_id"], "abc-123-def");
181            assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
182        }
183
184        #[test]
185        fn test_try_parse_response_preserves_card_pool() {
186            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
187                         <== DraftCompleteDraft(pool-test)\n\
188                         {\"InternalEventName\":\"PremierDraft_ECL_20260120\",\
189                          \"CardPool\":[98535,98381,98366]}";
190            let entry = unity_entry(body);
191            let result = try_parse(&entry, Some(test_timestamp()));
192
193            assert!(result.is_some());
194            let event = result.as_ref().unwrap_or_else(|| unreachable!());
195            let payload = draft_complete_payload(event);
196
197            let raw = &payload["raw_complete_draft"];
198            assert_eq!(raw["CardPool"][0], 98535);
199            assert_eq!(raw["CardPool"][2], 98366);
200        }
201
202        #[test]
203        fn test_try_parse_response_event_name_fallback() {
204            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
205                         <== DraftCompleteDraft(fallback-test)\n\
206                         {\"EventName\":\"QuickDraft_MKM_20260201\"}";
207            let entry = unity_entry(body);
208            let result = try_parse(&entry, Some(test_timestamp()));
209
210            assert!(result.is_some());
211            let event = result.as_ref().unwrap_or_else(|| unreachable!());
212            let payload = draft_complete_payload(event);
213
214            assert_eq!(payload["event_name"], "QuickDraft_MKM_20260201");
215        }
216    }
217
218    // -- Flat/legacy format --------------------------------------------------
219
220    mod flat_format {
221        use super::*;
222
223        #[test]
224        fn test_try_parse_flat_basic() {
225            let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
226                         {\n\
227                           \"DraftId\": \"abc-123-def\",\n\
228                           \"EventName\": \"PremierDraft_MKM_20260201\"\n\
229                         }";
230            let entry = unity_entry(body);
231            let result = try_parse(&entry, Some(test_timestamp()));
232
233            assert!(result.is_some());
234            let event = result.as_ref().unwrap_or_else(|| unreachable!());
235            let payload = draft_complete_payload(event);
236
237            assert_eq!(payload["type"], "draft_complete");
238            assert_eq!(payload["draft_id"], "abc-123-def");
239            assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
240        }
241
242        #[test]
243        fn test_try_parse_flat_traditional() {
244            let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
245                         {\n\
246                           \"DraftId\": \"trad-456\",\n\
247                           \"EventName\": \"TradDraft_DSK_20260115\"\n\
248                         }";
249            let entry = unity_entry(body);
250            let result = try_parse(&entry, Some(test_timestamp()));
251
252            assert!(result.is_some());
253            let event = result.as_ref().unwrap_or_else(|| unreachable!());
254            let payload = draft_complete_payload(event);
255
256            assert_eq!(payload["draft_id"], "trad-456");
257            assert_eq!(payload["event_name"], "TradDraft_DSK_20260115");
258        }
259
260        #[test]
261        fn test_try_parse_flat_quick_draft() {
262            let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
263                         {\n\
264                           \"DraftId\": \"quick-789\",\n\
265                           \"EventName\": \"QuickDraft_MKM_20260201\"\n\
266                         }";
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_complete_payload(event);
273
274            assert_eq!(payload["draft_id"], "quick-789");
275            assert_eq!(payload["event_name"], "QuickDraft_MKM_20260201");
276        }
277
278        #[test]
279        fn test_try_parse_flat_lowercase_draft_id() {
280            let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
281                         {\n\
282                           \"draftId\": \"lowercase-123\",\n\
283                           \"EventName\": \"PremierDraft_MKM_20260201\"\n\
284                         }";
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_complete_payload(event);
291
292            assert_eq!(payload["draft_id"], "lowercase-123");
293        }
294
295        #[test]
296        fn test_try_parse_flat_internal_event_name() {
297            let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
298                         {\n\
299                           \"DraftId\": \"intern-456\",\n\
300                           \"InternalEventName\": \"PremierDraft_MKM_20260201\"\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_complete_payload(event);
308
309            assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
310        }
311    }
312
313    // -- Missing / default fields --------------------------------------------
314
315    mod missing_fields {
316        use super::*;
317
318        #[test]
319        fn test_try_parse_missing_draft_id() {
320            let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
321                         {\n\
322                           \"EventName\": \"PremierDraft_MKM_20260201\"\n\
323                         }";
324            let entry = unity_entry(body);
325            let result = try_parse(&entry, Some(test_timestamp()));
326
327            assert!(result.is_some());
328            let event = result.as_ref().unwrap_or_else(|| unreachable!());
329            let payload = draft_complete_payload(event);
330
331            assert_eq!(payload["draft_id"], "");
332            assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
333        }
334
335        #[test]
336        fn test_try_parse_missing_event_name() {
337            let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
338                         {\n\
339                           \"DraftId\": \"no-event-name\"\n\
340                         }";
341            let entry = unity_entry(body);
342            let result = try_parse(&entry, Some(test_timestamp()));
343
344            assert!(result.is_some());
345            let event = result.as_ref().unwrap_or_else(|| unreachable!());
346            let payload = draft_complete_payload(event);
347
348            assert_eq!(payload["draft_id"], "no-event-name");
349            assert_eq!(payload["event_name"], "");
350        }
351
352        #[test]
353        fn test_try_parse_minimal_payload() {
354            let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
355                         {}";
356            let entry = unity_entry(body);
357            let result = try_parse(&entry, Some(test_timestamp()));
358
359            assert!(result.is_some());
360            let event = result.as_ref().unwrap_or_else(|| unreachable!());
361            let payload = draft_complete_payload(event);
362
363            assert_eq!(payload["draft_id"], "");
364            assert_eq!(payload["event_name"], "");
365        }
366    }
367
368    // -- Metadata preservation -----------------------------------------------
369
370    mod metadata {
371        use super::*;
372
373        #[test]
374        fn test_try_parse_preserves_raw_bytes() {
375            let body = r#"[UnityCrossThreadLogger]==> DraftCompleteDraft {"id":"raw-test","request":"{\"EventName\":\"PremierDraft_MKM_20260201\"}"}"#;
376            let entry = unity_entry(body);
377            let result = try_parse(&entry, Some(test_timestamp()));
378
379            assert!(result.is_some());
380            let event = result.as_ref().unwrap_or_else(|| unreachable!());
381            assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
382        }
383
384        #[test]
385        fn test_try_parse_stores_timestamp() {
386            let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
387                         {\"DraftId\": \"ts-test\"}";
388            let entry = unity_entry(body);
389            let ts = Some(test_timestamp());
390            let result = try_parse(&entry, ts);
391
392            assert!(result.is_some());
393            let event = result.as_ref().unwrap_or_else(|| unreachable!());
394            assert_eq!(event.metadata().timestamp(), ts);
395        }
396
397        #[test]
398        fn test_try_parse_preserves_raw_payload() {
399            let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
400                         {\n\
401                           \"DraftId\": \"raw-payload\",\n\
402                           \"ExtraField\": \"preserved\"\n\
403                         }";
404            let entry = unity_entry(body);
405            let result = try_parse(&entry, Some(test_timestamp()));
406
407            assert!(result.is_some());
408            let event = result.as_ref().unwrap_or_else(|| unreachable!());
409            let payload = draft_complete_payload(event);
410
411            assert_eq!(payload["raw_complete_draft"]["ExtraField"], "preserved");
412        }
413    }
414
415    // -- Non-matching entries (should return None) ----------------------------
416
417    mod non_matching {
418        use super::*;
419
420        #[test]
421        fn test_try_parse_unrelated_entry_returns_none() {
422            let body = "[UnityCrossThreadLogger]greToClientEvent\n{\"data\": 1}";
423            let entry = unity_entry(body);
424            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
425        }
426
427        #[test]
428        fn test_try_parse_empty_body_returns_none() {
429            let body = "[UnityCrossThreadLogger]";
430            let entry = unity_entry(body);
431            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
432        }
433
434        #[test]
435        fn test_try_parse_bot_draft_entry_returns_none() {
436            let body = "[UnityCrossThreadLogger]BotDraft_DraftPick\n\
437                         {\"PickInfo\": {\"CardId\": 12345}}";
438            let entry = unity_entry(body);
439            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
440        }
441
442        #[test]
443        fn test_try_parse_human_draft_entry_returns_none() {
444            let body = "[UnityCrossThreadLogger]Draft.Notify\n\
445                         {\"SelfPack\": 0, \"SelfPick\": 0, \
446                          \"PackCards\": \"12345\"}";
447            let entry = unity_entry(body);
448            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
449        }
450
451        #[test]
452        fn test_try_parse_malformed_json_returns_none() {
453            let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
454                         {broken json!!!}";
455            let entry = unity_entry(body);
456            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
457        }
458
459        #[test]
460        fn test_try_parse_marker_only_no_json_returns_none() {
461            let body = "[UnityCrossThreadLogger]DraftCompleteDraft";
462            let entry = unity_entry(body);
463            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
464        }
465
466        #[test]
467        fn test_try_parse_connection_manager_header_returns_none() {
468            let entry = LogEntry {
469                header: EntryHeader::ConnectionManager,
470                body: "[ConnectionManager]some connection message".to_owned(),
471            };
472            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
473        }
474
475        #[test]
476        fn test_try_parse_old_underscore_marker_returns_none() {
477            let body = "[UnityCrossThreadLogger]Draft_CompleteDraft\n\
478                         {\"DraftId\": \"old-marker\", \
479                          \"EventName\": \"PremierDraft_MKM_20260201\"}";
480            let entry = unity_entry(body);
481            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
482        }
483    }
484
485    // -- Header with timestamp -----------------------------------------------
486
487    mod timestamp_in_header {
488        use super::*;
489
490        #[test]
491        fn test_try_parse_with_timestamp_prefix() {
492            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
493                         DraftCompleteDraft\n\
494                         {\"DraftId\": \"ts-prefix\", \
495                          \"EventName\": \"PremierDraft_MKM_20260201\"}";
496            let entry = unity_entry(body);
497            let result = try_parse(&entry, Some(test_timestamp()));
498
499            assert!(result.is_some());
500            let event = result.as_ref().unwrap_or_else(|| unreachable!());
501            let payload = draft_complete_payload(event);
502
503            assert_eq!(payload["type"], "draft_complete");
504            assert_eq!(payload["draft_id"], "ts-prefix");
505        }
506    }
507
508    // -- Performance class ---------------------------------------------------
509
510    mod performance_class {
511        use super::*;
512
513        #[test]
514        fn test_draft_complete_event_is_durable_per_event() {
515            let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
516                         {\"DraftId\": \"perf-test\"}";
517            let entry = unity_entry(body);
518            let result = try_parse(&entry, Some(test_timestamp()));
519
520            assert!(result.is_some());
521            let event = result.as_ref().unwrap_or_else(|| unreachable!());
522            assert_eq!(event.performance_class(), PerformanceClass::DurablePerEvent);
523        }
524    }
525
526    // -- Internal helpers ----------------------------------------------------
527
528    mod helpers {
529        use super::*;
530
531        #[test]
532        fn test_build_payload_request_format() {
533            let parsed = serde_json::json!({
534                "id": "req-123",
535                "request": "{\"EventName\":\"PremierDraft_MKM_20260201\",\"IsBotDraft\":false}"
536            });
537            let payload = build_payload(&parsed, None);
538            assert_eq!(payload["type"], "draft_complete");
539            assert_eq!(payload["draft_id"], "req-123");
540            assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
541        }
542
543        #[test]
544        fn test_build_payload_response_format() {
545            let parsed = serde_json::json!({
546                "CourseId": "course-456",
547                "InternalEventName": "PremierDraft_MKM_20260201",
548                "CurrentModule": "DeckSelect"
549            });
550            let payload = build_payload(&parsed, Some("resp-789"));
551            assert_eq!(payload["draft_id"], "resp-789");
552            assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
553        }
554
555        #[test]
556        fn test_build_payload_flat_format() {
557            let parsed = serde_json::json!({
558                "DraftId": "test-id",
559                "EventName": "PremierDraft_MKM_20260201"
560            });
561            let payload = build_payload(&parsed, None);
562            assert_eq!(payload["type"], "draft_complete");
563            assert_eq!(payload["draft_id"], "test-id");
564            assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
565        }
566
567        #[test]
568        fn test_build_payload_empty() {
569            let parsed = serde_json::json!({});
570            let payload = build_payload(&parsed, None);
571            assert_eq!(payload["draft_id"], "");
572            assert_eq!(payload["event_name"], "");
573        }
574
575        #[test]
576        fn test_extract_draft_id_from_body_response() {
577            let body = "<== DraftCompleteDraft(abc-123-def)\n{\"some\":\"json\"}";
578            assert_eq!(
579                extract_draft_id_from_body(body),
580                Some("abc-123-def".to_owned()),
581            );
582        }
583
584        #[test]
585        fn test_extract_draft_id_from_body_request_returns_none() {
586            let body = r#"==> DraftCompleteDraft {"id":"abc-123-def"}"#;
587            assert!(extract_draft_id_from_body(body).is_none());
588        }
589
590        #[test]
591        fn test_extract_draft_id_from_body_no_marker() {
592            assert!(extract_draft_id_from_body("no marker here").is_none());
593        }
594    }
595}