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/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)