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,51 @@
1
+ """Out-of-band protocol compatibility diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from typing import Optional
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class DecoderDiagnostic:
11
+ """One deduplicated decoder-compatibility warning.
12
+
13
+ Diagnostics are deliberately not :class:`BDOEvent` objects and therefore
14
+ bypass event filters. A storage-ID-filtered production app can learn that
15
+ its profile stopped resolving destinations even when no matching event can
16
+ be delivered.
17
+ """
18
+
19
+ code: str
20
+ message: str
21
+ severity: str = "warning"
22
+ subsystem: str = "storage"
23
+ opcode: Optional[int] = None
24
+ requested_storage_ids: tuple[int, ...] = ()
25
+
26
+ def to_dict(self) -> dict[str, object]:
27
+ return asdict(self)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class DecoderHealth:
32
+ """Snapshot of storage-decoder compatibility, separate from packet loss."""
33
+
34
+ storage_status: str = "not_observed"
35
+ storage_messages_observed: int = 0
36
+ storage_messages_decoded: int = 0
37
+ storage_records_decoded: int = 0
38
+ storage_destination_failures: int = 0
39
+ storage_geometry_failures: int = 0
40
+ diagnostics_emitted: int = 0
41
+
42
+ @property
43
+ def storage_is_compatible(self) -> Optional[bool]:
44
+ if self.storage_status == "not_observed":
45
+ return None
46
+ return self.storage_status == "compatible"
47
+
48
+ def to_dict(self) -> dict[str, object]:
49
+ output = asdict(self)
50
+ output["storage_is_compatible"] = self.storage_is_compatible
51
+ return output
bdo_toolkit/events.py ADDED
@@ -0,0 +1,214 @@
1
+ """Stable event objects emitted by the toolkit.
2
+
3
+ The packet protocol is still moving, so these models intentionally keep an
4
+ ``extra`` mapping for newly discovered fields that should not require a schema
5
+ break on day one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import datetime as dt
12
+ from dataclasses import dataclass, field
13
+ from types import MappingProxyType
14
+ from typing import Any, Mapping, Optional
15
+
16
+
17
+ def _freeze_value(value: Any) -> Any:
18
+ if isinstance(value, Mapping):
19
+ return MappingProxyType(
20
+ {key: _freeze_value(item) for key, item in value.items()}
21
+ )
22
+ if isinstance(value, (list, tuple)):
23
+ return tuple(_freeze_value(item) for item in value)
24
+ if isinstance(value, (set, frozenset)):
25
+ return frozenset(_freeze_value(item) for item in value)
26
+ return value
27
+
28
+
29
+ def _thaw_value(value: Any) -> Any:
30
+ if isinstance(value, Mapping):
31
+ return {key: _thaw_value(item) for key, item in value.items()}
32
+ if isinstance(value, tuple):
33
+ return [_thaw_value(item) for item in value]
34
+ if isinstance(value, frozenset):
35
+ return [_thaw_value(item) for item in value]
36
+ return value
37
+
38
+
39
+ def _hashable_value(value: Any) -> Any:
40
+ if isinstance(value, Mapping):
41
+ return frozenset(
42
+ (key, _hashable_value(item)) for key, item in value.items()
43
+ )
44
+ if isinstance(value, (tuple, list)):
45
+ return tuple(_hashable_value(item) for item in value)
46
+ if isinstance(value, (set, frozenset)):
47
+ return frozenset(_hashable_value(item) for item in value)
48
+ return value
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Flow:
53
+ """Network flow for a server-to-client packet stream."""
54
+
55
+ source_ip: str
56
+ source_port: int
57
+ destination_ip: str
58
+ destination_port: int
59
+
60
+ def __str__(self) -> str:
61
+ return (
62
+ f"{self.source_ip}:{self.source_port} -> "
63
+ f"{self.destination_ip}:{self.destination_port}"
64
+ )
65
+
66
+ def to_dict(self) -> dict[str, object]:
67
+ return dataclasses.asdict(self)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class BDOEvent:
72
+ """One decoded gameplay-relevant packet event."""
73
+
74
+ event_type: str
75
+ timestamp: float
76
+ flow: Flow
77
+ item_id: int
78
+ quantity: int
79
+ # Known semantic origin, producing system, or event context. Storage
80
+ # endpoint identity belongs exclusively to storage_id/name fields.
81
+ source: Optional[str] = None
82
+ raw_context: Optional[str] = None
83
+ opcode: Optional[int] = None
84
+ message_length: Optional[int] = None
85
+ base_item_id: Optional[int] = None
86
+ enhancement_level: Optional[int] = None
87
+ enhancement: Optional[str] = None
88
+ inventory_slot: Optional[int] = None
89
+ item_instance: Optional[str] = None
90
+ # Opaque per-record instance observed to remain identical while matching
91
+ # item/quantity records entered different town storages. It is not the
92
+ # destination identity; ``storage_id`` carries that key.
93
+ storage_instance: Optional[str] = None
94
+ record_index: Optional[int] = None
95
+ record_count: Optional[int] = None
96
+ record_offset: Optional[int] = None
97
+ confidence: Optional[str] = None
98
+ extra: Mapping[str, Any] = field(default_factory=dict)
99
+ storage_id: Optional[int] = None
100
+ storage_name: Optional[str] = None
101
+ storage_name_confidence: Optional[str] = None
102
+ # Internal TCP connection-lifetime identity. This must not affect the
103
+ # public event schema, equality, or hashing; stateful classifiers use it to
104
+ # keep reconnects that reuse one four-tuple isolated.
105
+ _flow_generation: int = field(
106
+ default=0,
107
+ kw_only=True,
108
+ repr=False,
109
+ compare=False,
110
+ hash=False,
111
+ )
112
+
113
+ def __post_init__(self) -> None:
114
+ object.__setattr__(self, "extra", _freeze_value(self.extra))
115
+
116
+ @property
117
+ def timestamp_text(self) -> str:
118
+ return dt.datetime.fromtimestamp(self.timestamp).strftime("%H:%M:%S.%f")[:-3]
119
+
120
+ @property
121
+ def timestamp_iso(self) -> str:
122
+ """Deterministic UTC ISO-8601 timestamp for serialization."""
123
+ return (
124
+ dt.datetime.fromtimestamp(self.timestamp, tz=dt.timezone.utc)
125
+ .isoformat(timespec="milliseconds")
126
+ .replace("+00:00", "Z")
127
+ )
128
+
129
+ def to_dict(self) -> dict[str, Any]:
130
+ output: dict[str, Any] = {
131
+ "event_type": self.event_type,
132
+ "timestamp": self.timestamp,
133
+ "timestamp_iso": self.timestamp_iso,
134
+ "flow": self.flow.to_dict(),
135
+ "item_id": self.item_id,
136
+ "quantity": self.quantity,
137
+ }
138
+ optional = {
139
+ "source": self.source,
140
+ "raw_context": self.raw_context,
141
+ "opcode": f"0x{self.opcode:04X}" if self.opcode is not None else None,
142
+ "message_length": self.message_length,
143
+ "base_item_id": self.base_item_id,
144
+ "enhancement_level": self.enhancement_level,
145
+ "enhancement": self.enhancement,
146
+ "inventory_slot": (
147
+ f"0x{self.inventory_slot:02x}"
148
+ if self.inventory_slot is not None
149
+ else None
150
+ ),
151
+ "item_instance": self.item_instance,
152
+ "storage_instance": self.storage_instance,
153
+ "storage_id": self.storage_id,
154
+ "storage_name": self.storage_name,
155
+ "storage_name_confidence": self.storage_name_confidence,
156
+ "record_index": self.record_index,
157
+ "record_count": self.record_count,
158
+ "record_offset": self.record_offset,
159
+ "confidence": self.confidence,
160
+ }
161
+ for key, value in optional.items():
162
+ if value is not None:
163
+ output[key] = value
164
+ if self.extra:
165
+ output["extra"] = _thaw_value(self.extra)
166
+ return output
167
+
168
+ def __hash__(self) -> int:
169
+ values = tuple(
170
+ getattr(self, field_info.name)
171
+ for field_info in dataclasses.fields(self)
172
+ if field_info.name != "extra"
173
+ and field_info.compare
174
+ and field_info.hash is not False
175
+ )
176
+ return hash((values, _hashable_value(self.extra)))
177
+
178
+ def format_human(self) -> str:
179
+ parts = [f"[{self.timestamp_text}]", self.event_type.upper()]
180
+ is_storage = self.event_type in {
181
+ "storage_delta",
182
+ "storage_snapshot",
183
+ "storage_record",
184
+ }
185
+ if self.source is not None:
186
+ parts.append(f"source={self.source!r}")
187
+ if is_storage:
188
+ if self.storage_name is not None:
189
+ parts.append(f"destination={self.storage_name!r}")
190
+ elif self.storage_id is not None:
191
+ parts.append(
192
+ f"destination='UNKNOWN_STORAGE(0x{self.storage_id:08x})'"
193
+ )
194
+ parts.extend((f"item_id={self.item_id}", f"quantity={self.quantity}"))
195
+ if self.base_item_id is not None:
196
+ parts.append(f"base_item_id={self.base_item_id}")
197
+ if self.enhancement_level is not None:
198
+ parts.append(f"enhancement_level={self.enhancement_level}")
199
+ if self.enhancement is not None:
200
+ parts.append(f"enhancement={self.enhancement}")
201
+ if self.record_index is not None and self.record_count is not None:
202
+ parts.append(f"record_index={self.record_index}/{self.record_count}")
203
+ if self.item_instance is not None:
204
+ parts.append(f"item_instance={self.item_instance}")
205
+ if self.storage_instance is not None:
206
+ parts.append(f"storage_instance={self.storage_instance}")
207
+ if self.storage_id is not None:
208
+ parts.append(f"storage_id=0x{self.storage_id:08x}")
209
+ if self.storage_name_confidence is not None:
210
+ parts.append(
211
+ f"storage_name_confidence={self.storage_name_confidence}"
212
+ )
213
+ return " ".join(parts)
214
+
bdo_toolkit/filters.py ADDED
@@ -0,0 +1,105 @@
1
+ """Composable event filters for app-facing streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Iterable, Optional, TypeVar
7
+
8
+ from .events import BDOEvent
9
+
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ _ACTIVITY_EVENT_TYPES = frozenset(
15
+ {"loot_preview", "item_received", "storage_delta"}
16
+ )
17
+ _SNAPSHOT_RECORD_EVENT_TYPES = frozenset(
18
+ {"inventory_snapshot", "storage_snapshot"}
19
+ )
20
+
21
+
22
+ def _freeze(values: Optional[Iterable[T]]) -> Optional[frozenset[T]]:
23
+ if values is None:
24
+ return None
25
+ if isinstance(values, (str, bytes)):
26
+ raise TypeError("filter values must be an iterable of values, not a string")
27
+ return frozenset(values)
28
+
29
+
30
+ @dataclass(frozen=True, init=False)
31
+ class EventFilter:
32
+ """Filter completed events before yielding them to an application.
33
+
34
+ Constructing ``EventFilter()`` directly remains an unconstrained filter.
35
+ The named presets make common delivery policies explicit without changing
36
+ the meaning of any caller-supplied filter.
37
+ """
38
+
39
+ event_types: Optional[frozenset[str]] = None
40
+ # Exact semantic origin, producing-system, or event-context labels.
41
+ sources: Optional[frozenset[str]] = None
42
+ # Authoritative numeric storage destination identities.
43
+ storage_ids: Optional[frozenset[int]] = None
44
+ item_ids: Optional[frozenset[int]] = None
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ event_types: Optional[Iterable[str]] = None,
50
+ sources: Optional[Iterable[str]] = None,
51
+ storage_ids: Optional[Iterable[int]] = None,
52
+ item_ids: Optional[Iterable[int]] = None,
53
+ ) -> None:
54
+ # Normalize at the public constructor boundary so callers can use
55
+ # ordinary sets/lists while the frozen object remains truly immutable.
56
+ object.__setattr__(self, "event_types", _freeze(event_types))
57
+ object.__setattr__(self, "sources", _freeze(sources))
58
+ object.__setattr__(self, "storage_ids", _freeze(storage_ids))
59
+ object.__setattr__(self, "item_ids", _freeze(item_ids))
60
+
61
+ @classmethod
62
+ def from_values(
63
+ cls,
64
+ *,
65
+ event_types: Optional[Iterable[str]] = None,
66
+ sources: Optional[Iterable[str]] = None,
67
+ storage_ids: Optional[Iterable[int]] = None,
68
+ item_ids: Optional[Iterable[int]] = None,
69
+ ) -> "EventFilter":
70
+ return cls(
71
+ event_types=event_types,
72
+ sources=sources,
73
+ storage_ids=storage_ids,
74
+ item_ids=item_ids,
75
+ )
76
+
77
+ @classmethod
78
+ def activity(cls) -> "EventFilter":
79
+ """Ordinary live events, excluding hydration and neutral diagnostics."""
80
+
81
+ return cls(event_types=_ACTIVITY_EVENT_TYPES)
82
+
83
+ @classmethod
84
+ def snapshot_records(cls) -> "EventFilter":
85
+ """Per-record inventory and storage character-load hydration."""
86
+
87
+ return cls(event_types=_SNAPSHOT_RECORD_EVENT_TYPES)
88
+
89
+ @classmethod
90
+ def all(cls) -> "EventFilter":
91
+ """Every completed decoded event, equivalent to ``EventFilter()``."""
92
+
93
+ return cls()
94
+
95
+ def allows(self, event: BDOEvent) -> bool:
96
+ if self.event_types is not None and event.event_type not in self.event_types:
97
+ return False
98
+ if self.sources is not None and event.source not in self.sources:
99
+ return False
100
+ if self.storage_ids is not None and event.storage_id not in self.storage_ids:
101
+ return False
102
+ if self.item_ids is not None and event.item_id not in self.item_ids:
103
+ return False
104
+ return True
105
+
@@ -0,0 +1,48 @@
1
+ """Canonical experimental facade for aggregate inventory and storage state.
2
+
3
+ The implementation remains in :mod:`bdo_toolkit.character_state` so existing
4
+ imports continue to work. These aliases provide item-domain naming without a
5
+ wholesale move or package-root stability promise.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .character_state import (
11
+ CharacterLoadSession,
12
+ CharacterStateSnapshot as ItemStateSnapshot,
13
+ InventoryContainerSummary,
14
+ InventoryHydrationDiagnostics,
15
+ InventorySnapshotSummary,
16
+ ItemStateCaptureLimitError,
17
+ ItemStateCaptureLimits,
18
+ ItemStateCoverage,
19
+ ItemStateDiagnostics,
20
+ ItemStateProvenance,
21
+ SnapshotItem,
22
+ StorageContents,
23
+ StorageDestinationDiagnostics,
24
+ StorageHydrationDiagnostics,
25
+ StorageSnapshotSummary,
26
+ analyze_character_load_pcap as analyze_item_state_pcap,
27
+ format_character_state as format_item_state,
28
+ )
29
+
30
+ __all__ = [
31
+ "CharacterLoadSession",
32
+ "InventoryContainerSummary",
33
+ "InventoryHydrationDiagnostics",
34
+ "InventorySnapshotSummary",
35
+ "ItemStateCaptureLimitError",
36
+ "ItemStateCaptureLimits",
37
+ "ItemStateCoverage",
38
+ "ItemStateDiagnostics",
39
+ "ItemStateProvenance",
40
+ "ItemStateSnapshot",
41
+ "SnapshotItem",
42
+ "StorageContents",
43
+ "StorageDestinationDiagnostics",
44
+ "StorageHydrationDiagnostics",
45
+ "StorageSnapshotSummary",
46
+ "analyze_item_state_pcap",
47
+ "format_item_state",
48
+ ]