bdo-toolkit 1.0.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.
- bdo_toolkit/__init__.py +87 -0
- bdo_toolkit/_async_sessions.py +651 -0
- bdo_toolkit/_capture_backend.py +194 -0
- bdo_toolkit/_capture_options.py +68 -0
- bdo_toolkit/_capture_runtime.py +626 -0
- bdo_toolkit/_deposit_origin.py +1599 -0
- bdo_toolkit/_engine.py +327 -0
- bdo_toolkit/_framing.py +904 -0
- bdo_toolkit/_profile_runtime.py +157 -0
- bdo_toolkit/_protocol.py +386 -0
- bdo_toolkit/_reassembly.py +654 -0
- bdo_toolkit/_specs.py +285 -0
- bdo_toolkit/_storage_destination_validation.py +167 -0
- bdo_toolkit/_storage_hydration.py +241 -0
- bdo_toolkit/_version.py +3 -0
- bdo_toolkit/calibration.py +3223 -0
- bdo_toolkit/capture.py +1713 -0
- bdo_toolkit/character_state.py +3506 -0
- bdo_toolkit/cli.py +948 -0
- bdo_toolkit/diagnostics.py +51 -0
- bdo_toolkit/events.py +214 -0
- bdo_toolkit/filters.py +105 -0
- bdo_toolkit/item_state.py +48 -0
- bdo_toolkit/origin_learning.py +779 -0
- bdo_toolkit/profiles.py +370 -0
- bdo_toolkit/py.typed +1 -0
- bdo_toolkit/remote_profiles.py +358 -0
- bdo_toolkit/solare/__init__.py +50 -0
- bdo_toolkit/solare/_constants.py +94 -0
- bdo_toolkit/solare/_detail_learning.py +1437 -0
- bdo_toolkit/solare/_details.py +796 -0
- bdo_toolkit/solare/_discovery.py +1212 -0
- bdo_toolkit/solare/_live_tracker.py +472 -0
- bdo_toolkit/solare/_replay_capture.py +182 -0
- bdo_toolkit/solare/_result.py +441 -0
- bdo_toolkit/solare/_scanner.py +203 -0
- bdo_toolkit/solare/_validation.py +11 -0
- bdo_toolkit/solare/async_session.py +444 -0
- bdo_toolkit/solare/models.py +806 -0
- bdo_toolkit/solare/replay.py +62 -0
- bdo_toolkit/solare/session.py +1051 -0
- bdo_toolkit/writers.py +30 -0
- bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
- bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
- bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
- bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
- bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- bdo_toolkit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
"""Immutable public models for Arena of Solare leaderboard snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any, ClassVar, Iterable, Optional
|
|
11
|
+
|
|
12
|
+
from .._capture_runtime import CaptureEndpoint, _capture_is_clean
|
|
13
|
+
|
|
14
|
+
_SOLARE_SCHEMA_VERSION = 2
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SolareDetectionStatus(str, Enum):
|
|
18
|
+
"""Final or best-known structural classification for one capture window."""
|
|
19
|
+
|
|
20
|
+
COMPLETE = "complete"
|
|
21
|
+
DETECTED_INCOMPLETE = "detected-incomplete"
|
|
22
|
+
RICH_CANDIDATE = "rich-candidate"
|
|
23
|
+
RANKED_PARTIAL = "ranked-partial"
|
|
24
|
+
MENU_CONTEXT = "menu-context"
|
|
25
|
+
INCONCLUSIVE = "inconclusive"
|
|
26
|
+
NO_TRAFFIC = "no-traffic"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SolareUpdateKind(str, Enum):
|
|
30
|
+
"""Structured live progress stages; display wording is intentionally separate."""
|
|
31
|
+
|
|
32
|
+
CAPTURE_READY = "capture-ready"
|
|
33
|
+
TRAFFIC = "traffic"
|
|
34
|
+
MENU_CONTEXT = "menu-context"
|
|
35
|
+
RANKED_PROGRESS = "ranked-progress"
|
|
36
|
+
RICH_CANDIDATE = "rich-candidate"
|
|
37
|
+
CROSS_CHECK = "cross-check"
|
|
38
|
+
SNAPSHOT_CONFIRMED = "snapshot-confirmed"
|
|
39
|
+
WARNING = "warning"
|
|
40
|
+
FINISHED = "finished"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class SolareClass:
|
|
45
|
+
"""Numeric BDO class code plus a best-effort display name."""
|
|
46
|
+
|
|
47
|
+
code: int
|
|
48
|
+
name: Optional[str]
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, object]:
|
|
51
|
+
return {"code": self.code, "name": self.name}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class SolareSpecialization:
|
|
56
|
+
"""Observed class-aware specialization code."""
|
|
57
|
+
|
|
58
|
+
code: int
|
|
59
|
+
branch: str
|
|
60
|
+
name: Optional[str]
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, object]:
|
|
63
|
+
return {"code": self.code, "branch": self.branch, "name": self.name}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class SolareRawSection:
|
|
68
|
+
"""Opaque bytes retained at a record-relative offset.
|
|
69
|
+
|
|
70
|
+
The Python API exposes literal ``bytes`` through :attr:`data`. JSON uses
|
|
71
|
+
lowercase hexadecimal so round-tripping is deterministic and does not
|
|
72
|
+
imply that the section's internal fields are understood.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
offset: int
|
|
76
|
+
data: bytes
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def length(self) -> int:
|
|
80
|
+
return len(self.data)
|
|
81
|
+
|
|
82
|
+
def to_dict(self) -> dict[str, object]:
|
|
83
|
+
return {
|
|
84
|
+
"offset": self.offset,
|
|
85
|
+
"length": self.length,
|
|
86
|
+
"encoding": "hex",
|
|
87
|
+
"data": self.data.hex(),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class SolareClassPerformance:
|
|
93
|
+
"""One occupied class slot from a validated Solare player record.
|
|
94
|
+
|
|
95
|
+
Performance fields are optional because rank/name/class discovery can be
|
|
96
|
+
structurally confirmed even when a patch changes the deeper record layout.
|
|
97
|
+
Missing means unavailable, never zero. The containing ``SolarePlayer`` or
|
|
98
|
+
``SolareOverallEntry`` establishes whether the slot came from the class or
|
|
99
|
+
overall leaderboard response.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
slot: int
|
|
103
|
+
primary: bool
|
|
104
|
+
player_class: SolareClass
|
|
105
|
+
specialization: Optional[SolareSpecialization] = None
|
|
106
|
+
matches: Optional[int] = None
|
|
107
|
+
wins: Optional[int] = None
|
|
108
|
+
draws: Optional[int] = None
|
|
109
|
+
losses: Optional[int] = None
|
|
110
|
+
recent_results_raw: tuple[int, ...] = ()
|
|
111
|
+
recent_results_wire_text: Optional[str] = None
|
|
112
|
+
gear_loadout_raw: Optional[SolareRawSection] = None
|
|
113
|
+
skill_addons_raw: Optional[SolareRawSection] = None
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def win_rate(self) -> Optional[float]:
|
|
117
|
+
if self.matches is None or self.wins is None or self.matches <= 0:
|
|
118
|
+
return None
|
|
119
|
+
return round((self.wins / self.matches) * 100, 2)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def record_is_balanced(self) -> Optional[bool]:
|
|
123
|
+
values = (self.matches, self.wins, self.draws, self.losses)
|
|
124
|
+
if any(value is None for value in values):
|
|
125
|
+
return None
|
|
126
|
+
assert self.matches is not None
|
|
127
|
+
assert self.wins is not None
|
|
128
|
+
assert self.draws is not None
|
|
129
|
+
assert self.losses is not None
|
|
130
|
+
return self.wins + self.draws + self.losses == self.matches
|
|
131
|
+
|
|
132
|
+
def to_dict(self, *, include_raw: bool = False) -> dict[str, Any]:
|
|
133
|
+
output: dict[str, Any] = {
|
|
134
|
+
"slot": self.slot,
|
|
135
|
+
"primary": self.primary,
|
|
136
|
+
"class": self.player_class.to_dict(),
|
|
137
|
+
}
|
|
138
|
+
if self.specialization is not None:
|
|
139
|
+
output["specialization"] = self.specialization.to_dict()
|
|
140
|
+
for key in ("matches", "wins", "draws", "losses"):
|
|
141
|
+
value = getattr(self, key)
|
|
142
|
+
if value is not None:
|
|
143
|
+
output[key] = value
|
|
144
|
+
if self.win_rate is not None:
|
|
145
|
+
output["win_rate"] = self.win_rate
|
|
146
|
+
if self.recent_results_raw:
|
|
147
|
+
output["recent_results_raw"] = list(self.recent_results_raw)
|
|
148
|
+
if self.recent_results_wire_text is not None:
|
|
149
|
+
output["recent_results_wire_text"] = self.recent_results_wire_text
|
|
150
|
+
if include_raw:
|
|
151
|
+
if self.gear_loadout_raw is not None:
|
|
152
|
+
output["gear_loadout_raw"] = self.gear_loadout_raw.to_dict()
|
|
153
|
+
if self.skill_addons_raw is not None:
|
|
154
|
+
output["skill_addons_raw"] = self.skill_addons_raw.to_dict()
|
|
155
|
+
return output
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class SolarePlayer:
|
|
160
|
+
"""One player carried by the rich Solare leaderboard table."""
|
|
161
|
+
|
|
162
|
+
name: str
|
|
163
|
+
global_rank: int
|
|
164
|
+
primary_class: SolareClass
|
|
165
|
+
elo: Optional[int] = field(default=None, kw_only=True)
|
|
166
|
+
classes_played: tuple[SolareClassPerformance, ...] = field(
|
|
167
|
+
default=(),
|
|
168
|
+
kw_only=True,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def to_dict(self, *, include_raw: bool = False) -> dict[str, Any]:
|
|
172
|
+
output: dict[str, Any] = {
|
|
173
|
+
"name": self.name,
|
|
174
|
+
"global_rank": self.global_rank,
|
|
175
|
+
"primary_class": self.primary_class.to_dict(),
|
|
176
|
+
"classes_played": [
|
|
177
|
+
item.to_dict(include_raw=include_raw) for item in self.classes_played
|
|
178
|
+
],
|
|
179
|
+
}
|
|
180
|
+
if self.elo is not None:
|
|
181
|
+
output["elo"] = self.elo
|
|
182
|
+
return output
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class SolareOverallEntry:
|
|
187
|
+
"""One independently decoded row from the overall top-100 table.
|
|
188
|
+
|
|
189
|
+
Aggregate wins, draws, and losses are independently decoded overall-record
|
|
190
|
+
values. ``total_matches`` is their arithmetic sum, not a separately
|
|
191
|
+
decoded wire field.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
name: str
|
|
195
|
+
global_rank: int
|
|
196
|
+
elo: Optional[int] = field(default=None, kw_only=True)
|
|
197
|
+
classes_played: tuple[SolareClassPerformance, ...] = field(
|
|
198
|
+
default=(),
|
|
199
|
+
kw_only=True,
|
|
200
|
+
)
|
|
201
|
+
total_wins: Optional[int] = field(default=None, kw_only=True)
|
|
202
|
+
total_draws: Optional[int] = field(default=None, kw_only=True)
|
|
203
|
+
total_losses: Optional[int] = field(default=None, kw_only=True)
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def total_matches(self) -> Optional[int]:
|
|
207
|
+
"""Return aggregate W+D+L when the complete overall tuple is available."""
|
|
208
|
+
|
|
209
|
+
values = (self.total_wins, self.total_draws, self.total_losses)
|
|
210
|
+
if any(value is None for value in values):
|
|
211
|
+
return None
|
|
212
|
+
assert self.total_wins is not None
|
|
213
|
+
assert self.total_draws is not None
|
|
214
|
+
assert self.total_losses is not None
|
|
215
|
+
return self.total_wins + self.total_draws + self.total_losses
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def total_win_rate(self) -> Optional[float]:
|
|
219
|
+
"""Return the aggregate win percentage when total matches is positive."""
|
|
220
|
+
|
|
221
|
+
matches = self.total_matches
|
|
222
|
+
if matches is None or matches <= 0:
|
|
223
|
+
return None
|
|
224
|
+
assert self.total_wins is not None
|
|
225
|
+
return round((self.total_wins / matches) * 100, 2)
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def primary_class(self) -> Optional[SolareClass]:
|
|
229
|
+
"""Return the class from the row's validated primary class slot."""
|
|
230
|
+
|
|
231
|
+
performance = next(
|
|
232
|
+
(item for item in self.classes_played if item.primary),
|
|
233
|
+
None,
|
|
234
|
+
)
|
|
235
|
+
return performance.player_class if performance is not None else None
|
|
236
|
+
|
|
237
|
+
def to_dict(self, *, include_raw: bool = False) -> dict[str, Any]:
|
|
238
|
+
output: dict[str, Any] = {
|
|
239
|
+
"name": self.name,
|
|
240
|
+
"global_rank": self.global_rank,
|
|
241
|
+
"classes_played": [
|
|
242
|
+
item.to_dict(include_raw=include_raw) for item in self.classes_played
|
|
243
|
+
],
|
|
244
|
+
}
|
|
245
|
+
if self.primary_class is not None:
|
|
246
|
+
output["primary_class"] = self.primary_class.to_dict()
|
|
247
|
+
if self.elo is not None:
|
|
248
|
+
output["elo"] = self.elo
|
|
249
|
+
if self.total_matches is not None:
|
|
250
|
+
output["total_matches"] = self.total_matches
|
|
251
|
+
output["total_wins"] = self.total_wins
|
|
252
|
+
output["total_draws"] = self.total_draws
|
|
253
|
+
output["total_losses"] = self.total_losses
|
|
254
|
+
if self.total_win_rate is not None:
|
|
255
|
+
output["total_win_rate"] = self.total_win_rate
|
|
256
|
+
return output
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass(frozen=True)
|
|
260
|
+
class SolareFamilyLayout:
|
|
261
|
+
"""Discovered message geometry; opcode is diagnostic, not trusted input."""
|
|
262
|
+
|
|
263
|
+
role: str
|
|
264
|
+
opcode: int
|
|
265
|
+
message_length: int
|
|
266
|
+
message_count: int
|
|
267
|
+
record_stride: int
|
|
268
|
+
name_offset: int
|
|
269
|
+
rank_offset: int
|
|
270
|
+
class_offset: Optional[int] = None
|
|
271
|
+
detail_layout_id: Optional[str] = None
|
|
272
|
+
|
|
273
|
+
def to_dict(self) -> dict[str, object]:
|
|
274
|
+
output: dict[str, object] = {
|
|
275
|
+
"role": self.role,
|
|
276
|
+
"opcode": f"0x{self.opcode:04X}",
|
|
277
|
+
"message_length": self.message_length,
|
|
278
|
+
"message_count": self.message_count,
|
|
279
|
+
"record_stride": self.record_stride,
|
|
280
|
+
"name_offset": self.name_offset,
|
|
281
|
+
"rank_offset": self.rank_offset,
|
|
282
|
+
}
|
|
283
|
+
if self.class_offset is not None:
|
|
284
|
+
output["class_offset"] = self.class_offset
|
|
285
|
+
if self.detail_layout_id is not None:
|
|
286
|
+
output["detail_layout_id"] = self.detail_layout_id
|
|
287
|
+
return output
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass(frozen=True)
|
|
291
|
+
class SolareCaptureEndpoint(CaptureEndpoint):
|
|
292
|
+
"""Solare-specific public spelling of the shared capture endpoint."""
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataclass(frozen=True)
|
|
296
|
+
class SolareCaptureHealth:
|
|
297
|
+
"""Capture-integrity diagnostics retained with every result."""
|
|
298
|
+
|
|
299
|
+
payload_segments: int = 0
|
|
300
|
+
payload_bytes: int = 0
|
|
301
|
+
synchronized_messages: int = 0
|
|
302
|
+
retained_large_messages: int = 0
|
|
303
|
+
tcp_gap_resets: int = 0
|
|
304
|
+
pcap_received: Optional[int] = None
|
|
305
|
+
pcap_dropped: Optional[int] = None
|
|
306
|
+
pcap_interface_dropped: Optional[int] = None
|
|
307
|
+
capture_buffer_bytes: Optional[int] = None
|
|
308
|
+
saved_packets: int = 0
|
|
309
|
+
candidate_messages_observed: int = 0
|
|
310
|
+
candidate_frames_retained: int = 0
|
|
311
|
+
candidate_bytes_retained: int = 0
|
|
312
|
+
peak_candidate_frames: int = 0
|
|
313
|
+
peak_candidate_bytes: int = 0
|
|
314
|
+
candidate_frames_evicted: int = 0
|
|
315
|
+
candidate_bytes_evicted: int = 0
|
|
316
|
+
candidate_history_rolled_over: bool = False
|
|
317
|
+
packet_queue_peak: int = 0
|
|
318
|
+
packet_queue_overflows: int = 0
|
|
319
|
+
flow_state_evictions: int = 0
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def capture_is_clean(self) -> bool:
|
|
323
|
+
return _capture_is_clean(
|
|
324
|
+
tcp_gap_resets=self.tcp_gap_resets,
|
|
325
|
+
pcap_dropped=self.pcap_dropped,
|
|
326
|
+
pcap_interface_dropped=self.pcap_interface_dropped,
|
|
327
|
+
packet_queue_overflows=self.packet_queue_overflows,
|
|
328
|
+
flow_state_evictions=self.flow_state_evictions,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def to_dict(self) -> dict[str, object]:
|
|
332
|
+
output: dict[str, object] = {
|
|
333
|
+
"payload_segments": self.payload_segments,
|
|
334
|
+
"payload_bytes": self.payload_bytes,
|
|
335
|
+
"synchronized_messages": self.synchronized_messages,
|
|
336
|
+
"retained_large_messages": self.retained_large_messages,
|
|
337
|
+
"tcp_gap_resets": self.tcp_gap_resets,
|
|
338
|
+
"saved_packets": self.saved_packets,
|
|
339
|
+
"candidate_messages_observed": self.candidate_messages_observed,
|
|
340
|
+
"candidate_frames_retained": self.candidate_frames_retained,
|
|
341
|
+
"candidate_bytes_retained": self.candidate_bytes_retained,
|
|
342
|
+
"peak_candidate_frames": self.peak_candidate_frames,
|
|
343
|
+
"peak_candidate_bytes": self.peak_candidate_bytes,
|
|
344
|
+
"candidate_frames_evicted": self.candidate_frames_evicted,
|
|
345
|
+
"candidate_bytes_evicted": self.candidate_bytes_evicted,
|
|
346
|
+
"candidate_history_rolled_over": self.candidate_history_rolled_over,
|
|
347
|
+
"packet_queue_peak": self.packet_queue_peak,
|
|
348
|
+
"packet_queue_overflows": self.packet_queue_overflows,
|
|
349
|
+
"flow_state_evictions": self.flow_state_evictions,
|
|
350
|
+
"capture_is_clean": self.capture_is_clean,
|
|
351
|
+
}
|
|
352
|
+
optional = {
|
|
353
|
+
"pcap_received": self.pcap_received,
|
|
354
|
+
"pcap_dropped": self.pcap_dropped,
|
|
355
|
+
"pcap_interface_dropped": self.pcap_interface_dropped,
|
|
356
|
+
"capture_buffer_bytes": self.capture_buffer_bytes,
|
|
357
|
+
}
|
|
358
|
+
output.update(
|
|
359
|
+
{key: value for key, value in optional.items() if value is not None}
|
|
360
|
+
)
|
|
361
|
+
return output
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@dataclass(frozen=True)
|
|
365
|
+
class SolareEvidence:
|
|
366
|
+
"""Structural and capture evidence supporting a result."""
|
|
367
|
+
|
|
368
|
+
scanned_messages: int = 0
|
|
369
|
+
candidate_families: tuple[tuple[int, int, int], ...] = ()
|
|
370
|
+
ranked_players: int = 0
|
|
371
|
+
class_group_counts: tuple[tuple[int, int], ...] = ()
|
|
372
|
+
overall_players: int = 0
|
|
373
|
+
exact_cross_check: int = 0
|
|
374
|
+
rich_layout: Optional[SolareFamilyLayout] = None
|
|
375
|
+
overall_layout: Optional[SolareFamilyLayout] = None
|
|
376
|
+
health: SolareCaptureHealth = SolareCaptureHealth()
|
|
377
|
+
|
|
378
|
+
def to_dict(self) -> dict[str, Any]:
|
|
379
|
+
output: dict[str, Any] = {
|
|
380
|
+
"scanned_messages": self.scanned_messages,
|
|
381
|
+
"candidate_families": [
|
|
382
|
+
{
|
|
383
|
+
"opcode": f"0x{opcode:04X}",
|
|
384
|
+
"message_length": length,
|
|
385
|
+
"message_count": count,
|
|
386
|
+
}
|
|
387
|
+
for opcode, length, count in self.candidate_families
|
|
388
|
+
],
|
|
389
|
+
"ranked_players": self.ranked_players,
|
|
390
|
+
"class_group_counts": {
|
|
391
|
+
str(code): count for code, count in self.class_group_counts
|
|
392
|
+
},
|
|
393
|
+
"overall_players": self.overall_players,
|
|
394
|
+
"exact_cross_check": self.exact_cross_check,
|
|
395
|
+
"capture_health": self.health.to_dict(),
|
|
396
|
+
}
|
|
397
|
+
if self.rich_layout is not None:
|
|
398
|
+
output["rich_layout"] = self.rich_layout.to_dict()
|
|
399
|
+
if self.overall_layout is not None:
|
|
400
|
+
output["overall_layout"] = self.overall_layout.to_dict()
|
|
401
|
+
return output
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@dataclass(frozen=True)
|
|
405
|
+
class SolareLeaderboardSnapshot:
|
|
406
|
+
"""Atomic, structurally confirmed Arena of Solare leaderboard snapshot."""
|
|
407
|
+
|
|
408
|
+
snapshot_id: str
|
|
409
|
+
observed_at: float
|
|
410
|
+
players: tuple[SolarePlayer, ...]
|
|
411
|
+
overall_top_100: tuple[SolareOverallEntry, ...] = field(
|
|
412
|
+
default=(),
|
|
413
|
+
kw_only=True,
|
|
414
|
+
)
|
|
415
|
+
class_table_capabilities: frozenset[str] = field(
|
|
416
|
+
default=frozenset({"rankings"}),
|
|
417
|
+
kw_only=True,
|
|
418
|
+
)
|
|
419
|
+
overall_capabilities: frozenset[str] = field(
|
|
420
|
+
default=frozenset({"rankings"}),
|
|
421
|
+
kw_only=True,
|
|
422
|
+
)
|
|
423
|
+
schema_version: ClassVar[int] = _SOLARE_SCHEMA_VERSION
|
|
424
|
+
|
|
425
|
+
def __post_init__(self) -> None:
|
|
426
|
+
# Preserve deep immutability even when a caller supplies an ordinary
|
|
427
|
+
# set to the public constructor despite the frozenset annotation.
|
|
428
|
+
object.__setattr__(
|
|
429
|
+
self,
|
|
430
|
+
"class_table_capabilities",
|
|
431
|
+
frozenset(self.class_table_capabilities),
|
|
432
|
+
)
|
|
433
|
+
object.__setattr__(
|
|
434
|
+
self,
|
|
435
|
+
"overall_capabilities",
|
|
436
|
+
frozenset(self.overall_capabilities),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
@property
|
|
440
|
+
def capabilities(self) -> frozenset[str]:
|
|
441
|
+
"""Return capabilities guaranteed independently by both tables."""
|
|
442
|
+
|
|
443
|
+
return self.class_table_capabilities & self.overall_capabilities
|
|
444
|
+
|
|
445
|
+
@property
|
|
446
|
+
def observed_at_iso(self) -> str:
|
|
447
|
+
return (
|
|
448
|
+
dt.datetime.fromtimestamp(self.observed_at, tz=dt.timezone.utc)
|
|
449
|
+
.isoformat(timespec="milliseconds")
|
|
450
|
+
.replace("+00:00", "Z")
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
@property
|
|
454
|
+
def top_100(self) -> tuple[SolarePlayer, ...]:
|
|
455
|
+
"""Return rich-table detail records whose global rank is in 1..100.
|
|
456
|
+
|
|
457
|
+
This compatibility view remains a tuple of :class:`SolarePlayer` and
|
|
458
|
+
is unchanged for the historical exact-overlap captures. It can be
|
|
459
|
+
shorter than 100 when the authoritative overall table contains a
|
|
460
|
+
player outside the per-class top-20 tables; use :attr:`overall_top_100`
|
|
461
|
+
when all authoritative overall rows are required.
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
return tuple(
|
|
465
|
+
player
|
|
466
|
+
for player in sorted(self.players, key=lambda item: item.global_rank)
|
|
467
|
+
if 1 <= player.global_rank <= 100
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def class_leaderboard(self, class_code: int) -> tuple[SolarePlayer, ...]:
|
|
471
|
+
return tuple(
|
|
472
|
+
player
|
|
473
|
+
for player in sorted(self.players, key=lambda item: item.global_rank)
|
|
474
|
+
if player.primary_class.code == class_code
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def get_player(self, name: str) -> Optional[SolarePlayer]:
|
|
478
|
+
return next((player for player in self.players if player.name == name), None)
|
|
479
|
+
|
|
480
|
+
def get_overall_entry(self, name: str) -> Optional[SolareOverallEntry]:
|
|
481
|
+
"""Return the first exact, case-sensitive overall-table name match."""
|
|
482
|
+
|
|
483
|
+
return next(
|
|
484
|
+
(entry for entry in self.overall_top_100 if entry.name == name),
|
|
485
|
+
None,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def to_dict(self, *, include_raw: bool = False) -> dict[str, Any]:
|
|
489
|
+
return {
|
|
490
|
+
"schema_version": _SOLARE_SCHEMA_VERSION,
|
|
491
|
+
"snapshot_id": self.snapshot_id,
|
|
492
|
+
"observed_at": self.observed_at,
|
|
493
|
+
"observed_at_iso": self.observed_at_iso,
|
|
494
|
+
"capabilities": sorted(self.capabilities),
|
|
495
|
+
"class_table_capabilities": sorted(self.class_table_capabilities),
|
|
496
|
+
"overall_capabilities": sorted(self.overall_capabilities),
|
|
497
|
+
"complete": True,
|
|
498
|
+
"record_count": len(self.players),
|
|
499
|
+
"players": [
|
|
500
|
+
player.to_dict(include_raw=include_raw) for player in self.players
|
|
501
|
+
],
|
|
502
|
+
"overall_record_count": len(self.overall_top_100),
|
|
503
|
+
"overall_top_100": [
|
|
504
|
+
entry.to_dict(include_raw=include_raw) for entry in self.overall_top_100
|
|
505
|
+
],
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
def to_json(self, *, include_raw: bool = False, indent: Optional[int] = 2) -> str:
|
|
509
|
+
return json.dumps(
|
|
510
|
+
self.to_dict(include_raw=include_raw),
|
|
511
|
+
ensure_ascii=False,
|
|
512
|
+
indent=indent,
|
|
513
|
+
sort_keys=True,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@dataclass(frozen=True)
|
|
518
|
+
class SolareCaptureResult:
|
|
519
|
+
"""Final outcome and sole evidence owner; only ``complete`` has a snapshot."""
|
|
520
|
+
|
|
521
|
+
status: SolareDetectionStatus
|
|
522
|
+
evidence: SolareEvidence
|
|
523
|
+
snapshot: Optional[SolareLeaderboardSnapshot] = None
|
|
524
|
+
message: Optional[str] = None
|
|
525
|
+
schema_version: ClassVar[int] = _SOLARE_SCHEMA_VERSION
|
|
526
|
+
|
|
527
|
+
def __post_init__(self) -> None:
|
|
528
|
+
if self.status is SolareDetectionStatus.COMPLETE and self.snapshot is None:
|
|
529
|
+
raise ValueError("a complete Solare result requires a snapshot")
|
|
530
|
+
if (
|
|
531
|
+
self.status is not SolareDetectionStatus.COMPLETE
|
|
532
|
+
and self.snapshot is not None
|
|
533
|
+
):
|
|
534
|
+
raise ValueError("only a complete Solare result may contain a snapshot")
|
|
535
|
+
|
|
536
|
+
@property
|
|
537
|
+
def complete(self) -> bool:
|
|
538
|
+
return self.status is SolareDetectionStatus.COMPLETE
|
|
539
|
+
|
|
540
|
+
def to_dict(self, *, include_raw: bool = False) -> dict[str, Any]:
|
|
541
|
+
output: dict[str, Any] = {
|
|
542
|
+
"schema_version": _SOLARE_SCHEMA_VERSION,
|
|
543
|
+
"status": self.status.value,
|
|
544
|
+
"complete": self.complete,
|
|
545
|
+
"evidence": self.evidence.to_dict(),
|
|
546
|
+
}
|
|
547
|
+
if self.message is not None:
|
|
548
|
+
output["message"] = self.message
|
|
549
|
+
if self.snapshot is not None:
|
|
550
|
+
output["snapshot"] = self.snapshot.to_dict(include_raw=include_raw)
|
|
551
|
+
return output
|
|
552
|
+
|
|
553
|
+
def to_json(self, *, include_raw: bool = False, indent: Optional[int] = 2) -> str:
|
|
554
|
+
return json.dumps(
|
|
555
|
+
self.to_dict(include_raw=include_raw),
|
|
556
|
+
ensure_ascii=False,
|
|
557
|
+
indent=indent,
|
|
558
|
+
sort_keys=True,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@dataclass(frozen=True)
|
|
563
|
+
class SolareUpdate:
|
|
564
|
+
"""One structured live-capture progress update."""
|
|
565
|
+
|
|
566
|
+
kind: SolareUpdateKind
|
|
567
|
+
message: str
|
|
568
|
+
ranked_players: int = 0
|
|
569
|
+
overall_players: int = 0
|
|
570
|
+
exact_cross_check: int = 0
|
|
571
|
+
result: Optional[SolareCaptureResult] = None
|
|
572
|
+
|
|
573
|
+
def to_dict(self, *, include_raw: bool = False) -> dict[str, Any]:
|
|
574
|
+
output: dict[str, Any] = {
|
|
575
|
+
"kind": self.kind.value,
|
|
576
|
+
"message": self.message,
|
|
577
|
+
"ranked_players": self.ranked_players,
|
|
578
|
+
"overall_players": self.overall_players,
|
|
579
|
+
"exact_cross_check": self.exact_cross_check,
|
|
580
|
+
}
|
|
581
|
+
if self.result is not None:
|
|
582
|
+
output["result"] = self.result.to_dict(include_raw=include_raw)
|
|
583
|
+
return output
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def solare_snapshot_id(
|
|
587
|
+
players: Iterable[SolarePlayer],
|
|
588
|
+
*,
|
|
589
|
+
overall_top_100: Iterable[SolareOverallEntry] = (),
|
|
590
|
+
) -> str:
|
|
591
|
+
"""Return a deterministic semantic identifier, excluding opaque raw blobs.
|
|
592
|
+
|
|
593
|
+
Exact-overlap snapshots retain the original rich-row-only digest only when
|
|
594
|
+
every available comparable overall-table detail agrees with its independent
|
|
595
|
+
class-table row. A genuinely divergent overall table uses a versioned
|
|
596
|
+
envelope containing both table identities and their non-raw semantics.
|
|
597
|
+
"""
|
|
598
|
+
|
|
599
|
+
player_rows = tuple(players)
|
|
600
|
+
rows = []
|
|
601
|
+
for player in sorted(
|
|
602
|
+
player_rows,
|
|
603
|
+
key=lambda item: (item.global_rank, item.name),
|
|
604
|
+
):
|
|
605
|
+
rows.append(
|
|
606
|
+
{
|
|
607
|
+
"name": player.name,
|
|
608
|
+
"rank": player.global_rank,
|
|
609
|
+
"class": player.primary_class.code,
|
|
610
|
+
"elo": player.elo,
|
|
611
|
+
"classes": [
|
|
612
|
+
{
|
|
613
|
+
"slot": item.slot,
|
|
614
|
+
"class": item.player_class.code,
|
|
615
|
+
"spec": (
|
|
616
|
+
item.specialization.code if item.specialization else None
|
|
617
|
+
),
|
|
618
|
+
"matches": item.matches,
|
|
619
|
+
"wins": item.wins,
|
|
620
|
+
"draws": item.draws,
|
|
621
|
+
"losses": item.losses,
|
|
622
|
+
"history": list(item.recent_results_raw),
|
|
623
|
+
}
|
|
624
|
+
for item in player.classes_played
|
|
625
|
+
],
|
|
626
|
+
}
|
|
627
|
+
)
|
|
628
|
+
overall_entries = tuple(
|
|
629
|
+
sorted(
|
|
630
|
+
overall_top_100,
|
|
631
|
+
key=lambda item: (item.global_rank, item.name),
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
overall_rows = [
|
|
635
|
+
{
|
|
636
|
+
"name": entry.name,
|
|
637
|
+
"rank": entry.global_rank,
|
|
638
|
+
"class": (
|
|
639
|
+
entry.primary_class.code if entry.primary_class is not None else None
|
|
640
|
+
),
|
|
641
|
+
"elo": entry.elo,
|
|
642
|
+
"total_matches": entry.total_matches,
|
|
643
|
+
"total_wins": entry.total_wins,
|
|
644
|
+
"total_draws": entry.total_draws,
|
|
645
|
+
"total_losses": entry.total_losses,
|
|
646
|
+
"classes": [
|
|
647
|
+
_class_performance_semantics(item, include_primary=True)
|
|
648
|
+
for item in entry.classes_played
|
|
649
|
+
],
|
|
650
|
+
}
|
|
651
|
+
for entry in overall_entries
|
|
652
|
+
]
|
|
653
|
+
rich_overall_players = tuple(
|
|
654
|
+
sorted(
|
|
655
|
+
(player for player in player_rows if 1 <= player.global_rank <= 100),
|
|
656
|
+
key=lambda item: (item.global_rank, item.name),
|
|
657
|
+
)
|
|
658
|
+
)
|
|
659
|
+
exact_semantic_overlap = len(overall_entries) == len(rich_overall_players) and all(
|
|
660
|
+
entry.global_rank == player.global_rank
|
|
661
|
+
and entry.name == player.name
|
|
662
|
+
and _available_overall_details_agree(entry, player)
|
|
663
|
+
for entry, player in zip(overall_entries, rich_overall_players)
|
|
664
|
+
)
|
|
665
|
+
if not overall_rows or exact_semantic_overlap:
|
|
666
|
+
semantic_payload: object = rows
|
|
667
|
+
else:
|
|
668
|
+
semantic_payload = {
|
|
669
|
+
"format": "solare-snapshot-v4-independent-overall-aggregates",
|
|
670
|
+
"overall_top_100": overall_rows,
|
|
671
|
+
"players": rows,
|
|
672
|
+
}
|
|
673
|
+
payload = json.dumps(
|
|
674
|
+
semantic_payload,
|
|
675
|
+
sort_keys=True,
|
|
676
|
+
separators=(",", ":"),
|
|
677
|
+
).encode("utf-8")
|
|
678
|
+
return "sha256:" + hashlib.sha256(payload).hexdigest()
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _class_performance_semantics(
|
|
682
|
+
performance: SolareClassPerformance,
|
|
683
|
+
*,
|
|
684
|
+
include_primary: bool = False,
|
|
685
|
+
) -> dict[str, object]:
|
|
686
|
+
"""Return non-opaque class-slot semantics used by snapshot identity."""
|
|
687
|
+
|
|
688
|
+
output: dict[str, object] = {
|
|
689
|
+
"slot": performance.slot,
|
|
690
|
+
"class": performance.player_class.code,
|
|
691
|
+
"spec": (
|
|
692
|
+
performance.specialization.code
|
|
693
|
+
if performance.specialization is not None
|
|
694
|
+
else None
|
|
695
|
+
),
|
|
696
|
+
"matches": performance.matches,
|
|
697
|
+
"wins": performance.wins,
|
|
698
|
+
"draws": performance.draws,
|
|
699
|
+
"losses": performance.losses,
|
|
700
|
+
"history": list(performance.recent_results_raw),
|
|
701
|
+
}
|
|
702
|
+
if include_primary:
|
|
703
|
+
output["primary"] = performance.primary
|
|
704
|
+
if performance.recent_results_wire_text is not None:
|
|
705
|
+
output["history_wire_text"] = performance.recent_results_wire_text
|
|
706
|
+
return output
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _available_overall_details_agree(
|
|
710
|
+
overall: SolareOverallEntry,
|
|
711
|
+
class_player: SolarePlayer,
|
|
712
|
+
) -> bool:
|
|
713
|
+
"""Compare only non-raw details actually available on an overall row."""
|
|
714
|
+
|
|
715
|
+
if overall.elo is not None and overall.elo != class_player.elo:
|
|
716
|
+
return False
|
|
717
|
+
if not _available_overall_totals_agree(overall, class_player):
|
|
718
|
+
return False
|
|
719
|
+
if not overall.classes_played:
|
|
720
|
+
return True
|
|
721
|
+
if len(overall.classes_played) != len(class_player.classes_played):
|
|
722
|
+
return False
|
|
723
|
+
if (
|
|
724
|
+
overall.primary_class is not None
|
|
725
|
+
and overall.primary_class.code != class_player.primary_class.code
|
|
726
|
+
):
|
|
727
|
+
return False
|
|
728
|
+
|
|
729
|
+
for overall_slot, class_slot in zip(
|
|
730
|
+
overall.classes_played,
|
|
731
|
+
class_player.classes_played,
|
|
732
|
+
):
|
|
733
|
+
if (
|
|
734
|
+
overall_slot.slot != class_slot.slot
|
|
735
|
+
or overall_slot.primary != class_slot.primary
|
|
736
|
+
or overall_slot.player_class.code != class_slot.player_class.code
|
|
737
|
+
):
|
|
738
|
+
return False
|
|
739
|
+
if overall_slot.specialization is not None and (
|
|
740
|
+
class_slot.specialization is None
|
|
741
|
+
or overall_slot.specialization.code != class_slot.specialization.code
|
|
742
|
+
):
|
|
743
|
+
return False
|
|
744
|
+
for field_name in ("matches", "wins", "draws", "losses"):
|
|
745
|
+
overall_value = getattr(overall_slot, field_name)
|
|
746
|
+
if overall_value is not None and overall_value != getattr(
|
|
747
|
+
class_slot, field_name
|
|
748
|
+
):
|
|
749
|
+
return False
|
|
750
|
+
if (
|
|
751
|
+
overall_slot.recent_results_raw
|
|
752
|
+
and overall_slot.recent_results_raw != class_slot.recent_results_raw
|
|
753
|
+
):
|
|
754
|
+
return False
|
|
755
|
+
if (
|
|
756
|
+
overall_slot.recent_results_wire_text is not None
|
|
757
|
+
and overall_slot.recent_results_wire_text
|
|
758
|
+
!= class_slot.recent_results_wire_text
|
|
759
|
+
):
|
|
760
|
+
return False
|
|
761
|
+
return True
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _available_overall_totals_agree(
|
|
765
|
+
overall: SolareOverallEntry,
|
|
766
|
+
class_player: SolarePlayer,
|
|
767
|
+
) -> bool:
|
|
768
|
+
"""Compare available overall aggregates with complete class-slot sums."""
|
|
769
|
+
|
|
770
|
+
overall_values = (
|
|
771
|
+
overall.total_wins,
|
|
772
|
+
overall.total_draws,
|
|
773
|
+
overall.total_losses,
|
|
774
|
+
)
|
|
775
|
+
if not any(value is not None for value in overall_values):
|
|
776
|
+
return True
|
|
777
|
+
if not class_player.classes_played:
|
|
778
|
+
return False
|
|
779
|
+
|
|
780
|
+
for field_name, overall_value in zip(
|
|
781
|
+
("wins", "draws", "losses"),
|
|
782
|
+
overall_values,
|
|
783
|
+
):
|
|
784
|
+
if overall_value is None:
|
|
785
|
+
continue
|
|
786
|
+
class_values = tuple(
|
|
787
|
+
getattr(performance, field_name)
|
|
788
|
+
for performance in class_player.classes_played
|
|
789
|
+
)
|
|
790
|
+
if any(value is None for value in class_values):
|
|
791
|
+
return False
|
|
792
|
+
if sum(value for value in class_values if value is not None) != overall_value:
|
|
793
|
+
return False
|
|
794
|
+
|
|
795
|
+
if overall.total_matches is not None:
|
|
796
|
+
class_matches = tuple(
|
|
797
|
+
performance.matches for performance in class_player.classes_played
|
|
798
|
+
)
|
|
799
|
+
if any(value is None for value in class_matches):
|
|
800
|
+
return False
|
|
801
|
+
if (
|
|
802
|
+
sum(value for value in class_matches if value is not None)
|
|
803
|
+
!= overall.total_matches
|
|
804
|
+
):
|
|
805
|
+
return False
|
|
806
|
+
return True
|