draftomen 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. draftomen/__init__.py +17 -0
  2. draftomen/assets/draftomen.icns +0 -0
  3. draftomen/assets/draftomen.ico +0 -0
  4. draftomen/assets/draftomen_logo.png +0 -0
  5. draftomen/audit.py +606 -0
  6. draftomen/backtest.py +449 -0
  7. draftomen/benchmark.py +834 -0
  8. draftomen/carddb.py +1869 -0
  9. draftomen/cardimages.py +313 -0
  10. draftomen/cli.py +891 -0
  11. draftomen/config.py +133 -0
  12. draftomen/deckbuilder.py +2723 -0
  13. draftomen/events.py +772 -0
  14. draftomen/logfollow.py +451 -0
  15. draftomen/mock_session.py +745 -0
  16. draftomen/paths.py +120 -0
  17. draftomen/pickengine.py +1117 -0
  18. draftomen/pool.py +1259 -0
  19. draftomen/preferences.py +289 -0
  20. draftomen/qml/AboutDialog.qml +156 -0
  21. draftomen/qml/AppBar.qml +120 -0
  22. draftomen/qml/BacktestView.qml +316 -0
  23. draftomen/qml/BuildView.qml +1151 -0
  24. draftomen/qml/CardPreview.qml +434 -0
  25. draftomen/qml/DimensionalButton.qml +42 -0
  26. draftomen/qml/DimensionalComboBox.qml +182 -0
  27. draftomen/qml/DimensionalSurface.qml +118 -0
  28. draftomen/qml/DimensionalTabButton.qml +41 -0
  29. draftomen/qml/LiveDraftView.qml +468 -0
  30. draftomen/qml/Main.qml +175 -0
  31. draftomen/qml/NavigationRail.qml +226 -0
  32. draftomen/qml/PoolSummaryPanel.qml +322 -0
  33. draftomen/qml/PrivacyDialog.qml +96 -0
  34. draftomen/qml/RecentPickThumbnail.qml +83 -0
  35. draftomen/qml/RecentPicksGallery.qml +333 -0
  36. draftomen/qml/RecommendationRow.qml +404 -0
  37. draftomen/qml/SettingsSwitch.qml +141 -0
  38. draftomen/qml/SettingsView.qml +531 -0
  39. draftomen/qml/StateBanner.qml +189 -0
  40. draftomen/qml/StatusStrip.qml +84 -0
  41. draftomen/qml/Theme.qml +71 -0
  42. draftomen/qml/qmldir +23 -0
  43. draftomen/qt_adapter.py +884 -0
  44. draftomen/qt_gui.py +338 -0
  45. draftomen/qt_mock.py +63 -0
  46. draftomen/ranking.py +126 -0
  47. draftomen/replay.py +480 -0
  48. draftomen/session.py +3668 -0
  49. draftomen/setinfo.py +26 -0
  50. draftomen/seventeen.py +2766 -0
  51. draftomen/splash.py +617 -0
  52. draftomen/tui.py +4007 -0
  53. draftomen/watch.py +336 -0
  54. draftomen-0.3.0.dist-info/METADATA +156 -0
  55. draftomen-0.3.0.dist-info/RECORD +59 -0
  56. draftomen-0.3.0.dist-info/WHEEL +5 -0
  57. draftomen-0.3.0.dist-info/entry_points.txt +6 -0
  58. draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
  59. draftomen-0.3.0.dist-info/top_level.txt +1 -0
