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.
Files changed (48) hide show
  1. bdo_toolkit/__init__.py +87 -0
  2. bdo_toolkit/_async_sessions.py +651 -0
  3. bdo_toolkit/_capture_backend.py +194 -0
  4. bdo_toolkit/_capture_options.py +68 -0
  5. bdo_toolkit/_capture_runtime.py +626 -0
  6. bdo_toolkit/_deposit_origin.py +1599 -0
  7. bdo_toolkit/_engine.py +327 -0
  8. bdo_toolkit/_framing.py +904 -0
  9. bdo_toolkit/_profile_runtime.py +157 -0
  10. bdo_toolkit/_protocol.py +386 -0
  11. bdo_toolkit/_reassembly.py +654 -0
  12. bdo_toolkit/_specs.py +285 -0
  13. bdo_toolkit/_storage_destination_validation.py +167 -0
  14. bdo_toolkit/_storage_hydration.py +241 -0
  15. bdo_toolkit/_version.py +3 -0
  16. bdo_toolkit/calibration.py +3223 -0
  17. bdo_toolkit/capture.py +1713 -0
  18. bdo_toolkit/character_state.py +3506 -0
  19. bdo_toolkit/cli.py +948 -0
  20. bdo_toolkit/diagnostics.py +51 -0
  21. bdo_toolkit/events.py +214 -0
  22. bdo_toolkit/filters.py +105 -0
  23. bdo_toolkit/item_state.py +48 -0
  24. bdo_toolkit/origin_learning.py +779 -0
  25. bdo_toolkit/profiles.py +370 -0
  26. bdo_toolkit/py.typed +1 -0
  27. bdo_toolkit/remote_profiles.py +358 -0
  28. bdo_toolkit/solare/__init__.py +50 -0
  29. bdo_toolkit/solare/_constants.py +94 -0
  30. bdo_toolkit/solare/_detail_learning.py +1437 -0
  31. bdo_toolkit/solare/_details.py +796 -0
  32. bdo_toolkit/solare/_discovery.py +1212 -0
  33. bdo_toolkit/solare/_live_tracker.py +472 -0
  34. bdo_toolkit/solare/_replay_capture.py +182 -0
  35. bdo_toolkit/solare/_result.py +441 -0
  36. bdo_toolkit/solare/_scanner.py +203 -0
  37. bdo_toolkit/solare/_validation.py +11 -0
  38. bdo_toolkit/solare/async_session.py +444 -0
  39. bdo_toolkit/solare/models.py +806 -0
  40. bdo_toolkit/solare/replay.py +62 -0
  41. bdo_toolkit/solare/session.py +1051 -0
  42. bdo_toolkit/writers.py +30 -0
  43. bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
  44. bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
  45. bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
  46. bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
  47. bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. 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