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/pool.py
ADDED
|
@@ -0,0 +1,1259 @@
|
|
|
1
|
+
"""Per-account draft pool state and JSON persistence.
|
|
2
|
+
Consume parser events into resumable draft snapshots keyed by account and draft.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from os import PathLike
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, TypeAlias
|
|
16
|
+
|
|
17
|
+
from draftomen.events import (
|
|
18
|
+
AccountEvent,
|
|
19
|
+
DraftCompletedEvent,
|
|
20
|
+
DraftEvent,
|
|
21
|
+
DraftStartedEvent,
|
|
22
|
+
PackOfferedEvent,
|
|
23
|
+
PickMadeEvent,
|
|
24
|
+
QuickDraftDetectedEvent,
|
|
25
|
+
)
|
|
26
|
+
from draftomen.paths import app_data_dir
|
|
27
|
+
|
|
28
|
+
PathInput: TypeAlias = str | PathLike[str]
|
|
29
|
+
Clock: TypeAlias = Callable[[], datetime]
|
|
30
|
+
|
|
31
|
+
STATE_DIRECTORY_NAME = "state"
|
|
32
|
+
STATE_SCHEMA_VERSION = 1
|
|
33
|
+
ACCOUNT_PROFILE_DIRECTORY_NAME = "accounts"
|
|
34
|
+
ACCOUNT_PROFILE_SCHEMA_VERSION = 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DraftPoolError(RuntimeError):
|
|
38
|
+
"""Raised when persisted draft pool state is invalid or conflicting.
|
|
39
|
+
Callers can surface this as a concise replay or watch diagnostic.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class DraftPick:
|
|
45
|
+
"""One draft pick coordinate with offered and chosen card data.
|
|
46
|
+
Offered cards remain optional so pick-only streams can still build pools.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
pack_number: int
|
|
50
|
+
pick_number: int
|
|
51
|
+
offered_grp_ids: tuple[int, ...] | None = None
|
|
52
|
+
pool_before_pick: tuple[int, ...] | None = None
|
|
53
|
+
chosen_grp_id: int | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def coordinate(self) -> tuple[int, int]:
|
|
57
|
+
"""Return the pack/pick tuple used for idempotent merges.
|
|
58
|
+
Coordinates are unique within one draft state.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
return (self.pack_number, self.pick_number)
|
|
62
|
+
|
|
63
|
+
def to_json(self) -> dict[str, object]:
|
|
64
|
+
"""Convert this pick to Draftomen's state JSON shape.
|
|
65
|
+
Lists are used on disk for stable, readable JSON.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"pack_number": self.pack_number,
|
|
70
|
+
"pick_number": self.pick_number,
|
|
71
|
+
"offered_grp_ids": _optional_int_list(self.offered_grp_ids),
|
|
72
|
+
"pool_before_pick": _optional_int_list(self.pool_before_pick),
|
|
73
|
+
"chosen_grp_id": self.chosen_grp_id,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_json(cls, data: Mapping[str, Any]) -> DraftPick:
|
|
78
|
+
"""Load one pick from Draftomen's state JSON shape.
|
|
79
|
+
Parsing is strict so corrupted state fails before use.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
return cls(
|
|
83
|
+
pack_number=_required_int(data.get("pack_number"), field_name="pick.pack_number"),
|
|
84
|
+
pick_number=_required_int(data.get("pick_number"), field_name="pick.pick_number"),
|
|
85
|
+
offered_grp_ids=_optional_int_tuple(
|
|
86
|
+
data.get("offered_grp_ids"),
|
|
87
|
+
field_name="pick.offered_grp_ids",
|
|
88
|
+
),
|
|
89
|
+
pool_before_pick=_optional_int_tuple(
|
|
90
|
+
data.get("pool_before_pick"),
|
|
91
|
+
field_name="pick.pool_before_pick",
|
|
92
|
+
),
|
|
93
|
+
chosen_grp_id=_optional_int(
|
|
94
|
+
data.get("chosen_grp_id"),
|
|
95
|
+
field_name="pick.chosen_grp_id",
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class DraftState:
|
|
102
|
+
"""Persisted state for one account-scoped Quick Draft.
|
|
103
|
+
The pool is the ordered list of chosen Arena grpIds accumulated so far.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
account_id: str
|
|
107
|
+
draft_id: str
|
|
108
|
+
event_name: str
|
|
109
|
+
set_code: str
|
|
110
|
+
course_id: str | None
|
|
111
|
+
started_at: str
|
|
112
|
+
updated_at: str
|
|
113
|
+
completed_at: str | None
|
|
114
|
+
completed: bool
|
|
115
|
+
picks: tuple[DraftPick, ...]
|
|
116
|
+
pool_grp_ids: tuple[int, ...]
|
|
117
|
+
account_screen_name: str | None = None
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def chosen_pick_count(self) -> int:
|
|
121
|
+
"""Return the number of picks with a chosen card.
|
|
122
|
+
This is the draft pick count represented in the accumulated pool.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
return sum(1 for pick in self.picks if pick.chosen_grp_id is not None)
|
|
126
|
+
|
|
127
|
+
def pick_for(self, *, pack_number: int, pick_number: int) -> DraftPick | None:
|
|
128
|
+
"""Return an existing pick for the coordinate, if present.
|
|
129
|
+
The state keeps picks sorted but lookup remains explicit and simple.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
for pick in self.picks:
|
|
133
|
+
if pick.pack_number == pack_number and pick.pick_number == pick_number:
|
|
134
|
+
return pick
|
|
135
|
+
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
def to_json(self) -> dict[str, object]:
|
|
139
|
+
"""Convert this state to Draftomen's state JSON shape.
|
|
140
|
+
Keys are intentionally explicit to make persisted drafts inspectable.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
"schema_version": STATE_SCHEMA_VERSION,
|
|
145
|
+
"account_id": self.account_id,
|
|
146
|
+
"account_screen_name": self.account_screen_name,
|
|
147
|
+
"draft_id": self.draft_id,
|
|
148
|
+
"event_name": self.event_name,
|
|
149
|
+
"set_code": self.set_code,
|
|
150
|
+
"course_id": self.course_id,
|
|
151
|
+
"started_at": self.started_at,
|
|
152
|
+
"updated_at": self.updated_at,
|
|
153
|
+
"completed_at": self.completed_at,
|
|
154
|
+
"completed": self.completed,
|
|
155
|
+
"picks": [pick.to_json() for pick in self.picks],
|
|
156
|
+
"pool_grp_ids": list(self.pool_grp_ids),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def from_json(cls, data: Mapping[str, Any]) -> DraftState:
|
|
161
|
+
"""Load a draft state from Draftomen's JSON shape.
|
|
162
|
+
Schema mismatches and duplicate coordinates fail loudly.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
schema_version = _required_int(
|
|
166
|
+
data.get("schema_version"),
|
|
167
|
+
field_name="schema_version",
|
|
168
|
+
)
|
|
169
|
+
if schema_version != STATE_SCHEMA_VERSION:
|
|
170
|
+
raise DraftPoolError(
|
|
171
|
+
"Unsupported draft state schema "
|
|
172
|
+
f"{schema_version}; expected {STATE_SCHEMA_VERSION}."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
picks_value = data.get("picks")
|
|
176
|
+
if not isinstance(picks_value, list):
|
|
177
|
+
raise DraftPoolError("Draft state is missing picks list.")
|
|
178
|
+
|
|
179
|
+
picks = _sorted_picks(
|
|
180
|
+
picks=tuple(
|
|
181
|
+
_draft_pick_from_value(value=value, index=index)
|
|
182
|
+
for index, value in enumerate(picks_value)
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
return cls(
|
|
186
|
+
account_id=_required_str(data.get("account_id"), field_name="account_id"),
|
|
187
|
+
account_screen_name=_optional_str(
|
|
188
|
+
data.get("account_screen_name"),
|
|
189
|
+
field_name="account_screen_name",
|
|
190
|
+
),
|
|
191
|
+
draft_id=_required_str(data.get("draft_id"), field_name="draft_id"),
|
|
192
|
+
event_name=_required_str(data.get("event_name"), field_name="event_name"),
|
|
193
|
+
set_code=_required_str(data.get("set_code"), field_name="set_code"),
|
|
194
|
+
course_id=_optional_str(data.get("course_id"), field_name="course_id"),
|
|
195
|
+
started_at=_required_str(data.get("started_at"), field_name="started_at"),
|
|
196
|
+
updated_at=_required_str(data.get("updated_at"), field_name="updated_at"),
|
|
197
|
+
completed_at=_optional_str(data.get("completed_at"), field_name="completed_at"),
|
|
198
|
+
completed=_required_bool(data.get("completed"), field_name="completed"),
|
|
199
|
+
picks=picks,
|
|
200
|
+
pool_grp_ids=_int_tuple(data.get("pool_grp_ids"), field_name="pool_grp_ids"),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True, slots=True)
|
|
205
|
+
class AccountProfile:
|
|
206
|
+
"""Durable display metadata for one MTGA account.
|
|
207
|
+
Profiles keep account names independent from individual draft snapshots.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
account_id: str
|
|
211
|
+
screen_name: str
|
|
212
|
+
|
|
213
|
+
def to_json(self) -> dict[str, object]:
|
|
214
|
+
"""Convert this profile to its small on-disk JSON shape.
|
|
215
|
+
The account id is repeated to validate the enclosing directory.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"schema_version": ACCOUNT_PROFILE_SCHEMA_VERSION,
|
|
220
|
+
"account_id": self.account_id,
|
|
221
|
+
"screen_name": self.screen_name,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@classmethod
|
|
225
|
+
def from_json(cls, data: Mapping[str, Any]) -> AccountProfile:
|
|
226
|
+
"""Load one profile from its on-disk JSON shape.
|
|
227
|
+
Schema mismatches and malformed values fail before use.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
schema_version = _required_int(
|
|
231
|
+
data.get("schema_version"),
|
|
232
|
+
field_name="account profile.schema_version",
|
|
233
|
+
)
|
|
234
|
+
if schema_version != ACCOUNT_PROFILE_SCHEMA_VERSION:
|
|
235
|
+
raise DraftPoolError(
|
|
236
|
+
"Unsupported account profile schema "
|
|
237
|
+
f"{schema_version}; expected {ACCOUNT_PROFILE_SCHEMA_VERSION}."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return cls(
|
|
241
|
+
account_id=_required_str(
|
|
242
|
+
data.get("account_id"),
|
|
243
|
+
field_name="account profile.account_id",
|
|
244
|
+
),
|
|
245
|
+
screen_name=_required_str(
|
|
246
|
+
data.get("screen_name"),
|
|
247
|
+
field_name="account profile.screen_name",
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def draft_state_root(*, app_dir: PathInput | None = None) -> Path:
|
|
253
|
+
"""Return the root directory for persisted draft state.
|
|
254
|
+
The directory is created by save operations, not path resolution.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
root = Path(app_data_dir() if app_dir is None else app_dir)
|
|
258
|
+
return root / STATE_DIRECTORY_NAME
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def draft_state_path(
|
|
262
|
+
*,
|
|
263
|
+
account_id: str,
|
|
264
|
+
draft_id: str,
|
|
265
|
+
app_dir: PathInput | None = None,
|
|
266
|
+
) -> Path:
|
|
267
|
+
"""Return the JSON path for one account and draft id.
|
|
268
|
+
Account ids map to one subdirectory each under the state root.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
draft_state_root(app_dir=app_dir)
|
|
273
|
+
/ _path_segment(value=account_id, field_name="account_id")
|
|
274
|
+
/ f"{_path_segment(value=draft_id, field_name='draft_id')}.json"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def account_profile_path(
|
|
279
|
+
*,
|
|
280
|
+
account_id: str,
|
|
281
|
+
app_dir: PathInput | None = None,
|
|
282
|
+
) -> Path:
|
|
283
|
+
"""Return the durable display-profile path for one MTGA account.
|
|
284
|
+
Profiles live separately from draft snapshots in the app data directory.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
root = Path(app_data_dir() if app_dir is None else app_dir)
|
|
288
|
+
return (
|
|
289
|
+
root
|
|
290
|
+
/ ACCOUNT_PROFILE_DIRECTORY_NAME
|
|
291
|
+
/ f"{_path_segment(value=account_id, field_name='account_id')}.json"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def list_account_profiles(
|
|
296
|
+
*,
|
|
297
|
+
app_dir: PathInput | None = None,
|
|
298
|
+
) -> tuple[AccountProfile, ...]:
|
|
299
|
+
"""Load every persisted account profile in stable account-id order.
|
|
300
|
+
Profiles remain discoverable even when an account has no saved draft.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
root = Path(app_data_dir() if app_dir is None else app_dir)
|
|
304
|
+
profile_root = root / ACCOUNT_PROFILE_DIRECTORY_NAME
|
|
305
|
+
if not profile_root.exists():
|
|
306
|
+
return ()
|
|
307
|
+
|
|
308
|
+
profiles: list[AccountProfile] = []
|
|
309
|
+
for path in sorted(profile_root.glob("*.json")):
|
|
310
|
+
profile = _load_account_profile(
|
|
311
|
+
account_id=path.stem,
|
|
312
|
+
app_dir=app_dir,
|
|
313
|
+
)
|
|
314
|
+
if profile is not None:
|
|
315
|
+
profiles.append(profile)
|
|
316
|
+
|
|
317
|
+
return tuple(profiles)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def load_draft_state(
|
|
321
|
+
*,
|
|
322
|
+
account_id: str,
|
|
323
|
+
draft_id: str,
|
|
324
|
+
app_dir: PathInput | None = None,
|
|
325
|
+
) -> DraftState:
|
|
326
|
+
"""Load one persisted draft state by account and draft id.
|
|
327
|
+
Missing or malformed files raise DraftPoolError with context.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
path = draft_state_path(account_id=account_id, draft_id=draft_id, app_dir=app_dir)
|
|
331
|
+
return _with_account_profile(state=_load_state_file(path=path), app_dir=app_dir)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def list_draft_states(*, app_dir: PathInput | None = None) -> tuple[DraftState, ...]:
|
|
335
|
+
"""Load every persisted draft state under the state root.
|
|
336
|
+
States are returned in stable account and filename order.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
root = draft_state_root(app_dir=app_dir)
|
|
340
|
+
if not root.exists():
|
|
341
|
+
return ()
|
|
342
|
+
|
|
343
|
+
states: list[DraftState] = []
|
|
344
|
+
for account_dir in sorted(root.iterdir()):
|
|
345
|
+
if account_dir.is_dir():
|
|
346
|
+
states.extend(
|
|
347
|
+
_with_account_profile(state=state, app_dir=app_dir)
|
|
348
|
+
for state in _load_matching_states(account_dir=account_dir)
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return tuple(states)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def save_draft_state(
|
|
355
|
+
state: DraftState,
|
|
356
|
+
*,
|
|
357
|
+
app_dir: PathInput | None = None,
|
|
358
|
+
) -> Path:
|
|
359
|
+
"""Persist one draft state atomically and return its path.
|
|
360
|
+
Parent directories are created so each account gets its own folder.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
path = draft_state_path(
|
|
364
|
+
account_id=state.account_id,
|
|
365
|
+
draft_id=state.draft_id,
|
|
366
|
+
app_dir=app_dir,
|
|
367
|
+
)
|
|
368
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
payload = json.dumps(state.to_json(), indent=2, sort_keys=True)
|
|
370
|
+
with tempfile.NamedTemporaryFile(
|
|
371
|
+
"w",
|
|
372
|
+
delete=False,
|
|
373
|
+
dir=path.parent,
|
|
374
|
+
encoding="utf-8",
|
|
375
|
+
) as temporary_file:
|
|
376
|
+
temporary_file.write(payload)
|
|
377
|
+
temporary_file.write("\n")
|
|
378
|
+
temporary_path = Path(temporary_file.name)
|
|
379
|
+
|
|
380
|
+
temporary_path.replace(path)
|
|
381
|
+
return path
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class DraftPoolStore:
|
|
385
|
+
"""Consume parser events into persisted per-account draft states.
|
|
386
|
+
Replaying already-seen events is idempotent and leaves JSON unchanged.
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
def __init__(
|
|
390
|
+
self,
|
|
391
|
+
*,
|
|
392
|
+
app_dir: PathInput | None = None,
|
|
393
|
+
clock: Clock | None = None,
|
|
394
|
+
) -> None:
|
|
395
|
+
self.app_dir = app_dir
|
|
396
|
+
self.root = draft_state_root(app_dir=app_dir)
|
|
397
|
+
self._clock = _system_clock if clock is None else clock
|
|
398
|
+
self._active_account_id: str | None = None
|
|
399
|
+
self._account_screen_names: dict[str, str] = {}
|
|
400
|
+
self._draft_ids_by_event: dict[tuple[str, str], str] = {}
|
|
401
|
+
|
|
402
|
+
def consume_all(self, *, events: Iterable[DraftEvent]) -> tuple[DraftState, ...]:
|
|
403
|
+
"""Consume an iterable of events and return touched draft states.
|
|
404
|
+
The returned state for each key is the latest state seen in the stream.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
touched: dict[tuple[str, str], DraftState] = {}
|
|
408
|
+
for event in events:
|
|
409
|
+
state = self.consume(event=event)
|
|
410
|
+
if state is not None:
|
|
411
|
+
touched[(state.account_id, state.draft_id)] = state
|
|
412
|
+
|
|
413
|
+
return tuple(touched.values())
|
|
414
|
+
|
|
415
|
+
def consume(self, *, event: DraftEvent) -> DraftState | None:
|
|
416
|
+
"""Consume one parser event and persist any changed draft state.
|
|
417
|
+
Account events only update the active account context.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
if isinstance(event, AccountEvent):
|
|
421
|
+
return self._consume_account(event=event)
|
|
422
|
+
|
|
423
|
+
if isinstance(event, QuickDraftDetectedEvent):
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
if isinstance(event, DraftStartedEvent):
|
|
427
|
+
return self._consume_started(event=event)
|
|
428
|
+
|
|
429
|
+
if isinstance(event, PackOfferedEvent):
|
|
430
|
+
return self._consume_pack_offered(event=event)
|
|
431
|
+
|
|
432
|
+
if isinstance(event, PickMadeEvent):
|
|
433
|
+
return self._consume_pick_made(event=event)
|
|
434
|
+
|
|
435
|
+
if isinstance(event, DraftCompletedEvent):
|
|
436
|
+
return self._consume_completed(event=event)
|
|
437
|
+
|
|
438
|
+
raise DraftPoolError(f"Unsupported draft event {event!r}.")
|
|
439
|
+
|
|
440
|
+
def load(self, *, account_id: str, draft_id: str) -> DraftState:
|
|
441
|
+
"""Load a draft state from this store's app directory.
|
|
442
|
+
This is a convenience wrapper around load_draft_state.
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
return load_draft_state(
|
|
446
|
+
account_id=account_id,
|
|
447
|
+
draft_id=draft_id,
|
|
448
|
+
app_dir=self.app_dir,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
def path_for(self, *, account_id: str, draft_id: str) -> Path:
|
|
452
|
+
"""Return the JSON path in this store's app directory.
|
|
453
|
+
The path may not exist until a matching state is saved.
|
|
454
|
+
"""
|
|
455
|
+
|
|
456
|
+
return draft_state_path(
|
|
457
|
+
account_id=account_id,
|
|
458
|
+
draft_id=draft_id,
|
|
459
|
+
app_dir=self.app_dir,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
def set_active_account(
|
|
463
|
+
self,
|
|
464
|
+
*,
|
|
465
|
+
account_id: str,
|
|
466
|
+
screen_name: str | None = None,
|
|
467
|
+
) -> None:
|
|
468
|
+
"""Set account context for later account-less draft events.
|
|
469
|
+
UI recovery uses this after selecting persisted account state.
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
self._active_account_id = account_id
|
|
473
|
+
if screen_name is not None:
|
|
474
|
+
self._account_screen_names[account_id] = screen_name
|
|
475
|
+
_save_account_profile(
|
|
476
|
+
account_id=account_id,
|
|
477
|
+
screen_name=screen_name,
|
|
478
|
+
app_dir=self.app_dir,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
def clear_active_account(self) -> None:
|
|
482
|
+
"""Clear account context after the log starts a new login sequence.
|
|
483
|
+
Account-less events must not inherit the previous session's identity.
|
|
484
|
+
"""
|
|
485
|
+
|
|
486
|
+
self._active_account_id = None
|
|
487
|
+
|
|
488
|
+
def _consume_account(self, *, event: AccountEvent) -> None:
|
|
489
|
+
self.set_active_account(
|
|
490
|
+
account_id=event.client_id,
|
|
491
|
+
screen_name=event.screen_name,
|
|
492
|
+
)
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
def _consume_started(self, *, event: DraftStartedEvent) -> DraftState:
|
|
496
|
+
account_id = self._account_id_for_started_event(event=event)
|
|
497
|
+
draft_id = event.course_id
|
|
498
|
+
self._remember_draft(
|
|
499
|
+
account_id=account_id,
|
|
500
|
+
event_name=event.event_name,
|
|
501
|
+
draft_id=draft_id,
|
|
502
|
+
)
|
|
503
|
+
state = self._load_existing_state(account_id=account_id, draft_id=draft_id)
|
|
504
|
+
if state is not None:
|
|
505
|
+
_ensure_metadata(
|
|
506
|
+
state=state,
|
|
507
|
+
event_name=event.event_name,
|
|
508
|
+
set_code=event.set_code,
|
|
509
|
+
course_id=event.course_id,
|
|
510
|
+
)
|
|
511
|
+
return state
|
|
512
|
+
|
|
513
|
+
now = self._now_iso()
|
|
514
|
+
state = DraftState(
|
|
515
|
+
account_id=account_id,
|
|
516
|
+
draft_id=draft_id,
|
|
517
|
+
event_name=event.event_name,
|
|
518
|
+
set_code=event.set_code,
|
|
519
|
+
course_id=event.course_id,
|
|
520
|
+
started_at=now,
|
|
521
|
+
updated_at=now,
|
|
522
|
+
completed_at=None,
|
|
523
|
+
completed=False,
|
|
524
|
+
picks=(),
|
|
525
|
+
pool_grp_ids=(),
|
|
526
|
+
account_screen_name=self._account_screen_name(account_id=account_id),
|
|
527
|
+
)
|
|
528
|
+
save_draft_state(state=state, app_dir=self.app_dir)
|
|
529
|
+
return state
|
|
530
|
+
|
|
531
|
+
def _consume_pack_offered(self, *, event: PackOfferedEvent) -> DraftState:
|
|
532
|
+
state = self._state_for_draft_event(
|
|
533
|
+
account_id=event.account_id,
|
|
534
|
+
event_name=event.event_name,
|
|
535
|
+
set_code=event.set_code,
|
|
536
|
+
)
|
|
537
|
+
existing_pick = state.pick_for(
|
|
538
|
+
pack_number=event.pack_number,
|
|
539
|
+
pick_number=event.pick_number,
|
|
540
|
+
)
|
|
541
|
+
if _is_new_draft_pack_conflict(existing_pick=existing_pick, event=event):
|
|
542
|
+
state = self._start_new_state_after_pack_conflict(
|
|
543
|
+
account_id=state.account_id,
|
|
544
|
+
event_name=event.event_name,
|
|
545
|
+
set_code=event.set_code,
|
|
546
|
+
)
|
|
547
|
+
existing_pick = None
|
|
548
|
+
|
|
549
|
+
if existing_pick is None or existing_pick.chosen_grp_id is None:
|
|
550
|
+
_ensure_pool_snapshot(state=state, pool_grp_ids=event.pool_grp_ids)
|
|
551
|
+
|
|
552
|
+
merged_pick = _merge_pick(
|
|
553
|
+
existing_pick=existing_pick,
|
|
554
|
+
pack_number=event.pack_number,
|
|
555
|
+
pick_number=event.pick_number,
|
|
556
|
+
offered_grp_ids=event.offered_grp_ids,
|
|
557
|
+
pool_before_pick=event.pool_grp_ids,
|
|
558
|
+
chosen_grp_id=None,
|
|
559
|
+
)
|
|
560
|
+
if existing_pick == merged_pick:
|
|
561
|
+
return state
|
|
562
|
+
|
|
563
|
+
updated = replace(
|
|
564
|
+
state,
|
|
565
|
+
picks=_replace_pick(picks=state.picks, pick=merged_pick),
|
|
566
|
+
updated_at=self._now_iso(),
|
|
567
|
+
)
|
|
568
|
+
save_draft_state(state=updated, app_dir=self.app_dir)
|
|
569
|
+
return updated
|
|
570
|
+
|
|
571
|
+
def _consume_pick_made(self, *, event: PickMadeEvent) -> DraftState:
|
|
572
|
+
state = self._state_for_draft_event(
|
|
573
|
+
account_id=event.account_id,
|
|
574
|
+
event_name=event.event_name,
|
|
575
|
+
set_code=event.set_code,
|
|
576
|
+
)
|
|
577
|
+
existing_pick = state.pick_for(
|
|
578
|
+
pack_number=event.pack_number,
|
|
579
|
+
pick_number=event.pick_number,
|
|
580
|
+
)
|
|
581
|
+
merged_pick = _merge_pick(
|
|
582
|
+
existing_pick=existing_pick,
|
|
583
|
+
pack_number=event.pack_number,
|
|
584
|
+
pick_number=event.pick_number,
|
|
585
|
+
offered_grp_ids=None,
|
|
586
|
+
pool_before_pick=None,
|
|
587
|
+
chosen_grp_id=event.chosen_grp_id,
|
|
588
|
+
)
|
|
589
|
+
if existing_pick == merged_pick:
|
|
590
|
+
return state
|
|
591
|
+
|
|
592
|
+
updated = replace(
|
|
593
|
+
state,
|
|
594
|
+
picks=_replace_pick(picks=state.picks, pick=merged_pick),
|
|
595
|
+
pool_grp_ids=state.pool_grp_ids + (event.chosen_grp_id,),
|
|
596
|
+
updated_at=self._now_iso(),
|
|
597
|
+
)
|
|
598
|
+
save_draft_state(state=updated, app_dir=self.app_dir)
|
|
599
|
+
return updated
|
|
600
|
+
|
|
601
|
+
def _consume_completed(self, *, event: DraftCompletedEvent) -> DraftState:
|
|
602
|
+
state = self._state_for_draft_event(
|
|
603
|
+
account_id=event.account_id,
|
|
604
|
+
event_name=event.event_name,
|
|
605
|
+
set_code=event.set_code,
|
|
606
|
+
)
|
|
607
|
+
if state.completed:
|
|
608
|
+
if not _same_pool_contents(state.pool_grp_ids, event.picked_grp_ids):
|
|
609
|
+
raise DraftPoolError(
|
|
610
|
+
f"Completion for draft {state.draft_id!r} conflicts with saved pool."
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
return state
|
|
614
|
+
|
|
615
|
+
pool_grp_ids = event.picked_grp_ids if not state.pool_grp_ids else state.pool_grp_ids
|
|
616
|
+
if not _same_pool_contents(pool_grp_ids, event.picked_grp_ids):
|
|
617
|
+
raise DraftPoolError(
|
|
618
|
+
f"Completion for draft {state.draft_id!r} does not match accumulated pool."
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
now = self._now_iso()
|
|
622
|
+
updated = replace(
|
|
623
|
+
state,
|
|
624
|
+
completed=True,
|
|
625
|
+
completed_at=now,
|
|
626
|
+
updated_at=now,
|
|
627
|
+
pool_grp_ids=pool_grp_ids,
|
|
628
|
+
)
|
|
629
|
+
save_draft_state(state=updated, app_dir=self.app_dir)
|
|
630
|
+
return updated
|
|
631
|
+
|
|
632
|
+
def _state_for_draft_event(
|
|
633
|
+
self,
|
|
634
|
+
*,
|
|
635
|
+
account_id: str | None,
|
|
636
|
+
event_name: str,
|
|
637
|
+
set_code: str,
|
|
638
|
+
) -> DraftState:
|
|
639
|
+
resolved_account_id = self._account_id_for_draft_event(
|
|
640
|
+
account_id=account_id,
|
|
641
|
+
event_name=event_name,
|
|
642
|
+
set_code=set_code,
|
|
643
|
+
)
|
|
644
|
+
draft_id = self._draft_ids_by_event.get((resolved_account_id, event_name))
|
|
645
|
+
if draft_id is not None:
|
|
646
|
+
state = self._load_existing_state(
|
|
647
|
+
account_id=resolved_account_id,
|
|
648
|
+
draft_id=draft_id,
|
|
649
|
+
)
|
|
650
|
+
if state is None:
|
|
651
|
+
return self._new_event_named_state(
|
|
652
|
+
account_id=resolved_account_id,
|
|
653
|
+
event_name=event_name,
|
|
654
|
+
set_code=set_code,
|
|
655
|
+
draft_id=draft_id,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
_ensure_metadata(
|
|
659
|
+
state=state,
|
|
660
|
+
event_name=event_name,
|
|
661
|
+
set_code=set_code,
|
|
662
|
+
course_id=state.course_id,
|
|
663
|
+
)
|
|
664
|
+
return state
|
|
665
|
+
|
|
666
|
+
resumed = self._find_resume_state(
|
|
667
|
+
account_id=resolved_account_id,
|
|
668
|
+
event_name=event_name,
|
|
669
|
+
set_code=set_code,
|
|
670
|
+
)
|
|
671
|
+
if resumed is not None:
|
|
672
|
+
self._remember_draft(
|
|
673
|
+
account_id=resolved_account_id,
|
|
674
|
+
event_name=event_name,
|
|
675
|
+
draft_id=resumed.draft_id,
|
|
676
|
+
)
|
|
677
|
+
return resumed
|
|
678
|
+
|
|
679
|
+
self._remember_draft(
|
|
680
|
+
account_id=resolved_account_id,
|
|
681
|
+
event_name=event_name,
|
|
682
|
+
draft_id=event_name,
|
|
683
|
+
)
|
|
684
|
+
return self._new_event_named_state(
|
|
685
|
+
account_id=resolved_account_id,
|
|
686
|
+
event_name=event_name,
|
|
687
|
+
set_code=set_code,
|
|
688
|
+
draft_id=event_name,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
def _start_new_state_after_pack_conflict(
|
|
692
|
+
self,
|
|
693
|
+
*,
|
|
694
|
+
account_id: str,
|
|
695
|
+
event_name: str,
|
|
696
|
+
set_code: str,
|
|
697
|
+
) -> DraftState:
|
|
698
|
+
draft_id = self._unused_synthetic_draft_id(
|
|
699
|
+
account_id=account_id,
|
|
700
|
+
event_name=event_name,
|
|
701
|
+
)
|
|
702
|
+
self._remember_draft(
|
|
703
|
+
account_id=account_id,
|
|
704
|
+
event_name=event_name,
|
|
705
|
+
draft_id=draft_id,
|
|
706
|
+
)
|
|
707
|
+
return self._new_event_named_state(
|
|
708
|
+
account_id=account_id,
|
|
709
|
+
event_name=event_name,
|
|
710
|
+
set_code=set_code,
|
|
711
|
+
draft_id=draft_id,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
def _unused_synthetic_draft_id(self, *, account_id: str, event_name: str) -> str:
|
|
715
|
+
base = f"{event_name}-{self._now_iso()}"
|
|
716
|
+
draft_id = base
|
|
717
|
+
index = 2
|
|
718
|
+
while self.path_for(account_id=account_id, draft_id=draft_id).exists():
|
|
719
|
+
draft_id = f"{base}-{index}"
|
|
720
|
+
index += 1
|
|
721
|
+
|
|
722
|
+
return draft_id
|
|
723
|
+
|
|
724
|
+
def _new_event_named_state(
|
|
725
|
+
self,
|
|
726
|
+
*,
|
|
727
|
+
account_id: str,
|
|
728
|
+
event_name: str,
|
|
729
|
+
set_code: str,
|
|
730
|
+
draft_id: str,
|
|
731
|
+
) -> DraftState:
|
|
732
|
+
now = self._now_iso()
|
|
733
|
+
state = DraftState(
|
|
734
|
+
account_id=account_id,
|
|
735
|
+
draft_id=draft_id,
|
|
736
|
+
event_name=event_name,
|
|
737
|
+
set_code=set_code,
|
|
738
|
+
course_id=None,
|
|
739
|
+
started_at=now,
|
|
740
|
+
updated_at=now,
|
|
741
|
+
completed_at=None,
|
|
742
|
+
completed=False,
|
|
743
|
+
picks=(),
|
|
744
|
+
pool_grp_ids=(),
|
|
745
|
+
account_screen_name=self._account_screen_name(account_id=account_id),
|
|
746
|
+
)
|
|
747
|
+
save_draft_state(state=state, app_dir=self.app_dir)
|
|
748
|
+
return state
|
|
749
|
+
|
|
750
|
+
def _find_resume_state(
|
|
751
|
+
self,
|
|
752
|
+
*,
|
|
753
|
+
account_id: str,
|
|
754
|
+
event_name: str,
|
|
755
|
+
set_code: str,
|
|
756
|
+
) -> DraftState | None:
|
|
757
|
+
account_dir = self.root / _path_segment(value=account_id, field_name="account_id")
|
|
758
|
+
if not account_dir.exists():
|
|
759
|
+
return None
|
|
760
|
+
|
|
761
|
+
matches = [
|
|
762
|
+
state
|
|
763
|
+
for state in _load_matching_states(account_dir=account_dir)
|
|
764
|
+
if state.event_name == event_name and state.set_code == set_code
|
|
765
|
+
]
|
|
766
|
+
active_matches = [state for state in matches if not state.completed]
|
|
767
|
+
if len(active_matches) == 1:
|
|
768
|
+
return self._with_current_account_metadata(state=active_matches[0])
|
|
769
|
+
|
|
770
|
+
if len(active_matches) > 1:
|
|
771
|
+
raise DraftPoolError(
|
|
772
|
+
f"Multiple active drafts match {event_name!r} for account {account_id!r}."
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
if len(matches) == 1:
|
|
776
|
+
return self._with_current_account_metadata(state=matches[0])
|
|
777
|
+
|
|
778
|
+
if len(matches) > 1:
|
|
779
|
+
raise DraftPoolError(
|
|
780
|
+
f"Multiple completed drafts match {event_name!r} for account {account_id!r}."
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
return None
|
|
784
|
+
|
|
785
|
+
def _load_existing_state(self, *, account_id: str, draft_id: str) -> DraftState | None:
|
|
786
|
+
path = draft_state_path(
|
|
787
|
+
account_id=account_id,
|
|
788
|
+
draft_id=draft_id,
|
|
789
|
+
app_dir=self.app_dir,
|
|
790
|
+
)
|
|
791
|
+
if not path.exists():
|
|
792
|
+
return None
|
|
793
|
+
|
|
794
|
+
state = _load_state_file(path=path)
|
|
795
|
+
return self._with_current_account_metadata(state=state)
|
|
796
|
+
|
|
797
|
+
def _account_screen_name(self, *, account_id: str) -> str | None:
|
|
798
|
+
screen_name = self._account_screen_names.get(account_id)
|
|
799
|
+
if screen_name is not None:
|
|
800
|
+
return screen_name
|
|
801
|
+
|
|
802
|
+
profile = _load_account_profile(account_id=account_id, app_dir=self.app_dir)
|
|
803
|
+
if profile is None:
|
|
804
|
+
return None
|
|
805
|
+
|
|
806
|
+
self._account_screen_names[account_id] = profile.screen_name
|
|
807
|
+
return profile.screen_name
|
|
808
|
+
|
|
809
|
+
def _with_current_account_metadata(self, *, state: DraftState) -> DraftState:
|
|
810
|
+
account_screen_name = self._account_screen_name(account_id=state.account_id)
|
|
811
|
+
if (
|
|
812
|
+
account_screen_name is None
|
|
813
|
+
or state.account_screen_name == account_screen_name
|
|
814
|
+
):
|
|
815
|
+
return state
|
|
816
|
+
|
|
817
|
+
updated = replace(state, account_screen_name=account_screen_name)
|
|
818
|
+
save_draft_state(state=updated, app_dir=self.app_dir)
|
|
819
|
+
return updated
|
|
820
|
+
|
|
821
|
+
def _remember_draft(self, *, account_id: str, event_name: str, draft_id: str) -> None:
|
|
822
|
+
self._draft_ids_by_event[(account_id, event_name)] = draft_id
|
|
823
|
+
|
|
824
|
+
def _account_id_for_started_event(self, *, event: DraftStartedEvent) -> str:
|
|
825
|
+
if event.account_id is not None or self._active_account_id is not None:
|
|
826
|
+
return self._account_id_for_event(account_id=event.account_id)
|
|
827
|
+
|
|
828
|
+
inferred_account_id = self._infer_account_id_from_course_id(
|
|
829
|
+
course_id=event.course_id,
|
|
830
|
+
)
|
|
831
|
+
if inferred_account_id is None:
|
|
832
|
+
raise DraftPoolError("Draft event is missing an MTGA account id.")
|
|
833
|
+
|
|
834
|
+
self._active_account_id = inferred_account_id
|
|
835
|
+
return inferred_account_id
|
|
836
|
+
|
|
837
|
+
def _infer_account_id_from_course_id(self, *, course_id: str) -> str | None:
|
|
838
|
+
if not self.root.exists():
|
|
839
|
+
return None
|
|
840
|
+
|
|
841
|
+
matches = [
|
|
842
|
+
state
|
|
843
|
+
for account_dir in sorted(self.root.iterdir())
|
|
844
|
+
if account_dir.is_dir()
|
|
845
|
+
for state in _load_matching_states(account_dir=account_dir)
|
|
846
|
+
if state.course_id == course_id or state.draft_id == course_id
|
|
847
|
+
]
|
|
848
|
+
if len(matches) == 1:
|
|
849
|
+
return matches[0].account_id
|
|
850
|
+
|
|
851
|
+
if len(matches) > 1:
|
|
852
|
+
raise DraftPoolError(f"Multiple account states match course {course_id!r}.")
|
|
853
|
+
|
|
854
|
+
return None
|
|
855
|
+
|
|
856
|
+
def _account_id_for_draft_event(
|
|
857
|
+
self,
|
|
858
|
+
*,
|
|
859
|
+
account_id: str | None,
|
|
860
|
+
event_name: str,
|
|
861
|
+
set_code: str,
|
|
862
|
+
) -> str:
|
|
863
|
+
if account_id is not None or self._active_account_id is not None:
|
|
864
|
+
return self._account_id_for_event(account_id=account_id)
|
|
865
|
+
|
|
866
|
+
inferred_account_id = self._infer_account_id_from_state(
|
|
867
|
+
event_name=event_name,
|
|
868
|
+
set_code=set_code,
|
|
869
|
+
)
|
|
870
|
+
if inferred_account_id is None:
|
|
871
|
+
raise DraftPoolError("Draft event is missing an MTGA account id.")
|
|
872
|
+
|
|
873
|
+
self._active_account_id = inferred_account_id
|
|
874
|
+
return inferred_account_id
|
|
875
|
+
|
|
876
|
+
def _infer_account_id_from_state(
|
|
877
|
+
self,
|
|
878
|
+
*,
|
|
879
|
+
event_name: str,
|
|
880
|
+
set_code: str,
|
|
881
|
+
) -> str | None:
|
|
882
|
+
if not self.root.exists():
|
|
883
|
+
return None
|
|
884
|
+
|
|
885
|
+
matches = [
|
|
886
|
+
state
|
|
887
|
+
for account_dir in sorted(self.root.iterdir())
|
|
888
|
+
if account_dir.is_dir()
|
|
889
|
+
for state in _load_matching_states(account_dir=account_dir)
|
|
890
|
+
if state.event_name == event_name and state.set_code == set_code
|
|
891
|
+
]
|
|
892
|
+
active_matches = [state for state in matches if not state.completed]
|
|
893
|
+
if len(active_matches) == 1:
|
|
894
|
+
return active_matches[0].account_id
|
|
895
|
+
|
|
896
|
+
if len(active_matches) > 1:
|
|
897
|
+
raise DraftPoolError(f"Multiple active account states match {event_name!r}.")
|
|
898
|
+
|
|
899
|
+
if len(matches) == 1:
|
|
900
|
+
return matches[0].account_id
|
|
901
|
+
|
|
902
|
+
if len(matches) > 1:
|
|
903
|
+
raise DraftPoolError(f"Multiple account states match {event_name!r}.")
|
|
904
|
+
|
|
905
|
+
return None
|
|
906
|
+
|
|
907
|
+
def _account_id_for_event(self, *, account_id: str | None) -> str:
|
|
908
|
+
if account_id is not None:
|
|
909
|
+
self._active_account_id = account_id
|
|
910
|
+
return account_id
|
|
911
|
+
|
|
912
|
+
if self._active_account_id is None:
|
|
913
|
+
raise DraftPoolError("Draft event is missing an MTGA account id.")
|
|
914
|
+
|
|
915
|
+
return self._active_account_id
|
|
916
|
+
|
|
917
|
+
def _now_iso(self) -> str:
|
|
918
|
+
return self._clock().astimezone(UTC).isoformat()
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _load_state_file(*, path: Path) -> DraftState:
|
|
922
|
+
if not path.exists():
|
|
923
|
+
raise DraftPoolError(f"Draft state does not exist at {path}.")
|
|
924
|
+
|
|
925
|
+
try:
|
|
926
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
927
|
+
except json.JSONDecodeError as error:
|
|
928
|
+
raise DraftPoolError(f"Malformed draft state {path}: {error}.") from error
|
|
929
|
+
|
|
930
|
+
if not isinstance(data, dict):
|
|
931
|
+
raise DraftPoolError(f"Malformed draft state {path}: expected object.")
|
|
932
|
+
|
|
933
|
+
state = DraftState.from_json(data=data)
|
|
934
|
+
expected_path = draft_state_path(
|
|
935
|
+
account_id=state.account_id,
|
|
936
|
+
draft_id=state.draft_id,
|
|
937
|
+
app_dir=path.parents[2] if len(path.parents) > 2 else None,
|
|
938
|
+
)
|
|
939
|
+
if path.name != expected_path.name:
|
|
940
|
+
raise DraftPoolError(
|
|
941
|
+
f"Draft state {path} filename does not match draft id {state.draft_id!r}."
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
return state
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _load_matching_states(*, account_dir: Path) -> tuple[DraftState, ...]:
|
|
948
|
+
states: list[DraftState] = []
|
|
949
|
+
for path in sorted(account_dir.glob("*.json")):
|
|
950
|
+
states.append(_load_state_file(path=path))
|
|
951
|
+
|
|
952
|
+
return tuple(states)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _with_account_profile(*, state: DraftState, app_dir: PathInput | None) -> DraftState:
|
|
956
|
+
profile = _load_account_profile(account_id=state.account_id, app_dir=app_dir)
|
|
957
|
+
if profile is None or state.account_screen_name == profile.screen_name:
|
|
958
|
+
return state
|
|
959
|
+
|
|
960
|
+
return replace(state, account_screen_name=profile.screen_name)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _load_account_profile(
|
|
964
|
+
*,
|
|
965
|
+
account_id: str,
|
|
966
|
+
app_dir: PathInput | None,
|
|
967
|
+
) -> AccountProfile | None:
|
|
968
|
+
path = account_profile_path(account_id=account_id, app_dir=app_dir)
|
|
969
|
+
if not path.exists():
|
|
970
|
+
return None
|
|
971
|
+
|
|
972
|
+
try:
|
|
973
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
974
|
+
except json.JSONDecodeError as error:
|
|
975
|
+
raise DraftPoolError(f"Malformed account profile {path}: {error}.") from error
|
|
976
|
+
|
|
977
|
+
if not isinstance(data, dict):
|
|
978
|
+
raise DraftPoolError(f"Malformed account profile {path}: expected object.")
|
|
979
|
+
|
|
980
|
+
profile = AccountProfile.from_json(data=data)
|
|
981
|
+
if profile.account_id != account_id:
|
|
982
|
+
raise DraftPoolError(
|
|
983
|
+
f"Account profile {path} does not match account {account_id!r}."
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
return profile
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _save_account_profile(
|
|
990
|
+
*,
|
|
991
|
+
account_id: str,
|
|
992
|
+
screen_name: str,
|
|
993
|
+
app_dir: PathInput | None,
|
|
994
|
+
) -> Path:
|
|
995
|
+
profile = AccountProfile(account_id=account_id, screen_name=screen_name)
|
|
996
|
+
path = account_profile_path(account_id=account_id, app_dir=app_dir)
|
|
997
|
+
existing = _load_account_profile(account_id=account_id, app_dir=app_dir)
|
|
998
|
+
if existing == profile:
|
|
999
|
+
return path
|
|
1000
|
+
|
|
1001
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1002
|
+
payload = json.dumps(profile.to_json(), indent=2, sort_keys=True)
|
|
1003
|
+
with tempfile.NamedTemporaryFile(
|
|
1004
|
+
"w",
|
|
1005
|
+
delete=False,
|
|
1006
|
+
dir=path.parent,
|
|
1007
|
+
encoding="utf-8",
|
|
1008
|
+
) as temporary_file:
|
|
1009
|
+
temporary_file.write(payload)
|
|
1010
|
+
temporary_file.write("\n")
|
|
1011
|
+
temporary_path = Path(temporary_file.name)
|
|
1012
|
+
|
|
1013
|
+
temporary_path.replace(path)
|
|
1014
|
+
return path
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _ensure_metadata(
|
|
1018
|
+
*,
|
|
1019
|
+
state: DraftState,
|
|
1020
|
+
event_name: str,
|
|
1021
|
+
set_code: str,
|
|
1022
|
+
course_id: str | None,
|
|
1023
|
+
) -> None:
|
|
1024
|
+
if state.event_name != event_name:
|
|
1025
|
+
raise DraftPoolError(
|
|
1026
|
+
f"Draft {state.draft_id!r} event name changed from "
|
|
1027
|
+
f"{state.event_name!r} to {event_name!r}."
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
if state.set_code != set_code:
|
|
1031
|
+
raise DraftPoolError(
|
|
1032
|
+
f"Draft {state.draft_id!r} set changed from {state.set_code!r} to {set_code!r}."
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
if course_id is not None and state.course_id not in {None, course_id}:
|
|
1036
|
+
raise DraftPoolError(
|
|
1037
|
+
f"Draft {state.draft_id!r} course id changed from "
|
|
1038
|
+
f"{state.course_id!r} to {course_id!r}."
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _ensure_pool_snapshot(*, state: DraftState, pool_grp_ids: tuple[int, ...]) -> None:
|
|
1043
|
+
if not _same_pool_contents(state.pool_grp_ids, pool_grp_ids):
|
|
1044
|
+
raise DraftPoolError(
|
|
1045
|
+
f"Pack for draft {state.draft_id!r} saw pool {pool_grp_ids!r}, "
|
|
1046
|
+
f"but accumulated pool is {state.pool_grp_ids!r}."
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _same_pool_contents(left: tuple[int, ...], right: tuple[int, ...]) -> bool:
|
|
1051
|
+
return Counter(left) == Counter(right)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _is_new_draft_pack_conflict(
|
|
1055
|
+
*,
|
|
1056
|
+
existing_pick: DraftPick | None,
|
|
1057
|
+
event: PackOfferedEvent,
|
|
1058
|
+
) -> bool:
|
|
1059
|
+
return (
|
|
1060
|
+
event.pack_number == 0
|
|
1061
|
+
and event.pick_number == 0
|
|
1062
|
+
and event.pool_grp_ids == ()
|
|
1063
|
+
and existing_pick is not None
|
|
1064
|
+
and existing_pick.offered_grp_ids is not None
|
|
1065
|
+
and existing_pick.offered_grp_ids != event.offered_grp_ids
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _merge_pick(
|
|
1070
|
+
*,
|
|
1071
|
+
existing_pick: DraftPick | None,
|
|
1072
|
+
pack_number: int,
|
|
1073
|
+
pick_number: int,
|
|
1074
|
+
offered_grp_ids: tuple[int, ...] | None,
|
|
1075
|
+
pool_before_pick: tuple[int, ...] | None,
|
|
1076
|
+
chosen_grp_id: int | None,
|
|
1077
|
+
) -> DraftPick:
|
|
1078
|
+
pick = existing_pick or DraftPick(pack_number=pack_number, pick_number=pick_number)
|
|
1079
|
+
_ensure_pick_coordinate(
|
|
1080
|
+
pick=pick,
|
|
1081
|
+
pack_number=pack_number,
|
|
1082
|
+
pick_number=pick_number,
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
next_offered = _merge_optional_tuple(
|
|
1086
|
+
current=pick.offered_grp_ids,
|
|
1087
|
+
incoming=offered_grp_ids,
|
|
1088
|
+
field_name="offered_grp_ids",
|
|
1089
|
+
coordinate=pick.coordinate,
|
|
1090
|
+
)
|
|
1091
|
+
next_pool_before = _merge_optional_tuple(
|
|
1092
|
+
current=pick.pool_before_pick,
|
|
1093
|
+
incoming=pool_before_pick,
|
|
1094
|
+
field_name="pool_before_pick",
|
|
1095
|
+
coordinate=pick.coordinate,
|
|
1096
|
+
)
|
|
1097
|
+
next_chosen = _merge_optional_int(
|
|
1098
|
+
current=pick.chosen_grp_id,
|
|
1099
|
+
incoming=chosen_grp_id,
|
|
1100
|
+
field_name="chosen_grp_id",
|
|
1101
|
+
coordinate=pick.coordinate,
|
|
1102
|
+
)
|
|
1103
|
+
if next_chosen is not None and next_offered is not None and next_chosen not in next_offered:
|
|
1104
|
+
raise DraftPoolError(
|
|
1105
|
+
f"Pick {pick.coordinate!r} chose {next_chosen}, which was not offered."
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
return replace(
|
|
1109
|
+
pick,
|
|
1110
|
+
offered_grp_ids=next_offered,
|
|
1111
|
+
pool_before_pick=next_pool_before,
|
|
1112
|
+
chosen_grp_id=next_chosen,
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _ensure_pick_coordinate(
|
|
1117
|
+
*,
|
|
1118
|
+
pick: DraftPick,
|
|
1119
|
+
pack_number: int,
|
|
1120
|
+
pick_number: int,
|
|
1121
|
+
) -> None:
|
|
1122
|
+
if pick.pack_number != pack_number or pick.pick_number != pick_number:
|
|
1123
|
+
raise DraftPoolError(
|
|
1124
|
+
f"Pick coordinate changed from {pick.coordinate!r} "
|
|
1125
|
+
f"to {(pack_number, pick_number)!r}."
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _replace_pick(*, picks: tuple[DraftPick, ...], pick: DraftPick) -> tuple[DraftPick, ...]:
|
|
1130
|
+
by_coordinate = {existing.coordinate: existing for existing in picks}
|
|
1131
|
+
by_coordinate[pick.coordinate] = pick
|
|
1132
|
+
return _sorted_picks(picks=tuple(by_coordinate.values()))
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _sorted_picks(*, picks: tuple[DraftPick, ...]) -> tuple[DraftPick, ...]:
|
|
1136
|
+
seen: set[tuple[int, int]] = set()
|
|
1137
|
+
for pick in picks:
|
|
1138
|
+
if pick.coordinate in seen:
|
|
1139
|
+
raise DraftPoolError(f"Duplicate pick coordinate {pick.coordinate!r}.")
|
|
1140
|
+
|
|
1141
|
+
seen.add(pick.coordinate)
|
|
1142
|
+
|
|
1143
|
+
return tuple(sorted(picks, key=lambda pick: pick.coordinate))
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _merge_optional_tuple(
|
|
1147
|
+
*,
|
|
1148
|
+
current: tuple[int, ...] | None,
|
|
1149
|
+
incoming: tuple[int, ...] | None,
|
|
1150
|
+
field_name: str,
|
|
1151
|
+
coordinate: tuple[int, int],
|
|
1152
|
+
) -> tuple[int, ...] | None:
|
|
1153
|
+
if incoming is None:
|
|
1154
|
+
return current
|
|
1155
|
+
|
|
1156
|
+
if current is not None and current != incoming:
|
|
1157
|
+
raise DraftPoolError(
|
|
1158
|
+
f"Pick {coordinate!r} {field_name} changed from {current!r} to {incoming!r}."
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
return incoming
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _merge_optional_int(
|
|
1165
|
+
*,
|
|
1166
|
+
current: int | None,
|
|
1167
|
+
incoming: int | None,
|
|
1168
|
+
field_name: str,
|
|
1169
|
+
coordinate: tuple[int, int],
|
|
1170
|
+
) -> int | None:
|
|
1171
|
+
if incoming is None:
|
|
1172
|
+
return current
|
|
1173
|
+
|
|
1174
|
+
if current is not None and current != incoming:
|
|
1175
|
+
raise DraftPoolError(
|
|
1176
|
+
f"Pick {coordinate!r} {field_name} changed from {current!r} to {incoming!r}."
|
|
1177
|
+
)
|
|
1178
|
+
|
|
1179
|
+
return incoming
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _draft_pick_from_value(*, value: Any, index: int) -> DraftPick:
|
|
1183
|
+
if not isinstance(value, dict):
|
|
1184
|
+
raise DraftPoolError(f"Draft pick at index {index} is not an object.")
|
|
1185
|
+
|
|
1186
|
+
return DraftPick.from_json(data=value)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _system_clock() -> datetime:
|
|
1190
|
+
return datetime.now(tz=UTC)
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _path_segment(*, value: str, field_name: str) -> str:
|
|
1194
|
+
if value in {"", ".", ".."}:
|
|
1195
|
+
raise DraftPoolError(f"Invalid {field_name}; cannot be used as a path segment.")
|
|
1196
|
+
|
|
1197
|
+
return value.replace("/", "_").replace("\\", "_").replace(":", "_")
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _optional_int_list(value: tuple[int, ...] | None) -> list[int] | None:
|
|
1201
|
+
if value is None:
|
|
1202
|
+
return None
|
|
1203
|
+
|
|
1204
|
+
return list(value)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _required_str(value: Any, *, field_name: str) -> str:
|
|
1208
|
+
if not isinstance(value, str) or value == "":
|
|
1209
|
+
raise DraftPoolError(f"Missing or invalid {field_name}; expected non-empty string.")
|
|
1210
|
+
|
|
1211
|
+
return value
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _optional_str(value: Any, *, field_name: str) -> str | None:
|
|
1215
|
+
if value is None:
|
|
1216
|
+
return None
|
|
1217
|
+
|
|
1218
|
+
return _required_str(value, field_name=field_name)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _required_bool(value: Any, *, field_name: str) -> bool:
|
|
1222
|
+
if not isinstance(value, bool):
|
|
1223
|
+
raise DraftPoolError(f"Missing or invalid {field_name}; expected boolean.")
|
|
1224
|
+
|
|
1225
|
+
return value
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _required_int(value: Any, *, field_name: str) -> int:
|
|
1229
|
+
if isinstance(value, bool):
|
|
1230
|
+
raise DraftPoolError(f"Missing or invalid {field_name}; expected integer.")
|
|
1231
|
+
|
|
1232
|
+
try:
|
|
1233
|
+
return int(value)
|
|
1234
|
+
except (TypeError, ValueError) as error:
|
|
1235
|
+
raise DraftPoolError(f"Missing or invalid {field_name}; expected integer.") from error
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _optional_int(value: Any, *, field_name: str) -> int | None:
|
|
1239
|
+
if value is None:
|
|
1240
|
+
return None
|
|
1241
|
+
|
|
1242
|
+
return _required_int(value, field_name=field_name)
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def _int_tuple(value: Any, *, field_name: str) -> tuple[int, ...]:
|
|
1246
|
+
if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
|
|
1247
|
+
raise DraftPoolError(f"Missing or invalid {field_name}; expected integer list.")
|
|
1248
|
+
|
|
1249
|
+
return tuple(
|
|
1250
|
+
_required_int(item, field_name=f"{field_name}[]")
|
|
1251
|
+
for item in value
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def _optional_int_tuple(value: Any, *, field_name: str) -> tuple[int, ...] | None:
|
|
1256
|
+
if value is None:
|
|
1257
|
+
return None
|
|
1258
|
+
|
|
1259
|
+
return _int_tuple(value, field_name=field_name)
|