draftomen/events.py ADDED
@@ -0,0 +1,772 @@
1
+ """Parse Quick Draft log lines into typed events.
2
+ Keep Arena log knowledge isolated in a pure line-consumer layer.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import re
9
+ from collections.abc import Iterable, Iterator, Sequence
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, NoReturn, TypeAlias
12
+
13
+ QUICK_DRAFT_PREFIX = "QuickDraft_"
14
+ EXPECTED_PACK_COUNT = 3
15
+ EXPECTED_PICKS_PER_PACK = 14
16
+ EXPECTED_TOTAL_PICKS = EXPECTED_PACK_COUNT * EXPECTED_PICKS_PER_PACK
17
+ FINAL_PACK_NUMBER = EXPECTED_PACK_COUNT - 1
18
+ FINAL_PICK_NUMBER = EXPECTED_PICKS_PER_PACK - 1
19
+
20
+ _REQUEST_LINE = re.compile(r"^\[UnityCrossThreadLogger\]==>\s+(?P<token>\S+)\s+(?P<body>\{.*\})$")
21
+ _RESPONSE_MARKER = re.compile(r"^<==\s+(?P<token>[^()]+)\(")
22
+ _LOGIN_DISPLAY_NAME = re.compile(
23
+ r"\[Accounts - Login\]\s+Logged in successfully\.\s+"
24
+ r"Display Name:\s+(?P<screen_name>.+?)\s*$"
25
+ )
26
+
27
+
28
+ class DraftLogParseError(ValueError):
29
+ """Raised when a draft-shaped log line cannot be parsed.
30
+ The raw offending line is retained for loud diagnostics.
31
+ """
32
+
33
+ def __init__(self, message: str, *, raw_line: str) -> None:
34
+ self.raw_line = raw_line
35
+ super().__init__(f"{message}\nRaw line: {raw_line}")
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class AccountEvent:
40
+ """Active MTGA account detected from the log stream.
41
+ A previous account id marks a mid-stream account change.
42
+ """
43
+
44
+ client_id: str
45
+ screen_name: str | None
46
+ previous_client_id: str | None = None
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class QuickDraftDetectedEvent:
51
+ """Quick Draft entry detected before Arena creates its draft course.
52
+ This informational event lets the UI prepare set-level data before P1P1.
53
+ """
54
+
55
+ event_name: str
56
+ set_code: str
57
+ account_id: str | None
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class DraftStartedEvent:
62
+ """Quick Draft course start detected from Arena course state.
63
+ Event name identifies the draft event and set code identifies the set.
64
+ """
65
+
66
+ event_name: str
67
+ set_code: str
68
+ course_id: str
69
+ account_id: str | None
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class PackOfferedEvent:
74
+ """Pack contents offered for a Quick Draft pick.
75
+ Card identifiers are normalized Arena grpIds.
76
+ """
77
+
78
+ event_name: str
79
+ set_code: str
80
+ pack_number: int
81
+ pick_number: int
82
+ offered_grp_ids: tuple[int, ...]
83
+ pool_grp_ids: tuple[int, ...]
84
+ account_id: str | None
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class PickMadeEvent:
89
+ """Chosen card for a Quick Draft pick.
90
+ Quick Draft picks one card, exposed as an Arena grpId.
91
+ """
92
+
93
+ event_name: str
94
+ set_code: str
95
+ pack_number: int
96
+ pick_number: int
97
+ chosen_grp_id: int
98
+ account_id: str | None
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class DraftCompletedEvent:
103
+ """Quick Draft completion detected from payload or final-pick inference.
104
+ The picked card list is the final pool snapshot from Arena.
105
+ """
106
+
107
+ event_name: str
108
+ set_code: str
109
+ pack_number: int
110
+ pick_number: int
111
+ picked_grp_ids: tuple[int, ...]
112
+ inferred: bool
113
+ account_id: str | None
114
+
115
+
116
+ DraftEvent: TypeAlias = (
117
+ AccountEvent
118
+ | QuickDraftDetectedEvent
119
+ | DraftStartedEvent
120
+ | PackOfferedEvent
121
+ | PickMadeEvent
122
+ | DraftCompletedEvent
123
+ )
124
+
125
+
126
+ @dataclass(slots=True)
127
+ class _ParserState:
128
+ account_id: str | None = None
129
+ pending_login_screen_name: str | None = None
130
+ screen_names_by_client_id: dict[str, str] = field(default_factory=dict)
131
+ observed_quick_draft_course_ids: set[str] = field(default_factory=set)
132
+ login_generation: int = 0
133
+
134
+
135
+ class DraftLogParser:
136
+ """Incrementally parse Quick Draft log lines.
137
+ Parser state preserves the active account across live polling batches.
138
+ """
139
+
140
+ def __init__(self) -> None:
141
+ self._state = _ParserState()
142
+
143
+ def parse_lines(self, *, lines: Iterable[str]) -> Iterator[DraftEvent]:
144
+ """Yield typed Quick Draft events from complete log lines.
145
+ The function consumes strings only and performs no I/O.
146
+ """
147
+
148
+ for raw_line in lines:
149
+ line = raw_line.rstrip("\r\n")
150
+ yield from _parse_line(line=line, state=self._state)
151
+
152
+ @property
153
+ def pending_login_screen_name(self) -> str | None:
154
+ """Return the latest login name that lacks an authenticated account id.
155
+ Callers may use it only when recovery leaves one unambiguous account.
156
+ """
157
+
158
+ return self._state.pending_login_screen_name
159
+
160
+ @property
161
+ def observed_quick_draft_course_ids(self) -> frozenset[str]:
162
+ """Return Quick Draft course ids seen in current course snapshots.
163
+ Snapshots associate an otherwise unbound login name with saved drafts.
164
+ """
165
+
166
+ return frozenset(self._state.observed_quick_draft_course_ids)
167
+
168
+ @property
169
+ def login_generation(self) -> int:
170
+ """Return the count of login boundaries observed in the log stream.
171
+ Consumers use it to discard account context from the prior login.
172
+ """
173
+
174
+ return self._state.login_generation
175
+
176
+
177
+ def parse_events(lines: Iterable[str]) -> Iterator[DraftEvent]:
178
+ """Yield typed Quick Draft events from log lines.
179
+ The function consumes strings only and performs no I/O.
180
+ """
181
+
182
+ parser = DraftLogParser()
183
+ yield from parser.parse_lines(lines=lines)
184
+
185
+
186
+ def _parse_line(line: str, state: _ParserState) -> tuple[DraftEvent, ...]:
187
+ stripped = line.strip()
188
+ if not stripped:
189
+ return ()
190
+
191
+ if "BotDraft_Draft" in stripped:
192
+ _raise(
193
+ "Unsupported Quick Draft token; expected current BotDraftDraft* format",
194
+ raw_line=line,
195
+ )
196
+
197
+ login_match = _LOGIN_DISPLAY_NAME.search(stripped)
198
+ if login_match is not None:
199
+ state.account_id = None
200
+ state.pending_login_screen_name = login_match.group("screen_name").strip()
201
+ state.screen_names_by_client_id.clear()
202
+ state.observed_quick_draft_course_ids.clear()
203
+ state.login_generation += 1
204
+ return ()
205
+
206
+ request_match = _REQUEST_LINE.match(stripped)
207
+ if request_match is not None:
208
+ return _parse_request_line(
209
+ token=request_match.group("token"),
210
+ body=request_match.group("body"),
211
+ raw_line=line,
212
+ state=state,
213
+ )
214
+
215
+ response_match = _RESPONSE_MARKER.match(stripped)
216
+ if response_match is not None:
217
+ token = response_match.group("token")
218
+ if token in {"BotDraftDraftStatus", "BotDraftDraftPick"}:
219
+ return ()
220
+
221
+ if "BotDraft" in token:
222
+ _raise(f"Unsupported BotDraft response token {token!r}", raw_line=line)
223
+
224
+ return ()
225
+
226
+ if stripped.startswith("{"):
227
+ if not _json_line_may_contain_events(stripped):
228
+ return ()
229
+ return _parse_json_line(text=stripped, raw_line=line, state=state)
230
+
231
+ if _contains_unparsed_draft_shape(stripped):
232
+ _raise("Unknown draft-shaped log line", raw_line=line)
233
+
234
+ return ()
235
+
236
+
237
+ def _parse_request_line(
238
+ *,
239
+ token: str,
240
+ body: str,
241
+ raw_line: str,
242
+ state: _ParserState,
243
+ ) -> tuple[DraftEvent, ...]:
244
+ if token == "EventJoin":
245
+ return _parse_event_join_request(
246
+ body=body,
247
+ raw_line=raw_line,
248
+ state=state,
249
+ )
250
+
251
+ if token == "BotDraftDraftStatus":
252
+ request = _request_payload(body=body, raw_line=raw_line)
253
+ event_name = _required_str(
254
+ request.get("EventName"),
255
+ field_name="request.EventName",
256
+ raw_line=raw_line,
257
+ )
258
+ _set_code(event_name=event_name, raw_line=raw_line)
259
+ return ()
260
+
261
+ if token == "BotDraftDraftPick":
262
+ request = _request_payload(body=body, raw_line=raw_line)
263
+ pick_info = _required_mapping(
264
+ request.get("PickInfo"),
265
+ field_name="request.PickInfo",
266
+ raw_line=raw_line,
267
+ )
268
+ event_name = _required_str(
269
+ pick_info.get("EventName", request.get("EventName")),
270
+ field_name="request.PickInfo.EventName",
271
+ raw_line=raw_line,
272
+ )
273
+ set_code = _set_code(event_name=event_name, raw_line=raw_line)
274
+ card_ids = _int_tuple(
275
+ pick_info.get("CardIds"),
276
+ field_name="request.PickInfo.CardIds",
277
+ raw_line=raw_line,
278
+ )
279
+ if len(card_ids) != 1:
280
+ _raise(
281
+ "Quick Draft pick request must contain exactly one CardIds entry",
282
+ raw_line=raw_line,
283
+ )
284
+
285
+ pack_number = _required_int(
286
+ pick_info.get("PackNumber"),
287
+ field_name="request.PickInfo.PackNumber",
288
+ raw_line=raw_line,
289
+ )
290
+ pick_number = _required_int(
291
+ pick_info.get("PickNumber"),
292
+ field_name="request.PickInfo.PickNumber",
293
+ raw_line=raw_line,
294
+ )
295
+ return (
296
+ PickMadeEvent(
297
+ event_name=event_name,
298
+ set_code=set_code,
299
+ pack_number=pack_number,
300
+ pick_number=pick_number,
301
+ chosen_grp_id=card_ids[0],
302
+ account_id=state.account_id,
303
+ ),
304
+ )
305
+
306
+ if "BotDraft" in token:
307
+ _raise(f"Unsupported BotDraft request token {token!r}", raw_line=raw_line)
308
+
309
+ return ()
310
+
311
+
312
+ def _parse_event_join_request(
313
+ *,
314
+ body: str,
315
+ raw_line: str,
316
+ state: _ParserState,
317
+ ) -> tuple[DraftEvent, ...]:
318
+ if QUICK_DRAFT_PREFIX not in raw_line:
319
+ return ()
320
+
321
+ request = _request_payload(body=body, raw_line=raw_line)
322
+ event_name = _required_str(
323
+ request.get("EventName"),
324
+ field_name="request.EventName",
325
+ raw_line=raw_line,
326
+ )
327
+ set_code = _set_code(event_name=event_name, raw_line=raw_line)
328
+ return (
329
+ QuickDraftDetectedEvent(
330
+ event_name=event_name,
331
+ set_code=set_code,
332
+ account_id=state.account_id,
333
+ ),
334
+ )
335
+
336
+
337
+ def _request_payload(*, body: str, raw_line: str) -> dict[str, Any]:
338
+ envelope = _json_object(text=body, raw_line=raw_line, context="request envelope")
339
+ request_text = _required_str(
340
+ envelope.get("request"),
341
+ field_name="request envelope.request",
342
+ raw_line=raw_line,
343
+ )
344
+ return _json_object(text=request_text, raw_line=raw_line, context="request payload")
345
+
346
+
347
+ def _parse_json_line(
348
+ *,
349
+ text: str,
350
+ raw_line: str,
351
+ state: _ParserState,
352
+ ) -> tuple[DraftEvent, ...]:
353
+ data = _json_object(text=text, raw_line=raw_line, context="JSON log line")
354
+ _remember_quick_draft_course_ids(data=data, state=state)
355
+
356
+ if "authenticateResponse" in data:
357
+ account = _parse_account(data=data, raw_line=raw_line, state=state)
358
+ return (account,)
359
+
360
+ if "Course" in data:
361
+ started = _parse_course(data=data, raw_line=raw_line, state=state)
362
+ if started is not None:
363
+ return (started,)
364
+ return ()
365
+
366
+ if "CurrentModule" in data and "Payload" in data:
367
+ if not _payload_line_is_draft_shaped(data=data):
368
+ return ()
369
+ return _parse_module_payload(data=data, raw_line=raw_line, state=state)
370
+
371
+ if _mapping_contains_draft_shape(data):
372
+ _raise("Unknown draft-shaped JSON log line", raw_line=raw_line)
373
+
374
+ return ()
375
+
376
+
377
+ def _remember_quick_draft_course_ids(
378
+ *,
379
+ data: dict[str, Any],
380
+ state: _ParserState,
381
+ ) -> None:
382
+ course_values: list[Any] = [data.get("Course")]
383
+ courses = data.get("Courses")
384
+ if isinstance(courses, list):
385
+ course_values.extend(courses)
386
+
387
+ for course in course_values:
388
+ if not isinstance(course, dict):
389
+ continue
390
+
391
+ event_name = course.get("InternalEventName")
392
+ course_id = course.get("CourseId")
393
+ if (
394
+ isinstance(event_name, str)
395
+ and event_name.startswith(QUICK_DRAFT_PREFIX)
396
+ and isinstance(course_id, str)
397
+ and course_id != ""
398
+ ):
399
+ state.observed_quick_draft_course_ids.add(course_id)
400
+
401
+
402
+ def _parse_account(
403
+ *,
404
+ data: dict[str, Any],
405
+ raw_line: str,
406
+ state: _ParserState,
407
+ ) -> AccountEvent:
408
+ response = _required_mapping(
409
+ data.get("authenticateResponse"),
410
+ field_name="authenticateResponse",
411
+ raw_line=raw_line,
412
+ )
413
+ client_id = _required_str(
414
+ response.get("clientId"),
415
+ field_name="authenticateResponse.clientId",
416
+ raw_line=raw_line,
417
+ )
418
+ screen_name = _screen_name_for_account(
419
+ response=response,
420
+ client_id=client_id,
421
+ raw_line=raw_line,
422
+ state=state,
423
+ )
424
+
425
+ previous_client_id = state.account_id if state.account_id != client_id else None
426
+ state.account_id = client_id
427
+ state.pending_login_screen_name = None
428
+ if screen_name is not None:
429
+ state.screen_names_by_client_id[client_id] = screen_name
430
+ return AccountEvent(
431
+ client_id=client_id,
432
+ screen_name=screen_name,
433
+ previous_client_id=previous_client_id,
434
+ )
435
+
436
+
437
+ def _screen_name_for_account(
438
+ *,
439
+ response: dict[str, Any],
440
+ client_id: str,
441
+ raw_line: str,
442
+ state: _ParserState,
443
+ ) -> str | None:
444
+ screen_name_value = response.get("screenName")
445
+ if screen_name_value is not None:
446
+ screen_name = _required_str(
447
+ screen_name_value,
448
+ field_name="authenticateResponse.screenName",
449
+ raw_line=raw_line,
450
+ )
451
+ if screen_name != client_id:
452
+ return screen_name
453
+
454
+ if state.pending_login_screen_name is not None:
455
+ return state.pending_login_screen_name
456
+
457
+ return state.screen_names_by_client_id.get(client_id)
458
+
459
+
460
+ def _parse_course(
461
+ *,
462
+ data: dict[str, Any],
463
+ raw_line: str,
464
+ state: _ParserState,
465
+ ) -> DraftStartedEvent | None:
466
+ course = _required_mapping(
467
+ data.get("Course"),
468
+ field_name="Course",
469
+ raw_line=raw_line,
470
+ )
471
+ current_module = course.get("CurrentModule")
472
+ event_name_value = course.get("InternalEventName")
473
+ if current_module != "BotDraft":
474
+ return None
475
+
476
+ event_name = _required_str(
477
+ event_name_value,
478
+ field_name="Course.InternalEventName",
479
+ raw_line=raw_line,
480
+ )
481
+ set_code = _set_code(event_name=event_name, raw_line=raw_line)
482
+ course_id = _required_str(
483
+ course.get("CourseId"),
484
+ field_name="Course.CourseId",
485
+ raw_line=raw_line,
486
+ )
487
+ return DraftStartedEvent(
488
+ event_name=event_name,
489
+ set_code=set_code,
490
+ course_id=course_id,
491
+ account_id=state.account_id,
492
+ )
493
+
494
+
495
+ def _parse_module_payload(
496
+ *,
497
+ data: dict[str, Any],
498
+ raw_line: str,
499
+ state: _ParserState,
500
+ ) -> tuple[DraftEvent, ...]:
501
+ module = _required_str(
502
+ data.get("CurrentModule"),
503
+ field_name="CurrentModule",
504
+ raw_line=raw_line,
505
+ )
506
+ if module not in {"BotDraft", "DeckSelect"}:
507
+ _raise(f"Unsupported draft CurrentModule {module!r}", raw_line=raw_line)
508
+
509
+ payload_text = _required_str(
510
+ data.get("Payload"),
511
+ field_name="Payload",
512
+ raw_line=raw_line,
513
+ )
514
+ payload = _json_object(
515
+ text=payload_text,
516
+ raw_line=raw_line,
517
+ context="module Payload",
518
+ )
519
+ result = _required_str(
520
+ payload.get("Result"),
521
+ field_name="Payload.Result",
522
+ raw_line=raw_line,
523
+ )
524
+ if result != "Success":
525
+ _raise(f"Draft payload result was {result!r}, not 'Success'", raw_line=raw_line)
526
+
527
+ event_name = _required_str(
528
+ payload.get("EventName"),
529
+ field_name="Payload.EventName",
530
+ raw_line=raw_line,
531
+ )
532
+ set_code = _set_code(event_name=event_name, raw_line=raw_line)
533
+ pack_number = _required_int(
534
+ payload.get("PackNumber"),
535
+ field_name="Payload.PackNumber",
536
+ raw_line=raw_line,
537
+ )
538
+ pick_number = _required_int(
539
+ payload.get("PickNumber"),
540
+ field_name="Payload.PickNumber",
541
+ raw_line=raw_line,
542
+ )
543
+ offered_grp_ids = _int_tuple(
544
+ payload.get("DraftPack"),
545
+ field_name="Payload.DraftPack",
546
+ raw_line=raw_line,
547
+ )
548
+ picked_grp_ids = _int_tuple(
549
+ payload.get("PickedCards"),
550
+ field_name="Payload.PickedCards",
551
+ raw_line=raw_line,
552
+ )
553
+ status_value = payload.get("DraftStatus")
554
+ if status_value is None:
555
+ status = None
556
+ else:
557
+ status = _required_str(
558
+ status_value,
559
+ field_name="Payload.DraftStatus",
560
+ raw_line=raw_line,
561
+ )
562
+
563
+ if status == "Completed":
564
+ if offered_grp_ids:
565
+ _raise(
566
+ "Completed draft payload must not include offered cards",
567
+ raw_line=raw_line,
568
+ )
569
+ return (
570
+ DraftCompletedEvent(
571
+ event_name=event_name,
572
+ set_code=set_code,
573
+ pack_number=pack_number,
574
+ pick_number=pick_number,
575
+ picked_grp_ids=picked_grp_ids,
576
+ inferred=False,
577
+ account_id=state.account_id,
578
+ ),
579
+ )
580
+
581
+ if _is_completion_shape(
582
+ pack_number=pack_number,
583
+ pick_number=pick_number,
584
+ offered_grp_ids=offered_grp_ids,
585
+ picked_grp_ids=picked_grp_ids,
586
+ ):
587
+ return (
588
+ DraftCompletedEvent(
589
+ event_name=event_name,
590
+ set_code=set_code,
591
+ pack_number=pack_number,
592
+ pick_number=pick_number,
593
+ picked_grp_ids=picked_grp_ids,
594
+ inferred=True,
595
+ account_id=state.account_id,
596
+ ),
597
+ )
598
+
599
+ if status is None:
600
+ _raise(
601
+ "Missing Payload.DraftStatus outside final completion shape",
602
+ raw_line=raw_line,
603
+ )
604
+
605
+ if status != "PickNext":
606
+ _raise(f"Unsupported draft status {status!r}", raw_line=raw_line)
607
+
608
+ if module != "BotDraft":
609
+ _raise("PickNext payload must use CurrentModule BotDraft", raw_line=raw_line)
610
+
611
+ if not offered_grp_ids:
612
+ _raise("PickNext payload must include offered DraftPack cards", raw_line=raw_line)
613
+
614
+ return (
615
+ PackOfferedEvent(
616
+ event_name=event_name,
617
+ set_code=set_code,
618
+ pack_number=pack_number,
619
+ pick_number=pick_number,
620
+ offered_grp_ids=offered_grp_ids,
621
+ pool_grp_ids=picked_grp_ids,
622
+ account_id=state.account_id,
623
+ ),
624
+ )
625
+
626
+
627
+ def _is_completion_shape(
628
+ *,
629
+ pack_number: int,
630
+ pick_number: int,
631
+ offered_grp_ids: tuple[int, ...],
632
+ picked_grp_ids: tuple[int, ...],
633
+ ) -> bool:
634
+ return (
635
+ pack_number == FINAL_PACK_NUMBER
636
+ and pick_number == FINAL_PICK_NUMBER
637
+ and offered_grp_ids == ()
638
+ and len(picked_grp_ids) == EXPECTED_TOTAL_PICKS
639
+ )
640
+
641
+
642
+ def _json_object(*, text: str, raw_line: str, context: str) -> dict[str, Any]:
643
+ try:
644
+ decoded = json.loads(text)
645
+ except json.JSONDecodeError as error:
646
+ _raise(f"Malformed {context}: {error.msg}", raw_line=raw_line)
647
+
648
+ if not isinstance(decoded, dict):
649
+ _raise(f"Malformed {context}: expected JSON object", raw_line=raw_line)
650
+
651
+ return decoded
652
+
653
+
654
+ def _required_mapping(value: Any, *, field_name: str, raw_line: str) -> dict[str, Any]:
655
+ if not isinstance(value, dict):
656
+ _raise(f"Missing or invalid {field_name}; expected object", raw_line=raw_line)
657
+
658
+ return value
659
+
660
+
661
+ def _required_str(value: Any, *, field_name: str, raw_line: str) -> str:
662
+ if not isinstance(value, str) or value == "":
663
+ _raise(
664
+ f"Missing or invalid {field_name}; expected non-empty string",
665
+ raw_line=raw_line,
666
+ )
667
+
668
+ return value
669
+
670
+
671
+ def _required_int(value: Any, *, field_name: str, raw_line: str) -> int:
672
+ if isinstance(value, bool):
673
+ _raise(f"Missing or invalid {field_name}; expected integer", raw_line=raw_line)
674
+
675
+ try:
676
+ return int(value)
677
+ except (TypeError, ValueError):
678
+ _raise(f"Missing or invalid {field_name}; expected integer", raw_line=raw_line)
679
+
680
+
681
+ def _int_tuple(value: Any, *, field_name: str, raw_line: str) -> tuple[int, ...]:
682
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
683
+ _raise(
684
+ f"Missing or invalid {field_name}; expected list of grpIds",
685
+ raw_line=raw_line,
686
+ )
687
+
688
+ return tuple(
689
+ _required_int(item, field_name=f"{field_name}[]", raw_line=raw_line)
690
+ for item in value
691
+ )
692
+
693
+
694
+ def _set_code(*, event_name: str, raw_line: str) -> str:
695
+ if not event_name.startswith(QUICK_DRAFT_PREFIX):
696
+ _raise(
697
+ f"Unsupported draft event {event_name!r}; expected QuickDraft",
698
+ raw_line=raw_line,
699
+ )
700
+
701
+ parts = event_name.split("_")
702
+ if len(parts) < 2 or parts[1] == "":
703
+ _raise("Quick Draft event name does not include a set code", raw_line=raw_line)
704
+
705
+ return parts[1]
706
+
707
+
708
+ def _json_line_may_contain_events(line: str) -> bool:
709
+ return any(
710
+ token in line
711
+ for token in (
712
+ "authenticateResponse",
713
+ "Course",
714
+ "CurrentModule",
715
+ "Payload",
716
+ QUICK_DRAFT_PREFIX,
717
+ "BotDraft",
718
+ "DraftPack",
719
+ "PickInfo",
720
+ "DraftStatus",
721
+ )
722
+ )
723
+
724
+
725
+ def _payload_line_is_draft_shaped(*, data: dict[str, Any]) -> bool:
726
+ module = data.get("CurrentModule")
727
+ payload = data.get("Payload")
728
+ if module == "BotDraft":
729
+ return True
730
+
731
+ if isinstance(payload, str):
732
+ return any(
733
+ token in payload
734
+ for token in (QUICK_DRAFT_PREFIX, "DraftStatus", "DraftPack", "PickedCards")
735
+ )
736
+
737
+ return False
738
+
739
+
740
+ def _mapping_contains_draft_shape(data: dict[str, Any]) -> bool:
741
+ if data.get("CurrentModule") == "BotDraft":
742
+ return True
743
+
744
+ return any(
745
+ key in data
746
+ for key in (
747
+ "DraftStatus",
748
+ "DraftPack",
749
+ "PickedCards",
750
+ "PickInfo",
751
+ "BotDraft",
752
+ )
753
+ )
754
+
755
+
756
+ def _contains_unparsed_draft_shape(line: str) -> bool:
757
+ if "BotDraft_Draft" in line:
758
+ return True
759
+
760
+ return any(
761
+ token in line
762
+ for token in (
763
+ "\"BotDraftDraft",
764
+ "\"DraftPack\"",
765
+ "\"PickInfo\"",
766
+ "\"DraftStatus\"",
767
+ )
768
+ )
769
+
770
+
771
+ def _raise(message: str, *, raw_line: str) -> NoReturn:
772
+ raise DraftLogParseError(message, raw_line=raw_line)