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/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Package metadata for Draftomen.
|
|
2
|
+
Keep shared version and disclaimer text in one place.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version(distribution_name="draftomen")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
__version__ = "0.0.0+unknown"
|
|
11
|
+
|
|
12
|
+
DISCLAIMER = (
|
|
13
|
+
"Draft Omen is unofficial Fan Content permitted under the Fan Content Policy. "
|
|
14
|
+
"Not approved/endorsed by Wizards. Portions of the materials used are property "
|
|
15
|
+
"of Wizards of the Coast. ©Wizards of the Coast LLC. Card data from 17Lands "
|
|
16
|
+
"(17lands.com); 17Lands does not endorse this tool."
|
|
17
|
+
)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
draftomen/audit.py
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"""Independent append-only audit logging for live draft decisions.
|
|
2
|
+
Records preserve scoring inputs, recommendations, and eventual choices.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Callable, Mapping
|
|
11
|
+
from dataclasses import asdict
|
|
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 import __version__
|
|
18
|
+
from draftomen.config import PickEngineConfig
|
|
19
|
+
from draftomen.events import (
|
|
20
|
+
DraftCompletedEvent,
|
|
21
|
+
PackOfferedEvent,
|
|
22
|
+
PickMadeEvent,
|
|
23
|
+
)
|
|
24
|
+
from draftomen.pickengine import ScoredCard, ScoredPack
|
|
25
|
+
from draftomen.pool import DraftState
|
|
26
|
+
from draftomen.ranking import RANKING_MODES, rank_scored_cards, validate_ranking_mode
|
|
27
|
+
from draftomen.seventeen import SeventeenLandsData
|
|
28
|
+
|
|
29
|
+
PathInput: TypeAlias = str | PathLike[str]
|
|
30
|
+
Clock: TypeAlias = Callable[[], datetime]
|
|
31
|
+
AuditRecord: TypeAlias = dict[str, Any]
|
|
32
|
+
|
|
33
|
+
AUDIT_DIRECTORY_NAME = "audit"
|
|
34
|
+
DRAFT_AUDIT_DIRECTORY_NAME = "drafts"
|
|
35
|
+
DRAFT_AUDIT_SCHEMA_VERSION = 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DraftAuditError(RuntimeError):
|
|
39
|
+
"""Raised when a draft audit record cannot be read or persisted.
|
|
40
|
+
Live callers surface the failure instead of silently losing evidence.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def draft_audit_path(
|
|
45
|
+
*,
|
|
46
|
+
account_id: str,
|
|
47
|
+
draft_id: str,
|
|
48
|
+
app_dir: PathInput,
|
|
49
|
+
) -> Path:
|
|
50
|
+
"""Return the append-only JSONL path for one account and draft.
|
|
51
|
+
Path segments are validated before being used on disk.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
safe_draft_id = _path_segment(value=draft_id, field_name="draft_id")
|
|
55
|
+
return (
|
|
56
|
+
Path(app_dir)
|
|
57
|
+
/ AUDIT_DIRECTORY_NAME
|
|
58
|
+
/ DRAFT_AUDIT_DIRECTORY_NAME
|
|
59
|
+
/ _path_segment(value=account_id, field_name="account_id")
|
|
60
|
+
/ f"{safe_draft_id}.jsonl"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_draft_audit_records(
|
|
65
|
+
*,
|
|
66
|
+
account_id: str,
|
|
67
|
+
draft_id: str,
|
|
68
|
+
app_dir: PathInput,
|
|
69
|
+
) -> tuple[AuditRecord, ...]:
|
|
70
|
+
"""Load and validate every audit record for one draft.
|
|
71
|
+
Malformed lines fail with their one-based line number.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
path = draft_audit_path(
|
|
75
|
+
account_id=account_id,
|
|
76
|
+
draft_id=draft_id,
|
|
77
|
+
app_dir=app_dir,
|
|
78
|
+
)
|
|
79
|
+
if not path.exists():
|
|
80
|
+
return ()
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
84
|
+
except OSError as error:
|
|
85
|
+
raise DraftAuditError(f"Could not read draft audit log {path}: {error}.") from error
|
|
86
|
+
|
|
87
|
+
records: list[AuditRecord] = []
|
|
88
|
+
for line_number, line in enumerate(lines, start=1):
|
|
89
|
+
try:
|
|
90
|
+
value = json.loads(line)
|
|
91
|
+
except json.JSONDecodeError as error:
|
|
92
|
+
raise DraftAuditError(
|
|
93
|
+
f"Malformed draft audit log {path} at line {line_number}: {error}."
|
|
94
|
+
) from error
|
|
95
|
+
|
|
96
|
+
if not isinstance(value, dict):
|
|
97
|
+
raise DraftAuditError(
|
|
98
|
+
f"Malformed draft audit log {path} at line {line_number}: "
|
|
99
|
+
"expected an object."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
schema_version = value.get("schema_version")
|
|
103
|
+
if schema_version != DRAFT_AUDIT_SCHEMA_VERSION:
|
|
104
|
+
raise DraftAuditError(
|
|
105
|
+
f"Unsupported draft audit schema {schema_version!r} in {path} "
|
|
106
|
+
f"at line {line_number}; expected {DRAFT_AUDIT_SCHEMA_VERSION}."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
record_id = value.get("record_id")
|
|
110
|
+
if not isinstance(record_id, str) or not record_id:
|
|
111
|
+
raise DraftAuditError(
|
|
112
|
+
f"Malformed draft audit log {path} at line {line_number}: "
|
|
113
|
+
"missing record_id."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
records.append(value)
|
|
117
|
+
|
|
118
|
+
return tuple(records)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class DraftAuditStore:
|
|
122
|
+
"""Persist stable, independently analyzable records for live drafts.
|
|
123
|
+
Duplicate log scans reuse record ids and leave prior evidence unchanged.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
app_dir: PathInput,
|
|
130
|
+
clock: Clock | None = None,
|
|
131
|
+
app_version: str = __version__,
|
|
132
|
+
) -> None:
|
|
133
|
+
self.app_dir = app_dir
|
|
134
|
+
self._clock = _system_clock if clock is None else clock
|
|
135
|
+
self._app_version = app_version
|
|
136
|
+
self._indexed_paths: set[Path] = set()
|
|
137
|
+
self._known_record_ids: dict[Path, set[str]] = {}
|
|
138
|
+
self._closed_decision_ids: dict[Path, set[str]] = {}
|
|
139
|
+
self._latest_evaluations: dict[tuple[Path, str], AuditRecord] = {}
|
|
140
|
+
|
|
141
|
+
def record_draft_started(self, *, state: DraftState) -> Path:
|
|
142
|
+
"""Record one draft's durable identity and starting metadata.
|
|
143
|
+
Repeated course snapshots resolve to the same stable record.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
payload: AuditRecord = {
|
|
147
|
+
"course_id": state.course_id,
|
|
148
|
+
"started_at": state.started_at,
|
|
149
|
+
}
|
|
150
|
+
record_id = _record_id(
|
|
151
|
+
prefix="draft-started",
|
|
152
|
+
value={
|
|
153
|
+
"account_id": state.account_id,
|
|
154
|
+
"draft_id": state.draft_id,
|
|
155
|
+
**payload,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
return self._append(
|
|
159
|
+
state=state,
|
|
160
|
+
record_type="draft_started",
|
|
161
|
+
record_id=record_id,
|
|
162
|
+
payload=payload,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def record_decision(
|
|
166
|
+
self,
|
|
167
|
+
*,
|
|
168
|
+
state: DraftState,
|
|
169
|
+
event: PackOfferedEvent,
|
|
170
|
+
scored_pack: ScoredPack,
|
|
171
|
+
config: PickEngineConfig,
|
|
172
|
+
ratings_data: SeventeenLandsData | None,
|
|
173
|
+
) -> Path:
|
|
174
|
+
"""Record one distinct scoring evaluation for an offered pack.
|
|
175
|
+
Evaluations after a recorded choice are ignored as retrospective rescans.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
path = self.path_for(state=state)
|
|
179
|
+
self._ensure_index(path=path)
|
|
180
|
+
decision_id = _decision_id(
|
|
181
|
+
state=state,
|
|
182
|
+
pack_number=event.pack_number,
|
|
183
|
+
pick_number=event.pick_number,
|
|
184
|
+
)
|
|
185
|
+
if decision_id in self._closed_decision_ids[path]:
|
|
186
|
+
return path
|
|
187
|
+
|
|
188
|
+
evaluation = _decision_payload(
|
|
189
|
+
state=state,
|
|
190
|
+
event=event,
|
|
191
|
+
scored_pack=scored_pack,
|
|
192
|
+
config=config,
|
|
193
|
+
ratings_data=ratings_data,
|
|
194
|
+
app_version=self._app_version,
|
|
195
|
+
decision_id=decision_id,
|
|
196
|
+
)
|
|
197
|
+
evaluation_id = _record_id(prefix="evaluation", value=evaluation)
|
|
198
|
+
payload = {
|
|
199
|
+
**evaluation,
|
|
200
|
+
"evaluation_id": evaluation_id,
|
|
201
|
+
}
|
|
202
|
+
return self._append(
|
|
203
|
+
state=state,
|
|
204
|
+
record_type="decision_evaluated",
|
|
205
|
+
record_id=evaluation_id,
|
|
206
|
+
payload=payload,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def record_choice(
|
|
210
|
+
self,
|
|
211
|
+
*,
|
|
212
|
+
state: DraftState,
|
|
213
|
+
event: PickMadeEvent,
|
|
214
|
+
ranking_mode: str,
|
|
215
|
+
) -> Path:
|
|
216
|
+
"""Record the actual Arena pick and recommendation visible at that time.
|
|
217
|
+
The choice links to the most recent persisted evaluation when available.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
mode = validate_ranking_mode(ranking_mode=ranking_mode)
|
|
221
|
+
path = self.path_for(state=state)
|
|
222
|
+
self._ensure_index(path=path)
|
|
223
|
+
decision_id = _decision_id(
|
|
224
|
+
state=state,
|
|
225
|
+
pack_number=event.pack_number,
|
|
226
|
+
pick_number=event.pick_number,
|
|
227
|
+
)
|
|
228
|
+
evaluation = self._latest_evaluations.get((path, decision_id))
|
|
229
|
+
recommended_grp_id = _recommended_grp_id(
|
|
230
|
+
evaluation=evaluation,
|
|
231
|
+
ranking_mode=mode,
|
|
232
|
+
)
|
|
233
|
+
payload: AuditRecord = {
|
|
234
|
+
"decision_id": decision_id,
|
|
235
|
+
"evaluation_id": (
|
|
236
|
+
None if evaluation is None else evaluation.get("evaluation_id")
|
|
237
|
+
),
|
|
238
|
+
"pack_number": event.pack_number,
|
|
239
|
+
"pick_number": event.pick_number,
|
|
240
|
+
"chosen_grp_id": event.chosen_grp_id,
|
|
241
|
+
"ranking_mode": mode,
|
|
242
|
+
"recommended_grp_id": recommended_grp_id,
|
|
243
|
+
"recommendation_followed": (
|
|
244
|
+
None
|
|
245
|
+
if recommended_grp_id is None
|
|
246
|
+
else recommended_grp_id == event.chosen_grp_id
|
|
247
|
+
),
|
|
248
|
+
}
|
|
249
|
+
record_id = _record_id(
|
|
250
|
+
prefix="choice",
|
|
251
|
+
value={
|
|
252
|
+
"decision_id": decision_id,
|
|
253
|
+
"chosen_grp_id": event.chosen_grp_id,
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
return self._append(
|
|
257
|
+
state=state,
|
|
258
|
+
record_type="choice_made",
|
|
259
|
+
record_id=record_id,
|
|
260
|
+
payload=payload,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def record_draft_completed(
|
|
264
|
+
self,
|
|
265
|
+
*,
|
|
266
|
+
state: DraftState,
|
|
267
|
+
event: DraftCompletedEvent,
|
|
268
|
+
) -> Path:
|
|
269
|
+
"""Record final pool contents and the completion signal type.
|
|
270
|
+
Repeated completion payloads share one stable record.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
payload: AuditRecord = {
|
|
274
|
+
"pack_number": event.pack_number,
|
|
275
|
+
"pick_number": event.pick_number,
|
|
276
|
+
"picked_grp_ids": list(event.picked_grp_ids),
|
|
277
|
+
"pick_count": len(event.picked_grp_ids),
|
|
278
|
+
"inferred": event.inferred,
|
|
279
|
+
"completed_at": state.completed_at,
|
|
280
|
+
}
|
|
281
|
+
record_id = _record_id(
|
|
282
|
+
prefix="draft-completed",
|
|
283
|
+
value={
|
|
284
|
+
"account_id": state.account_id,
|
|
285
|
+
"draft_id": state.draft_id,
|
|
286
|
+
**payload,
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
return self._append(
|
|
290
|
+
state=state,
|
|
291
|
+
record_type="draft_completed",
|
|
292
|
+
record_id=record_id,
|
|
293
|
+
payload=payload,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def path_for(self, *, state: DraftState) -> Path:
|
|
297
|
+
"""Return the audit path associated with one persisted draft state.
|
|
298
|
+
The path is resolved without creating files or directories.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
return draft_audit_path(
|
|
302
|
+
account_id=state.account_id,
|
|
303
|
+
draft_id=state.draft_id,
|
|
304
|
+
app_dir=self.app_dir,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def _append(
|
|
308
|
+
self,
|
|
309
|
+
*,
|
|
310
|
+
state: DraftState,
|
|
311
|
+
record_type: str,
|
|
312
|
+
record_id: str,
|
|
313
|
+
payload: Mapping[str, Any],
|
|
314
|
+
) -> Path:
|
|
315
|
+
path = self.path_for(state=state)
|
|
316
|
+
self._ensure_index(path=path)
|
|
317
|
+
if record_id in self._known_record_ids[path]:
|
|
318
|
+
return path
|
|
319
|
+
|
|
320
|
+
record: AuditRecord = {
|
|
321
|
+
"schema_version": DRAFT_AUDIT_SCHEMA_VERSION,
|
|
322
|
+
"record_id": record_id,
|
|
323
|
+
"record_type": record_type,
|
|
324
|
+
"recorded_at": self._now_iso(),
|
|
325
|
+
"app_version": self._app_version,
|
|
326
|
+
"account_id": state.account_id,
|
|
327
|
+
"draft_id": state.draft_id,
|
|
328
|
+
"event_name": state.event_name,
|
|
329
|
+
"set_code": state.set_code,
|
|
330
|
+
**dict(payload),
|
|
331
|
+
}
|
|
332
|
+
encoded = (
|
|
333
|
+
json.dumps(
|
|
334
|
+
record,
|
|
335
|
+
ensure_ascii=True,
|
|
336
|
+
separators=(",", ":"),
|
|
337
|
+
sort_keys=True,
|
|
338
|
+
)
|
|
339
|
+
+ "\n"
|
|
340
|
+
).encode("utf-8")
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
descriptor = os.open(
|
|
345
|
+
path,
|
|
346
|
+
os.O_APPEND | os.O_CREAT | os.O_WRONLY,
|
|
347
|
+
0o600,
|
|
348
|
+
)
|
|
349
|
+
try:
|
|
350
|
+
_write_all(descriptor=descriptor, data=encoded)
|
|
351
|
+
os.fsync(descriptor)
|
|
352
|
+
finally:
|
|
353
|
+
os.close(descriptor)
|
|
354
|
+
except OSError as error:
|
|
355
|
+
raise DraftAuditError(
|
|
356
|
+
f"Could not append draft audit record to {path}: {error}."
|
|
357
|
+
) from error
|
|
358
|
+
|
|
359
|
+
self._remember_record(path=path, record=record)
|
|
360
|
+
return path
|
|
361
|
+
|
|
362
|
+
def _ensure_index(self, *, path: Path) -> None:
|
|
363
|
+
if path in self._indexed_paths:
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
self._known_record_ids[path] = set()
|
|
367
|
+
self._closed_decision_ids[path] = set()
|
|
368
|
+
account_id = path.parent.name
|
|
369
|
+
draft_id = path.stem
|
|
370
|
+
for record in load_draft_audit_records(
|
|
371
|
+
account_id=account_id,
|
|
372
|
+
draft_id=draft_id,
|
|
373
|
+
app_dir=self.app_dir,
|
|
374
|
+
):
|
|
375
|
+
self._remember_record(path=path, record=record)
|
|
376
|
+
|
|
377
|
+
self._indexed_paths.add(path)
|
|
378
|
+
|
|
379
|
+
def _remember_record(self, *, path: Path, record: AuditRecord) -> None:
|
|
380
|
+
record_id = record["record_id"]
|
|
381
|
+
self._known_record_ids[path].add(record_id)
|
|
382
|
+
record_type = record.get("record_type")
|
|
383
|
+
decision_id = record.get("decision_id")
|
|
384
|
+
if record_type == "decision_evaluated" and isinstance(decision_id, str):
|
|
385
|
+
self._latest_evaluations[(path, decision_id)] = record
|
|
386
|
+
elif record_type == "choice_made" and isinstance(decision_id, str):
|
|
387
|
+
self._closed_decision_ids[path].add(decision_id)
|
|
388
|
+
|
|
389
|
+
def _now_iso(self) -> str:
|
|
390
|
+
return self._clock().astimezone(UTC).isoformat()
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _decision_payload(
|
|
394
|
+
*,
|
|
395
|
+
state: DraftState,
|
|
396
|
+
event: PackOfferedEvent,
|
|
397
|
+
scored_pack: ScoredPack,
|
|
398
|
+
config: PickEngineConfig,
|
|
399
|
+
ratings_data: SeventeenLandsData | None,
|
|
400
|
+
app_version: str,
|
|
401
|
+
decision_id: str,
|
|
402
|
+
) -> AuditRecord:
|
|
403
|
+
rankings = {
|
|
404
|
+
mode: [
|
|
405
|
+
card.card.grp_id
|
|
406
|
+
for card in rank_scored_cards(cards=scored_pack.cards, ranking_mode=mode)
|
|
407
|
+
]
|
|
408
|
+
for mode in RANKING_MODES
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
"decision_id": decision_id,
|
|
412
|
+
"pack_number": event.pack_number,
|
|
413
|
+
"pick_number": event.pick_number,
|
|
414
|
+
"pick_index": scored_pack.commitment.pick_index,
|
|
415
|
+
"offered_grp_ids": list(event.offered_grp_ids),
|
|
416
|
+
"pool_before_pick": list(event.pool_grp_ids),
|
|
417
|
+
"source_summary": scored_pack.source_summary,
|
|
418
|
+
"algorithm": {
|
|
419
|
+
"name": "draftomen.pickengine.PickEngine",
|
|
420
|
+
"app_version": app_version,
|
|
421
|
+
"config": asdict(config),
|
|
422
|
+
"features": {
|
|
423
|
+
"splash_enabled": scored_pack.splash_state.enabled,
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
"ratings_snapshot": _ratings_snapshot(ratings_data=ratings_data),
|
|
427
|
+
"normalization": {
|
|
428
|
+
"lower_rating": scored_pack.normalization.lower_rating,
|
|
429
|
+
"upper_rating": scored_pack.normalization.upper_rating,
|
|
430
|
+
"neutral_rating": scored_pack.normalization.neutral_rating,
|
|
431
|
+
},
|
|
432
|
+
"commitment": {
|
|
433
|
+
"pick_index": scored_pack.commitment.pick_index,
|
|
434
|
+
"pool_size": scored_pack.commitment.pool_size,
|
|
435
|
+
"color_weights": dict(scored_pack.commitment.color_weights),
|
|
436
|
+
"inferred_pair": scored_pack.commitment.inferred_pair,
|
|
437
|
+
"level": scored_pack.commitment.level,
|
|
438
|
+
"phase": scored_pack.commitment.phase,
|
|
439
|
+
"locked": scored_pack.commitment.locked,
|
|
440
|
+
},
|
|
441
|
+
"splash_state": asdict(scored_pack.splash_state),
|
|
442
|
+
"rankings": rankings,
|
|
443
|
+
"recommended_grp_id": rankings["score"][0] if rankings["score"] else None,
|
|
444
|
+
"candidates": [
|
|
445
|
+
_candidate_payload(scored_card=card, rank=rank)
|
|
446
|
+
for rank, card in enumerate(scored_pack.cards, start=1)
|
|
447
|
+
],
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _candidate_payload(*, scored_card: ScoredCard, rank: int) -> AuditRecord:
|
|
452
|
+
rating = scored_card.rating
|
|
453
|
+
return {
|
|
454
|
+
"rank": rank,
|
|
455
|
+
"grp_id": scored_card.card.grp_id,
|
|
456
|
+
"name": scored_card.card.name,
|
|
457
|
+
"colors": list(scored_card.card.colors),
|
|
458
|
+
"mana_value": scored_card.card.mana_value,
|
|
459
|
+
"rarity": scored_card.card.rarity,
|
|
460
|
+
"types": list(scored_card.card.types),
|
|
461
|
+
"unknown": scored_card.card.unknown,
|
|
462
|
+
"offered_index": scored_card.original_index,
|
|
463
|
+
"rating": {
|
|
464
|
+
"grp_id": rating.grp_id,
|
|
465
|
+
"name": rating.name,
|
|
466
|
+
"color": rating.color,
|
|
467
|
+
"rarity": rating.rarity,
|
|
468
|
+
"average_last_seen_at": rating.average_last_seen_at,
|
|
469
|
+
"gih_win_rate": rating.gih_win_rate,
|
|
470
|
+
"opening_hand_win_rate": rating.opening_hand_win_rate,
|
|
471
|
+
"drawn_improvement_win_rate": rating.drawn_improvement_win_rate,
|
|
472
|
+
"sample_counts": rating.sample_counts.to_json(),
|
|
473
|
+
"letter_grade": rating.letter_grade,
|
|
474
|
+
"neutral_prior_score": rating.neutral_prior_score,
|
|
475
|
+
"neutral_prior": rating.neutral_prior,
|
|
476
|
+
"source": {
|
|
477
|
+
"requested_format": rating.metadata.requested_format,
|
|
478
|
+
"source": rating.metadata.source,
|
|
479
|
+
"source_format": rating.metadata.source_format,
|
|
480
|
+
"fallback_reason": rating.metadata.fallback_reason,
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
"scoring": {
|
|
484
|
+
"base_rating": scored_card.base_rating,
|
|
485
|
+
"base_score": scored_card.base_score,
|
|
486
|
+
"color_fit": scored_card.color_fit,
|
|
487
|
+
"color_factor": scored_card.color_factor,
|
|
488
|
+
"adjusted_rating": scored_card.adjusted_rating,
|
|
489
|
+
"raw_score": scored_card.raw_score,
|
|
490
|
+
"score": scored_card.score,
|
|
491
|
+
"source_label": scored_card.source_label,
|
|
492
|
+
"pair_tiebreaker_pair": scored_card.pair_tiebreaker_pair,
|
|
493
|
+
"pair_tiebreaker_win_rate": scored_card.pair_tiebreaker_win_rate,
|
|
494
|
+
"pair_tiebreaker_weight": scored_card.pair_tiebreaker_weight,
|
|
495
|
+
"score_sort_index": scored_card.score_sort_index,
|
|
496
|
+
},
|
|
497
|
+
"splash": asdict(scored_card.splash),
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _ratings_snapshot(*, ratings_data: SeventeenLandsData | None) -> AuditRecord | None:
|
|
502
|
+
if ratings_data is None:
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
pair_datasets = {
|
|
506
|
+
pair: {
|
|
507
|
+
"event_format": dataset.event_format,
|
|
508
|
+
"fetched_at": dataset.fetched_at.astimezone(UTC).isoformat(),
|
|
509
|
+
}
|
|
510
|
+
for pair, dataset in sorted(ratings_data.pair_card_ratings.items())
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
"set_code": ratings_data.set_code,
|
|
514
|
+
"requested_format": ratings_data.requested_format,
|
|
515
|
+
"primary": {
|
|
516
|
+
"event_format": ratings_data.primary.event_format,
|
|
517
|
+
"fetched_at": ratings_data.primary.fetched_at.astimezone(UTC).isoformat(),
|
|
518
|
+
},
|
|
519
|
+
"fallback": (
|
|
520
|
+
None
|
|
521
|
+
if ratings_data.fallback is None
|
|
522
|
+
else {
|
|
523
|
+
"event_format": ratings_data.fallback.event_format,
|
|
524
|
+
"fetched_at": ratings_data.fallback.fetched_at.astimezone(UTC).isoformat(),
|
|
525
|
+
}
|
|
526
|
+
),
|
|
527
|
+
"pair_card_ratings": pair_datasets,
|
|
528
|
+
"pair_win_rates": {
|
|
529
|
+
pair: record.to_json()
|
|
530
|
+
for pair, record in sorted(ratings_data.pair_win_rates.items())
|
|
531
|
+
},
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _decision_id(
|
|
536
|
+
*,
|
|
537
|
+
state: DraftState,
|
|
538
|
+
pack_number: int,
|
|
539
|
+
pick_number: int,
|
|
540
|
+
) -> str:
|
|
541
|
+
return _record_id(
|
|
542
|
+
prefix="decision",
|
|
543
|
+
value={
|
|
544
|
+
"account_id": state.account_id,
|
|
545
|
+
"draft_id": state.draft_id,
|
|
546
|
+
"pack_number": pack_number,
|
|
547
|
+
"pick_number": pick_number,
|
|
548
|
+
},
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _recommended_grp_id(
|
|
553
|
+
*,
|
|
554
|
+
evaluation: AuditRecord | None,
|
|
555
|
+
ranking_mode: str,
|
|
556
|
+
) -> int | None:
|
|
557
|
+
if evaluation is None:
|
|
558
|
+
return None
|
|
559
|
+
|
|
560
|
+
rankings = evaluation.get("rankings")
|
|
561
|
+
if not isinstance(rankings, dict):
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
ranked_grp_ids = rankings.get(ranking_mode)
|
|
565
|
+
if not isinstance(ranked_grp_ids, list) or not ranked_grp_ids:
|
|
566
|
+
return None
|
|
567
|
+
|
|
568
|
+
grp_id = ranked_grp_ids[0]
|
|
569
|
+
return grp_id if isinstance(grp_id, int) else None
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _record_id(*, prefix: str, value: Mapping[str, Any]) -> str:
|
|
573
|
+
canonical = json.dumps(
|
|
574
|
+
value,
|
|
575
|
+
ensure_ascii=True,
|
|
576
|
+
separators=(",", ":"),
|
|
577
|
+
sort_keys=True,
|
|
578
|
+
)
|
|
579
|
+
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
580
|
+
return f"{prefix}:{digest}"
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _write_all(*, descriptor: int, data: bytes) -> None:
|
|
584
|
+
view = memoryview(data)
|
|
585
|
+
while view:
|
|
586
|
+
written = os.write(descriptor, view)
|
|
587
|
+
if written <= 0:
|
|
588
|
+
raise OSError("append returned no bytes")
|
|
589
|
+
view = view[written:]
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _path_segment(*, value: str, field_name: str) -> str:
|
|
593
|
+
if (
|
|
594
|
+
not value
|
|
595
|
+
or value in {".", ".."}
|
|
596
|
+
or "/" in value
|
|
597
|
+
or "\\" in value
|
|
598
|
+
or "\x00" in value
|
|
599
|
+
):
|
|
600
|
+
raise DraftAuditError(f"Invalid {field_name}; cannot be used as a path segment.")
|
|
601
|
+
|
|
602
|
+
return value
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _system_clock() -> datetime:
|
|
606
|
+
return datetime.now(tz=UTC)
|