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.
- draftomen/__init__.py +17 -0
- draftomen/assets/draftomen.icns +0 -0
- draftomen/assets/draftomen.ico +0 -0
- draftomen/assets/draftomen_logo.png +0 -0
- draftomen/audit.py +606 -0
- draftomen/backtest.py +449 -0
- draftomen/benchmark.py +834 -0
- draftomen/carddb.py +1869 -0
- draftomen/cardimages.py +313 -0
- draftomen/cli.py +891 -0
- draftomen/config.py +133 -0
- draftomen/deckbuilder.py +2723 -0
- draftomen/events.py +772 -0
- draftomen/logfollow.py +451 -0
- draftomen/mock_session.py +745 -0
- draftomen/paths.py +120 -0
- draftomen/pickengine.py +1117 -0
- draftomen/pool.py +1259 -0
- draftomen/preferences.py +289 -0
- draftomen/qml/AboutDialog.qml +156 -0
- draftomen/qml/AppBar.qml +120 -0
- draftomen/qml/BacktestView.qml +316 -0
- draftomen/qml/BuildView.qml +1151 -0
- draftomen/qml/CardPreview.qml +434 -0
- draftomen/qml/DimensionalButton.qml +42 -0
- draftomen/qml/DimensionalComboBox.qml +182 -0
- draftomen/qml/DimensionalSurface.qml +118 -0
- draftomen/qml/DimensionalTabButton.qml +41 -0
- draftomen/qml/LiveDraftView.qml +468 -0
- draftomen/qml/Main.qml +175 -0
- draftomen/qml/NavigationRail.qml +226 -0
- draftomen/qml/PoolSummaryPanel.qml +322 -0
- draftomen/qml/PrivacyDialog.qml +96 -0
- draftomen/qml/RecentPickThumbnail.qml +83 -0
- draftomen/qml/RecentPicksGallery.qml +333 -0
- draftomen/qml/RecommendationRow.qml +404 -0
- draftomen/qml/SettingsSwitch.qml +141 -0
- draftomen/qml/SettingsView.qml +531 -0
- draftomen/qml/StateBanner.qml +189 -0
- draftomen/qml/StatusStrip.qml +84 -0
- draftomen/qml/Theme.qml +71 -0
- draftomen/qml/qmldir +23 -0
- draftomen/qt_adapter.py +884 -0
- draftomen/qt_gui.py +338 -0
- draftomen/qt_mock.py +63 -0
- draftomen/ranking.py +126 -0
- draftomen/replay.py +480 -0
- draftomen/session.py +3668 -0
- draftomen/setinfo.py +26 -0
- draftomen/seventeen.py +2766 -0
- draftomen/splash.py +617 -0
- draftomen/tui.py +4007 -0
- draftomen/watch.py +336 -0
- draftomen-0.3.0.dist-info/METADATA +156 -0
- draftomen-0.3.0.dist-info/RECORD +59 -0
- draftomen-0.3.0.dist-info/WHEEL +5 -0
- draftomen-0.3.0.dist-info/entry_points.txt +6 -0
- draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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)
|