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/replay.py ADDED
@@ -0,0 +1,480 @@
1
+ """Deterministic plain-text replay rendering.
2
+ Completion events append the same build sheet used by live plain watch.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import tempfile
8
+ from collections.abc import Callable, Iterable
9
+ from dataclasses import dataclass
10
+ from os import PathLike
11
+ from pathlib import Path
12
+ from typing import TypeAlias
13
+
14
+ from draftomen.carddb import CardDatabase, CardInfo
15
+ from draftomen.deckbuilder import BuildPool, build_deck_from_pool, format_build_result
16
+ from draftomen.events import (
17
+ EXPECTED_PICKS_PER_PACK,
18
+ AccountEvent,
19
+ DraftCompletedEvent,
20
+ DraftEvent,
21
+ DraftStartedEvent,
22
+ PackOfferedEvent,
23
+ PickMadeEvent,
24
+ parse_events,
25
+ )
26
+ from draftomen.pickengine import PickEngine, ScoredCard, ScoredPack
27
+ from draftomen.pool import DraftPoolStore
28
+ from draftomen.seventeen import SEVENTEEN_LANDS_ATTRIBUTION, SeventeenLandsData
29
+
30
+ PathInput: TypeAlias = str | PathLike[str]
31
+ RatingsLoader: TypeAlias = Callable[[str], SeventeenLandsData]
32
+
33
+
34
+ class ReplayError(RuntimeError):
35
+ """Raised when a log cannot be replayed.
36
+ Callers should surface the message as a concise CLI diagnostic.
37
+ """
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class _ReplayHeader:
42
+ """Summary fields printed before replayed picks.
43
+ Missing fields are rendered explicitly so output stays deterministic.
44
+ """
45
+
46
+ account_id: str | None
47
+ screen_name: str | None
48
+ event_name: str | None
49
+ set_code: str | None
50
+ draft_id: str | None
51
+
52
+
53
+ def replay_log_file(
54
+ *,
55
+ logfile: PathInput,
56
+ card_database: CardDatabase,
57
+ ratings_data: SeventeenLandsData | None = None,
58
+ ratings_loader: RatingsLoader | None = None,
59
+ splash_enabled: bool = True,
60
+ ) -> str:
61
+ """Replay one captured Player.log file into deterministic text.
62
+ Ratings are caller-supplied or loaded once from the parsed set code.
63
+ """
64
+
65
+ path = Path(logfile)
66
+ try:
67
+ lines = path.read_text(encoding="utf-8").splitlines()
68
+ except OSError as error:
69
+ raise ReplayError(f"Could not read replay log {path}: {error}.") from error
70
+
71
+ events = tuple(parse_events(lines=lines))
72
+ return render_replay_events(
73
+ events=events,
74
+ card_database=card_database,
75
+ ratings_data=ratings_data,
76
+ ratings_loader=ratings_loader,
77
+ splash_enabled=splash_enabled,
78
+ )
79
+
80
+
81
+ def render_replay_events(
82
+ *,
83
+ events: Iterable[DraftEvent],
84
+ card_database: CardDatabase,
85
+ ratings_data: SeventeenLandsData | None = None,
86
+ ratings_loader: RatingsLoader | None = None,
87
+ splash_enabled: bool = True,
88
+ ) -> str:
89
+ """Render parsed events to stable plain-text replay output.
90
+ Pool validation is run first so conflicting streams fail before printing.
91
+ """
92
+
93
+ event_tuple = tuple(events)
94
+ if not event_tuple:
95
+ raise ReplayError("No Quick Draft events found in log file.")
96
+
97
+ _validate_events_with_pool(events=event_tuple)
98
+
99
+ header = _header_from_events(events=event_tuple)
100
+ loaded_ratings = _ratings_data_for_replay(
101
+ header=header,
102
+ ratings_data=ratings_data,
103
+ ratings_loader=ratings_loader,
104
+ )
105
+ pick_engine = PickEngine(
106
+ ratings_data=loaded_ratings,
107
+ splash_enabled=splash_enabled,
108
+ )
109
+ lines = _format_header(header=header)
110
+ lines.append("")
111
+
112
+ for event in event_tuple:
113
+ if isinstance(event, PackOfferedEvent):
114
+ lines.extend(
115
+ format_pack_offered_event(
116
+ event=event,
117
+ card_database=card_database,
118
+ pick_engine=pick_engine,
119
+ )
120
+ )
121
+ elif isinstance(event, PickMadeEvent):
122
+ lines.extend(
123
+ format_pick_made_event(
124
+ event=event,
125
+ card_database=card_database,
126
+ )
127
+ )
128
+ lines.append("")
129
+ elif isinstance(event, DraftCompletedEvent):
130
+ lines.extend(format_draft_completed_event(event=event))
131
+ lines.append("")
132
+ lines.extend(
133
+ _format_completed_build_sheet(
134
+ event=event,
135
+ header=header,
136
+ card_database=card_database,
137
+ ratings_data=loaded_ratings,
138
+ splash_enabled=splash_enabled,
139
+ )
140
+ )
141
+
142
+ return "\n".join(lines).rstrip() + "\n"
143
+
144
+
145
+ def _validate_events_with_pool(*, events: tuple[DraftEvent, ...]) -> None:
146
+ with tempfile.TemporaryDirectory(prefix="draftomen-replay-") as temporary_dir:
147
+ store = DraftPoolStore(app_dir=temporary_dir)
148
+ store.consume_all(events=events)
149
+
150
+
151
+ def _ratings_data_for_replay(
152
+ *,
153
+ header: _ReplayHeader,
154
+ ratings_data: SeventeenLandsData | None,
155
+ ratings_loader: RatingsLoader | None,
156
+ ) -> SeventeenLandsData | None:
157
+ if ratings_data is not None or ratings_loader is None:
158
+ return ratings_data
159
+
160
+ if header.set_code is None:
161
+ return None
162
+
163
+ return ratings_loader(header.set_code)
164
+
165
+
166
+ def _format_completed_build_sheet(
167
+ *,
168
+ event: DraftCompletedEvent,
169
+ header: _ReplayHeader,
170
+ card_database: CardDatabase,
171
+ ratings_data: SeventeenLandsData | None,
172
+ splash_enabled: bool,
173
+ ) -> list[str]:
174
+ pool = BuildPool(
175
+ set_code=event.set_code,
176
+ pool_grp_ids=event.picked_grp_ids,
177
+ source_label=f"replay {header.draft_id or event.event_name}",
178
+ account_id=header.account_id,
179
+ draft_id=header.draft_id,
180
+ )
181
+ selection, build_sheet = build_deck_from_pool(
182
+ pool=pool,
183
+ card_database=card_database,
184
+ ratings_data=ratings_data,
185
+ allow_splash=splash_enabled,
186
+ )
187
+ return format_build_result(
188
+ pool=pool,
189
+ selection=selection,
190
+ spell_selection=build_sheet.spell_selection,
191
+ mana_base=build_sheet.mana_base,
192
+ ).rstrip("\n").splitlines()
193
+
194
+
195
+ def _header_from_events(*, events: tuple[DraftEvent, ...]) -> _ReplayHeader:
196
+ account_names: dict[str, str | None] = {}
197
+ active_account_id: str | None = None
198
+ event_name: str | None = None
199
+ set_code: str | None = None
200
+ draft_id: str | None = None
201
+
202
+ for event in events:
203
+ if isinstance(event, AccountEvent):
204
+ active_account_id = event.client_id
205
+ account_names[event.client_id] = event.screen_name
206
+ continue
207
+
208
+ if isinstance(event, DraftStartedEvent):
209
+ active_account_id = event.account_id or active_account_id
210
+ event_name = event.event_name
211
+ set_code = event.set_code
212
+ draft_id = event.course_id
213
+ break
214
+
215
+ if isinstance(event, (PackOfferedEvent, PickMadeEvent, DraftCompletedEvent)):
216
+ active_account_id = event.account_id or active_account_id
217
+ event_name = event.event_name
218
+ set_code = event.set_code
219
+ draft_id = event.event_name
220
+ break
221
+
222
+ screen_name = None
223
+ if active_account_id is not None:
224
+ screen_name = account_names.get(active_account_id)
225
+
226
+ return _ReplayHeader(
227
+ account_id=active_account_id,
228
+ screen_name=screen_name,
229
+ event_name=event_name,
230
+ set_code=set_code,
231
+ draft_id=draft_id,
232
+ )
233
+
234
+
235
+ def format_pack_offered_event(
236
+ *,
237
+ event: PackOfferedEvent,
238
+ card_database: CardDatabase,
239
+ pick_engine: PickEngine | None = None,
240
+ scored_pack: ScoredPack | None = None,
241
+ ) -> list[str]:
242
+ """Format a pack offer with the same plain text replay uses.
243
+ Live watch mode calls this so pack rendering stays byte-compatible.
244
+ """
245
+
246
+ return _format_pack(
247
+ event=event,
248
+ card_database=card_database,
249
+ pick_engine=pick_engine,
250
+ scored_pack=scored_pack,
251
+ )
252
+
253
+
254
+ def format_pick_made_event(
255
+ *,
256
+ event: PickMadeEvent,
257
+ card_database: CardDatabase,
258
+ ) -> list[str]:
259
+ """Format a chosen-card event with replay-compatible text.
260
+ The caller decides whether to add a separating blank line.
261
+ """
262
+
263
+ return [
264
+ "Chosen card: "
265
+ f"{format_card_info(card_database.lookup(grp_id=event.chosen_grp_id))}"
266
+ ]
267
+
268
+
269
+ def format_draft_completed_event(*, event: DraftCompletedEvent) -> list[str]:
270
+ """Format draft completion with replay-compatible text.
271
+ Completion type records whether Arena emitted an explicit status.
272
+ """
273
+
274
+ completion_type = "inferred" if event.inferred else "explicit"
275
+ return [
276
+ "Draft complete: "
277
+ f"{len(event.picked_grp_ids)} cards ({completion_type} completion)"
278
+ ]
279
+
280
+
281
+ def format_card_info(card: CardInfo) -> str:
282
+ """Format one card for plain CLI output.
283
+ Unknown cards are displayed explicitly instead of failing lookups.
284
+ """
285
+
286
+ return _format_card(card)
287
+
288
+
289
+ def _format_header(*, header: _ReplayHeader) -> list[str]:
290
+ return [
291
+ "Draft Omen replay",
292
+ f"Account: {_format_account(header=header)}",
293
+ f"Set: {header.set_code or 'unknown'}",
294
+ f"Event: {header.event_name or 'unknown'}",
295
+ f"Draft: {header.draft_id or 'unknown'}",
296
+ f"Attribution: {SEVENTEEN_LANDS_ATTRIBUTION}",
297
+ ]
298
+
299
+
300
+ def _format_account(*, header: _ReplayHeader) -> str:
301
+ if header.account_id is None:
302
+ return "unknown"
303
+
304
+ if header.screen_name is None:
305
+ return header.account_id
306
+
307
+ return f"{header.screen_name} ({header.account_id})"
308
+
309
+
310
+ def _format_pack(
311
+ *,
312
+ event: PackOfferedEvent,
313
+ card_database: CardDatabase,
314
+ pick_engine: PickEngine | None,
315
+ scored_pack: ScoredPack | None = None,
316
+ ) -> list[str]:
317
+ if scored_pack is None:
318
+ engine = pick_engine if pick_engine is not None else PickEngine()
319
+ scored_pack = engine.score_pack(
320
+ offered_grp_ids=event.offered_grp_ids,
321
+ card_database=card_database,
322
+ pool_grp_ids=event.pool_grp_ids,
323
+ pick_index=_draft_pick_index(event=event),
324
+ )
325
+ lines = [
326
+ f"Pack {event.pack_number + 1} Pick {event.pick_number + 1}",
327
+ _format_pack_status(scored_pack=scored_pack),
328
+ f"Data source: {scored_pack.source_summary}",
329
+ ]
330
+ unresolved_count = len(
331
+ card_database.unresolved_grp_ids(
332
+ grp_ids=(*event.offered_grp_ids, *event.pool_grp_ids),
333
+ )
334
+ )
335
+ if unresolved_count > 0:
336
+ lines.append(f"Warning: {unresolved_count} unresolved card metadata")
337
+
338
+ lines.append("Offered cards:")
339
+ lines.extend(_format_scored_cards(cards=scored_pack.cards))
340
+ if any(card.no_data for card in scored_pack.cards):
341
+ lines.append(" * Prior uses neutral prior adjusted by ALSA when available.")
342
+
343
+ return lines
344
+
345
+
346
+ def _draft_pick_index(*, event: PackOfferedEvent) -> int:
347
+ return (event.pack_number * EXPECTED_PICKS_PER_PACK) + event.pick_number + 1
348
+
349
+
350
+ def _format_pack_status(*, scored_pack: ScoredPack) -> str:
351
+ commitment = scored_pack.commitment
352
+ pair = commitment.inferred_pair if commitment.inferred_pair is not None else "open"
353
+ percent = int(round(commitment.level * 100))
354
+ status = (
355
+ "Status: "
356
+ f"inferred pair {pair}, "
357
+ f"commitment {percent}% ({commitment.phase}), "
358
+ f"pool {commitment.pool_size}"
359
+ )
360
+ splash_status = _format_splash_status(scored_pack=scored_pack)
361
+ if splash_status is None:
362
+ return status
363
+
364
+ return f"{status}, {splash_status}"
365
+
366
+
367
+ def _format_splash_status(*, scored_pack: ScoredPack) -> str | None:
368
+ state = scored_pack.splash_state
369
+ if not state.enabled:
370
+ return "splash disabled"
371
+ if state.active_color is None:
372
+ return None
373
+
374
+ fixing_sources = state.fixing_for(color=state.active_color)
375
+ return (
376
+ f"splash {state.active_color} "
377
+ f"{state.picked_card_count}/2 cards, "
378
+ f"{fixing_sources} drafted fixing"
379
+ )
380
+
381
+
382
+ def _format_scored_cards(*, cards: tuple[ScoredCard, ...]) -> list[str]:
383
+ if not cards:
384
+ return []
385
+
386
+ card_width = max(len(_format_scored_card_name(card)) for card in cards)
387
+ fit_width = (
388
+ 9
389
+ if any(card.color_fit.startswith("splash-") for card in cards)
390
+ else 5
391
+ )
392
+ lines = [
393
+ " # Score "
394
+ f"{'Card':<{card_width}} "
395
+ f"Colors {'Fit':<{fit_width}} GIH WR ALSA MV Source"
396
+ ]
397
+ for rank, scored_card in enumerate(cards, start=1):
398
+ lines.append(
399
+ " "
400
+ f"{rank:02d} "
401
+ f"{scored_card.score:>5} "
402
+ f"{_format_scored_card_name(scored_card):<{card_width}} "
403
+ f"{_format_card_colors(scored_card.card):<9} "
404
+ f"{_format_color_fit(scored_card):<{fit_width}} "
405
+ f"{_format_win_rate(scored_card):>6} "
406
+ f"{_format_alsa(scored_card):>5} "
407
+ f"{_format_mana_value(scored_card.card):>4} "
408
+ f"{scored_card.source_label}"
409
+ )
410
+
411
+ return lines
412
+
413
+
414
+ def _format_scored_card_name(card: ScoredCard) -> str:
415
+ return f"{card.card.name} (grpId {card.card.grp_id})"
416
+
417
+
418
+ def _format_win_rate(card: ScoredCard) -> str:
419
+ if card.rating.gih_win_rate is None:
420
+ return "—"
421
+
422
+ return f"{card.rating.gih_win_rate:.1%}"
423
+
424
+
425
+ def _format_alsa(card: ScoredCard) -> str:
426
+ if card.rating.average_last_seen_at is None:
427
+ return "—"
428
+
429
+ return f"{card.rating.average_last_seen_at:.2f}"
430
+
431
+
432
+ def _format_color_fit(card: ScoredCard) -> str:
433
+ if card.color_fit == "on-color":
434
+ return "On"
435
+
436
+ if card.color_fit == "off-color":
437
+ return "Off!"
438
+
439
+ if card.color_fit == "colorless":
440
+ return "Any"
441
+
442
+ if card.color_fit == "unknown":
443
+ return "?"
444
+
445
+ if card.color_fit == "splash-ready":
446
+ return f"Splash {card.splash.splash_color}"
447
+
448
+ if card.color_fit == "splash-speculative":
449
+ return f"Splash?{card.splash.splash_color}"
450
+
451
+ if card.color_fit == "splash-fixer":
452
+ return f"Fix {card.splash.splash_color}"
453
+
454
+ return "Open"
455
+
456
+
457
+ def _format_mana_value(card: CardInfo) -> str:
458
+ if card.mana_value is None:
459
+ return "—"
460
+
461
+ if card.mana_value.is_integer():
462
+ return str(int(card.mana_value))
463
+
464
+ return f"{card.mana_value:.1f}"
465
+
466
+
467
+ def _format_card_colors(card: CardInfo) -> str:
468
+ if card.unknown:
469
+ return "Unknown"
470
+
471
+ return "".join(card.colors) if card.colors else "Colorless"
472
+
473
+
474
+ def _format_card(card: CardInfo) -> str:
475
+ if card.unknown:
476
+ colors = "Unknown"
477
+ else:
478
+ colors = "".join(card.colors) if card.colors else "Colorless"
479
+
480
+ return f"{card.name} [{colors}] (grpId {card.grp_id})"