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/splash.py ADDED
@@ -0,0 +1,617 @@
1
+ """Shared splash eligibility and mana-support assessment.
2
+ Keep third-color decisions separate from primary-pair commitment.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from dataclasses import dataclass
9
+
10
+ from draftomen.carddb import CardDatabase, CardInfo
11
+ from draftomen.config import SPLASH, SplashConfig
12
+ from draftomen.seventeen import GRADE_LABELS, SeventeenLandsData
13
+
14
+ COLOR_ORDER = ("W", "U", "B", "R", "G")
15
+ MANA_SYMBOL_PATTERN = re.compile(r"\{([^}]+)\}")
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class SplashState:
20
+ """Current one-color splash plan inferred from the drafted pool.
21
+ Fixing counts include deterministic fixing lands usable by the base pair.
22
+ """
23
+
24
+ enabled: bool
25
+ base_pair: str | None
26
+ active_color: str | None
27
+ picked_card_count: int
28
+ fixing_sources: tuple[tuple[str, int], ...]
29
+ aggressive: bool
30
+
31
+ def fixing_for(self, *, color: str) -> int:
32
+ return dict(self.fixing_sources).get(color, 0)
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class SplashAssessment:
37
+ """Explain whether and why one offered card participates in a splash.
38
+ Every field is serializable so live audit records preserve the decision.
39
+ """
40
+
41
+ classification: str
42
+ splash_color: str | None
43
+ off_color_pips: int
44
+ picked_card_count: int
45
+ fixing_sources: int
46
+ planned_basic_sources: int
47
+ available_sources: int
48
+ required_sources: int
49
+ grade: str | None
50
+ score_advantage: float | None
51
+ aggressive: bool
52
+ reasons: tuple[str, ...]
53
+
54
+ @property
55
+ def is_splash_candidate(self) -> bool:
56
+ return self.classification in {"splash-ready", "splash-speculative"}
57
+
58
+
59
+ def infer_splash_state(
60
+ *,
61
+ pool_grp_ids: tuple[int, ...],
62
+ card_database: CardDatabase,
63
+ ratings_data: SeventeenLandsData | None,
64
+ base_pair: str | None,
65
+ enabled: bool,
66
+ config: SplashConfig = SPLASH,
67
+ ) -> SplashState:
68
+ """Infer the active splash color and deterministic fixing from the pool.
69
+ An isolated elite third-color card establishes the plan after pair commitment.
70
+ """
71
+
72
+ fixing_sources = _fixing_counts(
73
+ pool_grp_ids=pool_grp_ids,
74
+ card_database=card_database,
75
+ base_pair=base_pair,
76
+ )
77
+ if not enabled or base_pair is None:
78
+ return SplashState(
79
+ enabled=enabled,
80
+ base_pair=base_pair,
81
+ active_color=None,
82
+ picked_card_count=0,
83
+ fixing_sources=tuple(fixing_sources.items()),
84
+ aggressive=False,
85
+ )
86
+
87
+ eligible_by_color: dict[str, list[tuple[float, int]]] = {
88
+ color: [] for color in COLOR_ORDER if color not in base_pair
89
+ }
90
+ for index, grp_id in enumerate(pool_grp_ids):
91
+ card = card_database.lookup(grp_id=grp_id)
92
+ splash_color, off_color_pips = splash_requirement(card=card, base_pair=base_pair)
93
+ if splash_color is None or off_color_pips > config.maximum_off_color_pips:
94
+ continue
95
+
96
+ grade = _global_grade(ratings_data=ratings_data, grp_id=grp_id)
97
+ if not grade_at_least(grade=grade, minimum=config.supported_minimum_grade):
98
+ continue
99
+
100
+ win_rate = _global_win_rate(ratings_data=ratings_data, grp_id=grp_id)
101
+ eligible_by_color[splash_color].append(
102
+ (0.0 if win_rate is None else win_rate, -index)
103
+ )
104
+
105
+ active_color = _active_splash_color(
106
+ eligible_by_color=eligible_by_color,
107
+ fixing_sources=fixing_sources,
108
+ config=config,
109
+ )
110
+ picked_card_count = (
111
+ 0 if active_color is None else len(eligible_by_color[active_color])
112
+ )
113
+ return SplashState(
114
+ enabled=True,
115
+ base_pair=base_pair,
116
+ active_color=active_color,
117
+ picked_card_count=picked_card_count,
118
+ fixing_sources=tuple(fixing_sources.items()),
119
+ aggressive=_pool_is_aggressive(
120
+ pool_grp_ids=pool_grp_ids,
121
+ card_database=card_database,
122
+ base_pair=base_pair,
123
+ config=config,
124
+ ),
125
+ )
126
+
127
+
128
+ def assess_splash_card(
129
+ *,
130
+ card: CardInfo,
131
+ grade: str | None,
132
+ base_score: float,
133
+ best_on_color_score: float | None,
134
+ locked: bool,
135
+ state: SplashState,
136
+ config: SplashConfig = SPLASH,
137
+ ) -> SplashAssessment:
138
+ """Classify one card as ready, speculative, fixing, or ordinary off-color.
139
+ Hard eligibility gates run before the smaller scoring multipliers.
140
+ """
141
+
142
+ if card.unknown:
143
+ return _assessment(
144
+ classification="unknown",
145
+ state=state,
146
+ grade=grade,
147
+ reasons=("card metadata is unavailable",),
148
+ )
149
+
150
+ base_pair = state.base_pair
151
+ if base_pair is None:
152
+ return _assessment(
153
+ classification="colorless" if not card.colors else "open",
154
+ state=state,
155
+ grade=grade,
156
+ reasons=(
157
+ ("card is colorless",)
158
+ if not card.colors
159
+ else ("primary colors are still open",)
160
+ ),
161
+ )
162
+
163
+ if _is_splash_fixer(card=card, state=state, config=config):
164
+ active_color = state.active_color
165
+ fixing_sources = (
166
+ 0
167
+ if active_color is None
168
+ else state.fixing_for(color=active_color) + 1
169
+ )
170
+ required_sources = _required_sources(
171
+ splash_card_count=max(1, state.picked_card_count),
172
+ config=config,
173
+ )
174
+ return _assessment(
175
+ classification="splash-fixer",
176
+ state=state,
177
+ splash_color=active_color,
178
+ grade=grade,
179
+ fixing_sources=fixing_sources,
180
+ planned_basic_sources=min(
181
+ config.planned_basic_sources,
182
+ required_sources,
183
+ ),
184
+ required_sources=required_sources,
185
+ reasons=("produces the active splash color",),
186
+ )
187
+
188
+ if not card.colors:
189
+ return _assessment(
190
+ classification="colorless",
191
+ state=state,
192
+ grade=grade,
193
+ reasons=("card is colorless",),
194
+ )
195
+
196
+ if card_is_castable_in_pair(card=card, base_pair=base_pair):
197
+ return _assessment(
198
+ classification="on-color",
199
+ state=state,
200
+ grade=grade,
201
+ reasons=("the card is castable with the primary pair",),
202
+ )
203
+
204
+ splash_color, off_color_pips = splash_requirement(
205
+ card=card,
206
+ base_pair=base_pair,
207
+ )
208
+ if not state.enabled:
209
+ return _assessment(
210
+ classification="off-color",
211
+ state=state,
212
+ splash_color=splash_color,
213
+ off_color_pips=off_color_pips,
214
+ grade=grade,
215
+ reasons=("splashing is disabled",),
216
+ )
217
+
218
+ if splash_color is None:
219
+ return _assessment(
220
+ classification="off-color",
221
+ state=state,
222
+ off_color_pips=off_color_pips,
223
+ grade=grade,
224
+ reasons=("card requires more than one color outside the primary pair",),
225
+ )
226
+
227
+ if off_color_pips > config.maximum_off_color_pips:
228
+ return _assessment(
229
+ classification="off-color",
230
+ state=state,
231
+ splash_color=splash_color,
232
+ off_color_pips=off_color_pips,
233
+ grade=grade,
234
+ reasons=("card has too many off-color mana pips",),
235
+ )
236
+
237
+ if state.active_color is not None and splash_color != state.active_color:
238
+ return _assessment(
239
+ classification="off-color",
240
+ state=state,
241
+ splash_color=splash_color,
242
+ off_color_pips=off_color_pips,
243
+ grade=grade,
244
+ reasons=(f"the active splash color is {state.active_color}",),
245
+ )
246
+
247
+ if state.picked_card_count >= config.maximum_cards:
248
+ return _assessment(
249
+ classification="off-color",
250
+ state=state,
251
+ splash_color=splash_color,
252
+ off_color_pips=off_color_pips,
253
+ grade=grade,
254
+ reasons=("the splash-card limit is already reached",),
255
+ )
256
+
257
+ score_advantage = (
258
+ None
259
+ if best_on_color_score is None
260
+ else base_score - best_on_color_score
261
+ )
262
+ if (
263
+ score_advantage is not None
264
+ and score_advantage < config.minimum_score_advantage
265
+ ):
266
+ return _assessment(
267
+ classification="off-color",
268
+ state=state,
269
+ splash_color=splash_color,
270
+ off_color_pips=off_color_pips,
271
+ grade=grade,
272
+ score_advantage=score_advantage,
273
+ reasons=("card is not enough better than the best on-color option",),
274
+ )
275
+
276
+ prospective_count = state.picked_card_count + 1
277
+ required_sources = _required_sources(
278
+ splash_card_count=prospective_count,
279
+ config=config,
280
+ )
281
+ fixing_sources = state.fixing_for(color=splash_color)
282
+ planned_basics = min(config.planned_basic_sources, required_sources)
283
+ available_sources = fixing_sources + planned_basics
284
+ supported_grade = config.supported_minimum_grade
285
+ if state.aggressive:
286
+ supported_grade = config.speculative_minimum_grade
287
+
288
+ if (
289
+ available_sources >= required_sources
290
+ and grade_at_least(grade=grade, minimum=supported_grade)
291
+ ):
292
+ return _assessment(
293
+ classification="splash-ready",
294
+ state=state,
295
+ splash_color=splash_color,
296
+ off_color_pips=off_color_pips,
297
+ grade=grade,
298
+ score_advantage=score_advantage,
299
+ fixing_sources=fixing_sources,
300
+ planned_basic_sources=planned_basics,
301
+ required_sources=required_sources,
302
+ reasons=("elite single-pip card has sufficient mana support",),
303
+ )
304
+
305
+ if (
306
+ not locked
307
+ and not state.aggressive
308
+ and grade_at_least(
309
+ grade=grade,
310
+ minimum=config.speculative_minimum_grade,
311
+ )
312
+ ):
313
+ return _assessment(
314
+ classification="splash-speculative",
315
+ state=state,
316
+ splash_color=splash_color,
317
+ off_color_pips=off_color_pips,
318
+ grade=grade,
319
+ score_advantage=score_advantage,
320
+ fixing_sources=fixing_sources,
321
+ planned_basic_sources=planned_basics,
322
+ required_sources=required_sources,
323
+ reasons=("exceptional card can be supported by future fixing",),
324
+ )
325
+
326
+ reasons: list[str] = []
327
+ if not grade_at_least(grade=grade, minimum=supported_grade):
328
+ reasons.append(f"grade is below {supported_grade}")
329
+ if available_sources < required_sources:
330
+ reasons.append(
331
+ f"needs {required_sources - available_sources} more deterministic sources"
332
+ )
333
+ if locked:
334
+ reasons.append("speculative splashes are disabled after color lock")
335
+ if state.aggressive:
336
+ reasons.append("aggressive pools require supported exceptional splashes")
337
+ return _assessment(
338
+ classification="off-color",
339
+ state=state,
340
+ splash_color=splash_color,
341
+ off_color_pips=off_color_pips,
342
+ grade=grade,
343
+ score_advantage=score_advantage,
344
+ fixing_sources=fixing_sources,
345
+ planned_basic_sources=planned_basics,
346
+ required_sources=required_sources,
347
+ reasons=tuple(reasons) or ("splash requirements are not met",),
348
+ )
349
+
350
+
351
+ def splash_requirement(*, card: CardInfo, base_pair: str) -> tuple[str | None, int]:
352
+ """Return one required off-pair color and its pip count when unambiguous.
353
+ Hybrid symbols castable using a base color do not create a splash requirement.
354
+ """
355
+
356
+ outside_colors = tuple(
357
+ color for color in COLOR_ORDER if color in card.colors and color not in base_pair
358
+ )
359
+ if len(outside_colors) != 1:
360
+ return (None, len(outside_colors))
361
+
362
+ splash_color = outside_colors[0]
363
+ if not card.mana_cost:
364
+ return (splash_color, 1)
365
+
366
+ pip_count = 0
367
+ castable_hybrid = False
368
+ for symbol in MANA_SYMBOL_PATTERN.findall(card.mana_cost):
369
+ symbol_colors = tuple(color for color in COLOR_ORDER if color in symbol)
370
+ if not symbol_colors:
371
+ continue
372
+ if any(color in base_pair for color in symbol_colors):
373
+ if splash_color in symbol_colors:
374
+ castable_hybrid = True
375
+ continue
376
+ if splash_color in symbol_colors:
377
+ pip_count += 1
378
+
379
+ if pip_count == 0 and castable_hybrid:
380
+ return (None, 0)
381
+
382
+ return (splash_color, pip_count or 1)
383
+
384
+
385
+ def card_is_castable_in_pair(*, card: CardInfo, base_pair: str) -> bool:
386
+ """Return whether a card can be cast using only the primary colors.
387
+ Hybrid symbols satisfied by a primary color count as on-color.
388
+ """
389
+
390
+ if not card.colors or all(color in base_pair for color in card.colors):
391
+ return True
392
+
393
+ splash_color, off_color_pips = splash_requirement(
394
+ card=card,
395
+ base_pair=base_pair,
396
+ )
397
+ return splash_color is None and off_color_pips == 0
398
+
399
+
400
+ def grade_at_least(*, grade: str | None, minimum: str) -> bool:
401
+ if grade not in GRADE_LABELS or minimum not in GRADE_LABELS:
402
+ return False
403
+
404
+ return GRADE_LABELS.index(grade) >= GRADE_LABELS.index(minimum)
405
+
406
+
407
+ def _assessment(
408
+ *,
409
+ classification: str,
410
+ state: SplashState,
411
+ grade: str | None,
412
+ reasons: tuple[str, ...],
413
+ splash_color: str | None = None,
414
+ off_color_pips: int = 0,
415
+ score_advantage: float | None = None,
416
+ fixing_sources: int = 0,
417
+ planned_basic_sources: int = 0,
418
+ required_sources: int = 0,
419
+ ) -> SplashAssessment:
420
+ return SplashAssessment(
421
+ classification=classification,
422
+ splash_color=splash_color,
423
+ off_color_pips=off_color_pips,
424
+ picked_card_count=state.picked_card_count,
425
+ fixing_sources=fixing_sources,
426
+ planned_basic_sources=planned_basic_sources,
427
+ available_sources=fixing_sources + planned_basic_sources,
428
+ required_sources=required_sources,
429
+ grade=grade,
430
+ score_advantage=score_advantage,
431
+ aggressive=state.aggressive,
432
+ reasons=reasons,
433
+ )
434
+
435
+
436
+ def _fixing_counts(
437
+ *,
438
+ pool_grp_ids: tuple[int, ...],
439
+ card_database: CardDatabase,
440
+ base_pair: str | None,
441
+ ) -> dict[str, int]:
442
+ counts = {
443
+ color: 0
444
+ for color in COLOR_ORDER
445
+ if base_pair is None or color not in base_pair
446
+ }
447
+ if base_pair is None:
448
+ return counts
449
+
450
+ for grp_id in pool_grp_ids:
451
+ card = card_database.lookup(grp_id=grp_id)
452
+ if card.unknown or not card_is_castable_in_pair(
453
+ card=card,
454
+ base_pair=base_pair,
455
+ ):
456
+ continue
457
+ if not _is_drafted_fixing_land(card=card):
458
+ continue
459
+
460
+ for color in card.produced_mana:
461
+ if color in counts:
462
+ counts[color] += 1
463
+
464
+ return counts
465
+
466
+
467
+ def _active_splash_color(
468
+ *,
469
+ eligible_by_color: dict[str, list[tuple[float, int]]],
470
+ fixing_sources: dict[str, int],
471
+ config: SplashConfig,
472
+ ) -> str | None:
473
+ candidates = tuple(
474
+ color for color, cards in eligible_by_color.items() if cards
475
+ )
476
+ if not candidates:
477
+ return None
478
+
479
+ return max(
480
+ candidates,
481
+ key=lambda color: (
482
+ _supported_card_count(
483
+ card_count=len(eligible_by_color[color]),
484
+ fixing_sources=fixing_sources.get(color, 0),
485
+ config=config,
486
+ ),
487
+ len(eligible_by_color[color]),
488
+ max(eligible_by_color[color]),
489
+ fixing_sources.get(color, 0),
490
+ -COLOR_ORDER.index(color),
491
+ ),
492
+ )
493
+
494
+
495
+ def _supported_card_count(
496
+ *,
497
+ card_count: int,
498
+ fixing_sources: int,
499
+ config: SplashConfig,
500
+ ) -> int:
501
+ available_sources = fixing_sources + config.planned_basic_sources
502
+ if available_sources >= config.multiple_card_sources:
503
+ return min(card_count, config.maximum_cards)
504
+ if available_sources >= config.single_card_sources:
505
+ return min(card_count, 1)
506
+
507
+ return 0
508
+
509
+
510
+ def _pool_is_aggressive(
511
+ *,
512
+ pool_grp_ids: tuple[int, ...],
513
+ card_database: CardDatabase,
514
+ base_pair: str,
515
+ config: SplashConfig,
516
+ ) -> bool:
517
+ spells = tuple(
518
+ card_database.lookup(grp_id=grp_id)
519
+ for grp_id in pool_grp_ids
520
+ if _is_base_spell(
521
+ card=card_database.lookup(grp_id=grp_id),
522
+ base_pair=base_pair,
523
+ )
524
+ )
525
+ if len(spells) < config.aggressive_minimum_spells:
526
+ return False
527
+
528
+ mana_values = tuple(
529
+ card.mana_value for card in spells if card.mana_value is not None
530
+ )
531
+ if not mana_values:
532
+ return False
533
+
534
+ average_mana_value = sum(mana_values) / len(mana_values)
535
+ two_drop_ratio = sum(value == 2.0 for value in mana_values) / len(mana_values)
536
+ return (
537
+ average_mana_value <= config.aggressive_average_mana_value_max
538
+ and two_drop_ratio >= config.aggressive_minimum_two_drop_ratio
539
+ )
540
+
541
+
542
+ def _is_base_spell(*, card: CardInfo, base_pair: str) -> bool:
543
+ if card.unknown or any("Land" in type_line for type_line in card.types):
544
+ return False
545
+
546
+ return card_is_castable_in_pair(card=card, base_pair=base_pair)
547
+
548
+
549
+ def _is_splash_fixer(
550
+ *,
551
+ card: CardInfo,
552
+ state: SplashState,
553
+ config: SplashConfig,
554
+ ) -> bool:
555
+ active_color = state.active_color
556
+ if active_color is None or active_color not in card.produced_mana:
557
+ return False
558
+ if not _is_drafted_fixing_land(card=card):
559
+ return False
560
+
561
+ base_pair = state.base_pair
562
+ if base_pair is None or not card_is_castable_in_pair(
563
+ card=card,
564
+ base_pair=base_pair,
565
+ ):
566
+ return False
567
+
568
+ required_sources = _required_sources(
569
+ splash_card_count=max(1, state.picked_card_count),
570
+ config=config,
571
+ )
572
+ available_sources = (
573
+ state.fixing_for(color=active_color) + config.planned_basic_sources
574
+ )
575
+ return available_sources < required_sources
576
+
577
+
578
+ def _is_drafted_fixing_land(*, card: CardInfo) -> bool:
579
+ is_land = any("Land" in type_line for type_line in card.types)
580
+ is_basic = any(
581
+ "Basic" in type_line and "Land" in type_line
582
+ for type_line in card.types
583
+ )
584
+ return is_land and not is_basic
585
+
586
+
587
+ def _required_sources(
588
+ *,
589
+ splash_card_count: int,
590
+ config: SplashConfig,
591
+ ) -> int:
592
+ if splash_card_count <= 1:
593
+ return config.single_card_sources
594
+
595
+ return config.multiple_card_sources
596
+
597
+
598
+ def _global_grade(
599
+ *,
600
+ ratings_data: SeventeenLandsData | None,
601
+ grp_id: int,
602
+ ) -> str | None:
603
+ if ratings_data is None:
604
+ return None
605
+
606
+ return ratings_data.rating_for(grp_id=grp_id).letter_grade
607
+
608
+
609
+ def _global_win_rate(
610
+ *,
611
+ ratings_data: SeventeenLandsData | None,
612
+ grp_id: int,
613
+ ) -> float | None:
614
+ if ratings_data is None:
615
+ return None
616
+
617
+ return ratings_data.rating_for(grp_id=grp_id).gih_win_rate