manasight_parser/events.rs
1//! Public event type enums and structs for parsed MTG Arena log events.
2//!
3//! These types represent the structured output of the parser and form the
4//! contract between the parser library and its consumers. Each event
5//! corresponds to a category in the
6//! [Event-to-Class Mapping][spec].
7//!
8//! [spec]: https://github.com/manasight/manasight-docs/blob/main/docs/requirements/feature-specs/log-file-parser.md#event-to-class-mapping
9
10use chrono::{DateTime, NaiveDateTime, Utc};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14// ---------------------------------------------------------------------------
15// Serde helper modules
16// ---------------------------------------------------------------------------
17
18/// Serialize `Vec<u8>` as a base64 string (RFC 4648 standard alphabet).
19mod base64_serde {
20 use base64::prelude::{Engine as _, BASE64_STANDARD};
21 use serde::{Deserialize, Deserializer, Serializer};
22
23 pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
24 where
25 S: Serializer,
26 {
27 serializer.serialize_str(&BASE64_STANDARD.encode(bytes))
28 }
29
30 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
31 where
32 D: Deserializer<'de>,
33 {
34 let s = String::deserialize(deserializer)?;
35 BASE64_STANDARD.decode(&s).map_err(serde::de::Error::custom)
36 }
37}
38
39/// Serialize `[u8; 32]` as a 64-character lowercase hex string.
40///
41/// Serialize-only: `EventMetadata` has a custom `Deserialize` impl that
42/// ignores `raw_bytes_hash` (it is always recomputed from `raw_bytes`), so
43/// no `deserialize` function is needed. If `#[derive(Deserialize)]` is
44/// ever added to `EventMetadata`, add a `deserialize` function here.
45mod hex_serde {
46 use serde::Serializer;
47 use std::fmt::Write as _;
48
49 pub fn serialize<S>(bytes: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
50 where
51 S: Serializer,
52 {
53 let hex = bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
54 // write! to String is infallible.
55 let _ = write!(acc, "{b:02x}");
56 acc
57 });
58 serializer.serialize_str(&hex)
59 }
60}
61
62// ---------------------------------------------------------------------------
63// Macros
64// ---------------------------------------------------------------------------
65
66/// Generates a category-specific event struct with `metadata` and `payload`
67/// fields plus public accessor methods.
68///
69/// When a new event category is added, create a new invocation of this
70/// macro rather than writing the struct and impl by hand.
71macro_rules! define_event {
72 (
73 $(#[$attr:meta])*
74 $name:ident
75 ) => {
76 $(#[$attr])*
77 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78 pub struct $name {
79 /// Shared event metadata (timestamp, raw bytes, hash).
80 metadata: EventMetadata,
81 /// The parsed JSON payload.
82 payload: serde_json::Value,
83 }
84
85 impl $name {
86 /// Constructs a new event with the given metadata and payload.
87 pub fn new(
88 metadata: EventMetadata,
89 payload: serde_json::Value,
90 ) -> Self {
91 Self { metadata, payload }
92 }
93
94 /// Returns the shared event metadata.
95 pub fn metadata(&self) -> &EventMetadata {
96 &self.metadata
97 }
98
99 /// Returns the parsed JSON payload.
100 pub fn payload(&self) -> &serde_json::Value {
101 &self.payload
102 }
103 }
104 };
105}
106
107/// Dispatches a field accessor across all `GameEvent` variants.
108///
109/// When a new variant is added to `GameEvent`, add it here too.
110/// `$method` must be a `&self` no-arg method present on all inner types.
111macro_rules! delegate_to_inner {
112 ($self:expr, $method:ident) => {
113 match $self {
114 Self::GameState(e) => e.$method(),
115 Self::ClientAction(e) => e.$method(),
116 Self::MatchState(e) => e.$method(),
117 Self::DraftBot(e) => e.$method(),
118 Self::DraftHuman(e) => e.$method(),
119 Self::DraftComplete(e) => e.$method(),
120 Self::EventLifecycle(e) => e.$method(),
121 Self::Session(e) => e.$method(),
122 Self::Rank(e) => e.$method(),
123 Self::DeckCollection(e) => e.$method(),
124 Self::Inventory(e) => e.$method(),
125 Self::DeckSubmission(e) => e.$method(),
126 Self::GameResult(e) => e.$method(),
127 Self::LogFileRotated(e) => e.$method(),
128 Self::DetailedLoggingStatus(e) => e.$method(),
129 Self::MatchConnectionState(e) => e.$method(),
130 Self::TcpConnectionClose(e) => e.$method(),
131 Self::WebSocketClosed(e) => e.$method(),
132 Self::ConnectionError(e) => e.$method(),
133 Self::Truncation(e) => e.$method(),
134 }
135 };
136}
137
138// ---------------------------------------------------------------------------
139// GameEvent enum
140// ---------------------------------------------------------------------------
141
142/// A parsed MTG Arena log event.
143///
144/// Each variant wraps a category-specific struct containing parsed fields,
145/// the original raw log bytes, and a precomputed payload hash. Consumers
146/// subscribe to the event bus and pattern-match on this enum.
147///
148/// Marked `#[non_exhaustive]` so that new event categories can be added
149/// in future releases without a breaking change for downstream consumers.
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151#[non_exhaustive]
152pub enum GameEvent {
153 /// GRE-to-client messages: `GameStateMessage`, `ConnectResp`,
154 /// `QueuedGameStateMessage`. Class 1 — interactive dispatch.
155 GameState(GameStateEvent),
156
157 /// Client-to-GRE messages: `SelectNResp`, `SubmitDeckResp`,
158 /// `MulliganResp`. Class 1 — interactive dispatch.
159 ClientAction(ClientActionEvent),
160
161 /// Match room state changes (`matchGameRoomStateChangedEvent`).
162 /// Class 1 — interactive dispatch.
163 MatchState(MatchStateEvent),
164
165 /// Bot draft events (`<== BotDraftDraftStatus`, `<== BotDraftDraftPick`,
166 /// `==> BotDraftDraftPick`).
167 /// Class 2 — durable per-event.
168 DraftBot(DraftBotEvent),
169
170 /// Human draft picks (`Draft.Notify`, `EventPlayerDraftMakePick`).
171 /// Class 2 — durable per-event.
172 DraftHuman(DraftHumanEvent),
173
174 /// Draft completion (`Draft_CompleteDraft`).
175 /// Class 2 — durable per-event.
176 DraftComplete(DraftCompleteEvent),
177
178 /// Event lifecycle: `==> EventJoin`, `==> EventClaimPrize`,
179 /// `==> EventEnterPairing`. Class 2 — durable per-event.
180 EventLifecycle(EventLifecycleEvent),
181
182 /// Session: login, account identity, logout.
183 /// Class 2 — durable per-event.
184 Session(SessionEvent),
185
186 /// Rank snapshot (`<== RankGetCombinedRankInfo`).
187 /// Class 2 — durable per-event.
188 Rank(RankEvent),
189
190 /// Deck snapshot (`<== StartHook` with `DeckSummaries` and `Decks`).
191 /// Class 2 — durable per-event.
192 DeckCollection(DeckCollectionEvent),
193
194 /// Inventory snapshot (`<== StartHook` with `InventoryInfo`):
195 /// currency, wildcards, etc. Class 2 — durable per-event.
196 Inventory(InventoryEvent),
197
198 /// Deck submission (`==> EventSetDeckV2`, `==> EventSetDeckV3`, and
199 /// future `Vn`). Payload: `{ deck_id, deck_format, event_name,
200 /// is_singleton }`. Used as the C-2b format-model fallback signal for
201 /// bot-match queues. Class 2 — durable per-event.
202 DeckSubmission(DeckSubmissionEvent),
203
204 /// Game result (`GameStage_GameOver` from GRE `GameStateMessage`).
205 /// Class 3 — triggers post-game batch assembly.
206 GameResult(GameResultEvent),
207
208 /// Log file rotation detected — `Player.log` was replaced (MTGA restart).
209 ///
210 /// Emitted by the file tailer when it detects that the log file at the
211 /// monitored path has been replaced (file size shrinkage or mtime jump).
212 /// Downstream consumers should reset their state for a new session.
213 /// Class 1 — interactive dispatch (local reset signal).
214 LogFileRotated(LogFileRotatedEvent),
215
216 /// Detailed logging status change detected.
217 ///
218 /// Emitted by the file tailer when it determines whether Arena's
219 /// "Detailed Logs (Plugin Support)" setting is enabled. `enabled: false`
220 /// is emitted after 30 seconds of observed log writes without any
221 /// `[UnityCrossThreadLogger]` headers. `enabled: true`
222 /// is emitted if structured headers are later detected (user enabled the
223 /// setting and restarted Arena).
224 /// Class 1 — interactive dispatch (local status signal).
225 DetailedLoggingStatus(DetailedLoggingStatusEvent),
226
227 /// Match connection state machine transition (`STATE CHANGED`).
228 ///
229 /// Parsed from `[UnityCrossThreadLogger]STATE CHANGED {"old":"...","new":"..."}`
230 /// entries. Payload is `{"old": "<state>", "new": "<state>"}`. Drives the
231 /// connection health indicator (AC-DET-1) — the definitive signal for
232 /// local-client disconnect detection.
233 /// Class 1 — interactive dispatch.
234 MatchConnectionState(MatchConnectionStateEvent),
235
236 /// TCP connection close event (`Client.TcpConnection.Close`).
237 ///
238 /// Parsed from `[UnityCrossThreadLogger]Client.TcpConnection.Close {...}`
239 /// entries. The payload is the full parsed JSON from the log line,
240 /// preserving `status`, `reason`, and abnormal-close-only fields
241 /// (`function`, `description`, `exception`). Feeds the desktop
242 /// connection health monitor (AC-DET-2); the parser is agnostic to
243 /// `status` semantics (per ADR-011).
244 /// Class 1 — interactive dispatch.
245 TcpConnectionClose(TcpConnectionCloseEvent),
246
247 /// WebSocket close event (`GREConnection.HandleWebSocketClosed`).
248 ///
249 /// Parsed from
250 /// `[UnityCrossThreadLogger]GREConnection.HandleWebSocketClosed {...}`
251 /// entries. The payload is the full parsed JSON from the log line,
252 /// which always includes `closeType`, `reason`, and a nested `tcpConn`
253 /// object snapshot of the paired TCP connection. Feeds the desktop
254 /// connection health monitor (AC-DET-3); the parser is agnostic to
255 /// `closeType` semantics (per ADR-011).
256 /// Class 1 — interactive dispatch.
257 WebSocketClosed(WebSocketClosedEvent),
258
259 /// Connection error event (error-path markers).
260 ///
261 /// Parsed from four JSON-bearing markers under `[UnityCrossThreadLogger]`:
262 /// `TcpConnection.ProcessRead.Exception`,
263 /// `Client.TcpConnection.ProcessFailure`,
264 /// `GREConnection.MatchDoorConnectionError`, and
265 /// `TcpConnection.Close.Exception`. Each variant is discriminated by a
266 /// stable `error_type` string and wraps the full parsed JSON under a
267 /// `payload` key. Feeds the desktop connection health monitor (AC-DET-5);
268 /// the parser is agnostic to inner error-code semantics (per ADR-011).
269 /// Class 1 — interactive dispatch.
270 ConnectionError(ConnectionErrorEvent),
271
272 /// GSM truncation marker observed.
273 ///
274 /// Parsed from `[Message summarized because one or more GameStateMessages
275 /// exceeded the 50 GameObject or 50 Annotation limit.]` entries. The
276 /// truncated GSM's body is irrecoverable from `Player.log`; this event
277 /// surfaces the signal so downstream consumers (deck tracker) can mark
278 /// the next `gsm_id` as crossing a data-loss gap. Payload is
279 /// `{"object_count": N, "annotation_count": M}`; timestamp lives in
280 /// `EventMetadata`. Class 1 — interactive dispatch.
281 Truncation(TruncationEvent),
282}
283
284impl GameEvent {
285 /// Returns the performance class for this event.
286 ///
287 /// - Class 1: interactive dispatch (local, ≤ 100 ms)
288 /// - Class 2: durable per-event upload
289 /// - Class 3: post-game batch upload trigger
290 pub fn performance_class(&self) -> PerformanceClass {
291 match self {
292 Self::GameState(_)
293 | Self::ClientAction(_)
294 | Self::MatchState(_)
295 | Self::LogFileRotated(_)
296 | Self::DetailedLoggingStatus(_)
297 | Self::MatchConnectionState(_)
298 | Self::TcpConnectionClose(_)
299 | Self::WebSocketClosed(_)
300 | Self::ConnectionError(_)
301 | Self::Truncation(_) => PerformanceClass::InteractiveDispatch,
302 Self::DraftBot(_)
303 | Self::DraftHuman(_)
304 | Self::DraftComplete(_)
305 | Self::EventLifecycle(_)
306 | Self::Session(_)
307 | Self::Rank(_)
308 | Self::DeckCollection(_)
309 | Self::Inventory(_)
310 | Self::DeckSubmission(_) => PerformanceClass::DurablePerEvent,
311 Self::GameResult(_) => PerformanceClass::PostGameBatch,
312 }
313 }
314
315 /// Returns the shared metadata common to all events.
316 pub fn metadata(&self) -> &EventMetadata {
317 delegate_to_inner!(self, metadata)
318 }
319
320 /// Returns the parsed JSON payload of the event.
321 pub fn payload(&self) -> &serde_json::Value {
322 delegate_to_inner!(self, payload)
323 }
324}
325
326// ---------------------------------------------------------------------------
327// PerformanceClass
328// ---------------------------------------------------------------------------
329
330/// Performance class determining latency target and delivery path.
331///
332/// See the [feature spec performance classes][spec] for details.
333///
334/// [spec]: https://github.com/manasight/manasight-docs/blob/main/docs/requirements/feature-specs/log-file-parser.md#performance-classes
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
336#[non_exhaustive]
337pub enum PerformanceClass {
338 /// Class 1: local-only, ≤ 100 ms latency. Also accumulated for Class 3.
339 InteractiveDispatch,
340 /// Class 2: persisted to disk queue, uploaded individually, ≤ 1 s.
341 DurablePerEvent,
342 /// Class 3: triggers assembly and upload of the complete game batch.
343 PostGameBatch,
344}
345
346impl PerformanceClass {
347 /// Returns the numeric class identifier (1, 2, or 3).
348 ///
349 /// Useful for logging, metrics, and wire-format tagging where a compact
350 /// integer representation is preferred over the enum variant name.
351 pub fn as_class_number(&self) -> u8 {
352 match self {
353 Self::InteractiveDispatch => 1,
354 Self::DurablePerEvent => 2,
355 Self::PostGameBatch => 3,
356 }
357 }
358
359 /// Returns `true` if events in this class must be persisted to durable
360 /// storage (disk queue or disk-backed buffer) before being considered
361 /// processed.
362 ///
363 /// Class 2 events are individually persisted to a disk queue for
364 /// per-event upload. Class 3 events trigger batch assembly from a
365 /// disk-backed game buffer. Class 1 events are local-only and do not
366 /// require durable storage (though they are also accumulated into the
367 /// Class 3 buffer asynchronously).
368 pub fn requires_durable_storage(&self) -> bool {
369 match self {
370 Self::InteractiveDispatch => false,
371 Self::DurablePerEvent | Self::PostGameBatch => true,
372 }
373 }
374
375 /// Returns `true` if this class triggers post-game batch assembly.
376 ///
377 /// Only Class 3 (`PostGameBatch`) triggers the assembly and upload of
378 /// the accumulated game buffer. Downstream consumers use this to know
379 /// when to finalize and ship the game record.
380 pub fn is_batch_trigger(&self) -> bool {
381 matches!(self, Self::PostGameBatch)
382 }
383}
384
385// ---------------------------------------------------------------------------
386// EventMetadata
387// ---------------------------------------------------------------------------
388
389/// Fields shared by every event: timestamp, raw bytes, and raw-bytes hash.
390///
391/// Constructed via [`EventMetadata::new`], which computes the `raw_bytes_hash`
392/// from `raw_bytes` to enforce the invariant that the hash always matches.
393/// This is critical for server-side deduplication via event fingerprints.
394///
395/// The `timestamp` is `Option<DateTime<Utc>>` because some log entries lack
396/// a parseable timestamp in the header. `None` means "no timestamp found in
397/// the log entry" — downstream consumers must handle this explicitly rather
398/// than receiving a synthetic `Utc::now()` that would break fingerprinting
399/// and chronological ordering.
400///
401/// All fields are private to protect the hash invariant. Use the accessor
402/// methods to read them.
403///
404/// Deserialization also enforces this invariant: the hash is recomputed from
405/// `raw_bytes` during deserialization rather than trusting the serialized value.
406#[derive(Debug, Clone, PartialEq, Serialize)]
407pub struct EventMetadata {
408 /// Local wall-clock timestamp parsed from the log entry header, stored
409 /// as `DateTime<Utc>` for historical reasons (the inner value is a
410 /// `NaiveDateTime` promoted with `.and_utc()`, so the UTC label is
411 /// misleading). Use `local_timestamp()` for the honest zone-less view,
412 /// or `instant_utc()` for the true UTC instant when available.
413 ///
414 /// `None` when the log entry header did not contain a parseable
415 /// timestamp.
416 timestamp: Option<DateTime<Utc>>,
417
418 /// True UTC instant derived from the embedded epoch-ms field in the
419 /// event payload, or `None` when no embedded timestamp is available for
420 /// this event type.
421 ///
422 /// Populated for match-lifecycle events (`matchGameRoomStateChangedEvent`)
423 /// whose payload carries a `"timestamp"` field expressed as milliseconds
424 /// since the Unix epoch. All other events leave this `None`.
425 #[serde(default, skip_serializing_if = "Option::is_none")]
426 instant_utc: Option<DateTime<Utc>>,
427
428 /// Original log entry bytes, serialized as base64. Private to prevent
429 /// mutation that would break the `raw_bytes_hash` invariant.
430 #[serde(with = "base64_serde")]
431 raw_bytes: Vec<u8>,
432
433 /// SHA-256 hash of `raw_bytes`, serialized as lowercase hex.
434 /// Precomputed at construction time. Used as part of the event
435 /// fingerprint for server-side deduplication.
436 #[serde(with = "hex_serde")]
437 raw_bytes_hash: [u8; 32],
438}
439
440impl EventMetadata {
441 /// Creates a new `EventMetadata`, computing `raw_bytes_hash` as the
442 /// SHA-256 digest of `raw_bytes`.
443 ///
444 /// `timestamp` is `None` when the log entry header did not contain a
445 /// parseable timestamp. This preserves the distinction between "real
446 /// timestamp from the log" and "no timestamp available" for downstream
447 /// consumers.
448 ///
449 /// `instant_utc` is set to `None`. Use [`EventMetadata::with_instant`]
450 /// when a true UTC instant is available from an embedded epoch-ms field.
451 pub fn new(timestamp: Option<DateTime<Utc>>, raw_bytes: Vec<u8>) -> Self {
452 let raw_bytes_hash: [u8; 32] = Sha256::digest(&raw_bytes).into();
453 Self {
454 timestamp,
455 instant_utc: None,
456 raw_bytes,
457 raw_bytes_hash,
458 }
459 }
460
461 /// Creates a new `EventMetadata` with a true UTC instant from an embedded
462 /// epoch-ms timestamp in the event payload, computing `raw_bytes_hash` as
463 /// the SHA-256 digest of `raw_bytes`.
464 ///
465 /// `timestamp` is the local wall-clock value parsed from the log entry
466 /// header (mislabeled as UTC — see [`EventMetadata::local_timestamp`]).
467 ///
468 /// `instant_utc` is the true UTC instant derived from the event payload's
469 /// embedded `"timestamp"` field (epoch-milliseconds). Pass `None` when the
470 /// embedded field is absent or unparseable.
471 pub fn with_instant(
472 timestamp: Option<DateTime<Utc>>,
473 instant_utc: Option<DateTime<Utc>>,
474 raw_bytes: Vec<u8>,
475 ) -> Self {
476 let raw_bytes_hash: [u8; 32] = Sha256::digest(&raw_bytes).into();
477 Self {
478 timestamp,
479 instant_utc,
480 raw_bytes,
481 raw_bytes_hash,
482 }
483 }
484
485 /// Returns the local wall-clock timestamp from the log entry header, or
486 /// `None` if the entry did not contain a parseable timestamp.
487 ///
488 /// The returned value is a zone-less `NaiveDateTime` representing the
489 /// player's local time as written in the log header. The MTGA log does not
490 /// include timezone information, so no UTC conversion is applied.
491 ///
492 /// For the true UTC instant (where available), use [`EventMetadata::instant_utc`].
493 pub fn local_timestamp(&self) -> Option<NaiveDateTime> {
494 self.timestamp.map(|t| t.naive_utc())
495 }
496
497 /// Returns the true UTC instant derived from the event's embedded
498 /// epoch-ms field, or `None` when no embedded timestamp is available.
499 ///
500 /// Populated for match-lifecycle events (`matchGameRoomStateChangedEvent`).
501 /// All other events return `None`; fall back to [`EventMetadata::local_timestamp`]
502 /// in that case.
503 pub fn instant_utc(&self) -> Option<DateTime<Utc>> {
504 self.instant_utc
505 }
506
507 /// Returns the log-header timestamp, mislabeled as UTC.
508 ///
509 /// The underlying value is a local wall-clock `NaiveDateTime` promoted
510 /// with `.and_utc()`, so the `DateTime<Utc>` type is misleading: the
511 /// inner value is **not** a true UTC instant.
512 ///
513 /// Prefer [`EventMetadata::instant_utc`] for the absolute UTC instant
514 /// (available for match-lifecycle events), or
515 /// [`EventMetadata::local_timestamp`] for the honest zone-less local time.
516 #[deprecated(note = "header timestamps are local wall-clock mislabeled as UTC; \
517 use instant_utc() for the true UTC instant (match-lifecycle events) \
518 or local_timestamp() for the zone-less local header value")]
519 pub fn timestamp(&self) -> Option<DateTime<Utc>> {
520 self.timestamp
521 }
522
523 /// Returns the original log entry bytes.
524 pub fn raw_bytes(&self) -> &[u8] {
525 &self.raw_bytes
526 }
527
528 /// Returns the SHA-256 hash of `raw_bytes`.
529 pub fn raw_bytes_hash(&self) -> &[u8; 32] {
530 &self.raw_bytes_hash
531 }
532}
533
534/// Custom `Deserialize` that recomputes `raw_bytes_hash` from `raw_bytes`,
535/// ensuring the hash invariant survives serialization round-trips.
536impl<'de> Deserialize<'de> for EventMetadata {
537 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
538 where
539 D: serde::Deserializer<'de>,
540 {
541 /// Wire format for deserializing `EventMetadata`. The
542 /// `raw_bytes_hash` field is optional and discarded — the real
543 /// hash is always recomputed from `raw_bytes`. `instant_utc` is
544 /// optional with a default of `None` for backward compatibility with
545 /// older serialized payloads that predate this field.
546 #[derive(Deserialize)]
547 struct EventMetadataWire {
548 timestamp: Option<DateTime<Utc>>,
549 #[serde(default)]
550 instant_utc: Option<DateTime<Utc>>,
551 #[serde(with = "base64_serde")]
552 raw_bytes: Vec<u8>,
553 // Accepts any format (hex string, integer array) or absence.
554 // The value is discarded — hash is always recomputed.
555 #[serde(default, rename = "raw_bytes_hash")]
556 _raw_bytes_hash: serde::de::IgnoredAny,
557 }
558
559 let wire = EventMetadataWire::deserialize(deserializer)?;
560 Ok(EventMetadata::with_instant(
561 wire.timestamp,
562 wire.instant_utc,
563 wire.raw_bytes,
564 ))
565 }
566}
567
568// ---------------------------------------------------------------------------
569// Class 1: Interactive Dispatch
570// ---------------------------------------------------------------------------
571
572define_event! {
573 /// GRE-to-client game state messages.
574 ///
575 /// Covers `GameStateMessage`, `ConnectResp`, and `QueuedGameStateMessage`
576 /// payloads from `greToClientEvent` entries.
577 GameStateEvent
578}
579
580define_event! {
581 /// Client-to-GRE player actions.
582 ///
583 /// Covers `SelectNResp`, `SubmitDeckResp`, `MulliganResp`, and other
584 /// `ClientToGREMessage` payloads.
585 ClientActionEvent
586}
587
588define_event! {
589 /// Match room state transitions.
590 ///
591 /// Parsed from `matchGameRoomStateChangedEvent` entries. Signals match
592 /// start/end and triggers overlay state transitions.
593 MatchStateEvent
594}
595
596// ---------------------------------------------------------------------------
597// Class 2: Durable Per-Event
598// ---------------------------------------------------------------------------
599
600define_event! {
601 /// Bot draft events.
602 ///
603 /// Parsed from `BotDraftDraftStatus` and `BotDraftDraftPick` request and
604 /// response entries. Each pick is independently valuable and must survive
605 /// crashes.
606 DraftBotEvent
607}
608
609define_event! {
610 /// Human draft pick events.
611 ///
612 /// Parsed from `Draft.Notify` and `EventPlayerDraftMakePick`.
613 DraftHumanEvent
614}
615
616define_event! {
617 /// Draft completion event.
618 ///
619 /// Parsed from `Draft_CompleteDraft`. Links the draft ID to the event
620 /// and marks the draft as finished.
621 DraftCompleteEvent
622}
623
624define_event! {
625 /// Event lifecycle transitions.
626 ///
627 /// Covers `==> EventJoin`, `==> EventClaimPrize`, and
628 /// `==> EventEnterPairing`. Each is independently meaningful.
629 EventLifecycleEvent
630}
631
632define_event! {
633 /// Session identity and connection events.
634 ///
635 /// Covers `authenticateResponse` and `FrontDoorConnection.Close`.
636 /// Needed to tag all subsequent events with player identity.
637 SessionEvent
638}
639
640define_event! {
641 /// Rank snapshot.
642 ///
643 /// Parsed from `<== RankGetCombinedRankInfo`. Infrequent, small,
644 /// independently useful.
645 RankEvent
646}
647
648define_event! {
649 /// Deck snapshot.
650 ///
651 /// Parsed from `<== StartHook` responses containing both `DeckSummaries`
652 /// and `Decks`. Correlates per-deck metadata with the associated deck
653 /// list payload when a matching `DeckId` is available.
654 DeckCollectionEvent
655}
656
657define_event! {
658 /// Inventory snapshot.
659 ///
660 /// Parsed from `<== StartHook` responses containing `InventoryInfo`.
661 /// Contains currency, wildcards, boosters, and vault progress.
662 InventoryEvent
663}
664
665define_event! {
666 /// Deck-submission event.
667 ///
668 /// Parsed from `==> EventSetDeckV2`, `==> EventSetDeckV3`, and future
669 /// `Vn` request lines. The payload carries the submitted deck's registered
670 /// `Format`, `DeckId`, `EventName` (queue string), and `is_singleton`
671 /// flag (non-empty `CommandZone`). Used by the C-2b format model as the
672 /// fallback format signal for bot-match queues where `event_id` alone does
673 /// not resolve the format.
674 DeckSubmissionEvent
675}
676
677// ---------------------------------------------------------------------------
678// Class 3: Post-Game Batch
679// ---------------------------------------------------------------------------
680
681define_event! {
682 /// Game result event — triggers post-game batch assembly.
683 ///
684 /// Parsed from a GRE `GameStateMessage` whose `gameInfo.stage` equals
685 /// `GameStage_GameOver` (specifically the `MatchState_GameComplete`
686 /// variant to avoid duplicate firing in Bo3). When this event fires,
687 /// the desktop app serializes the disk-backed game buffer into a single
688 /// compressed payload and uploads it.
689 GameResultEvent
690}
691
692// ---------------------------------------------------------------------------
693// Infrastructure events
694// ---------------------------------------------------------------------------
695
696define_event! {
697 /// Log file rotation event.
698 ///
699 /// Emitted when the file tailer detects that `Player.log` was replaced
700 /// (MTGA restart). The payload contains `previous_file_size` — the byte
701 /// offset in the old file at the time rotation was detected.
702 ///
703 /// Unlike parsed log events, `raw_bytes` in the metadata is empty and
704 /// the timestamp reflects when the rotation was detected (wall-clock),
705 /// not a timestamp parsed from the log.
706 LogFileRotatedEvent
707}
708
709impl LogFileRotatedEvent {
710 /// Creates a rotation event with the given detection timestamp and the
711 /// byte offset in the old file.
712 pub fn for_rotation(timestamp: DateTime<Utc>, previous_file_size: u64) -> Self {
713 let metadata = EventMetadata::new(Some(timestamp), Vec::new());
714 let payload = serde_json::json!({ "previous_file_size": previous_file_size });
715 Self::new(metadata, payload)
716 }
717
718 /// Returns the byte offset in the old file when rotation was detected.
719 ///
720 /// Returns `None` only if the payload was manually constructed without
721 /// the `previous_file_size` field (not expected in normal usage).
722 pub fn previous_file_size(&self) -> Option<u64> {
723 self.payload()["previous_file_size"].as_u64()
724 }
725}
726
727define_event! {
728 /// Detailed logging status event.
729 ///
730 /// Emitted when the file tailer detects whether Arena's "Detailed Logs
731 /// (Plugin Support)" setting is enabled. The payload contains `enabled`
732 /// — `false` after 30 seconds of log writes without structured headers,
733 /// `true` when structured headers are subsequently detected.
734 ///
735 /// Like `LogFileRotatedEvent`, `raw_bytes` in the metadata is empty and
736 /// the timestamp reflects wall-clock detection time.
737 DetailedLoggingStatusEvent
738}
739
740impl DetailedLoggingStatusEvent {
741 /// Creates a detailed logging status event.
742 pub fn new_status(timestamp: DateTime<Utc>, enabled: bool) -> Self {
743 let metadata = EventMetadata::new(Some(timestamp), Vec::new());
744 let payload = serde_json::json!({ "enabled": enabled });
745 Self::new(metadata, payload)
746 }
747
748 /// Returns whether detailed logging is enabled.
749 ///
750 /// Returns `None` only if the payload was manually constructed without
751 /// the `enabled` field (not expected in normal usage).
752 pub fn enabled(&self) -> Option<bool> {
753 self.payload()["enabled"].as_bool()
754 }
755}
756
757define_event! {
758 /// GSM truncation marker event.
759 ///
760 /// Emitted when Arena's `Player.log` writes the
761 /// `[Message summarized because one or more GameStateMessages exceeded the
762 /// 50 GameObject or 50 Annotation limit.]` marker in place of a normal
763 /// `GameStateMessage` JSON body. The truncated GSM's body is irrecoverable
764 /// from the log; this event surfaces the signal so downstream consumers
765 /// (deck tracker) can mark the next `gsm_id` as crossing a data-loss gap.
766 ///
767 /// Following the header-only convention used by `LogFileRotatedEvent` and
768 /// `DetailedLoggingStatusEvent`: timestamp lives in `EventMetadata`, the
769 /// payload carries `{"object_count": N, "annotation_count": M}`.
770 /// `raw_bytes` is empty because the marker has no parseable body and
771 /// downstream fingerprinting derives no value from preserving the
772 /// fixed-form marker text.
773 TruncationEvent
774}
775
776impl TruncationEvent {
777 /// Creates a truncation event from parsed marker counts.
778 pub fn new_truncation(
779 timestamp: Option<DateTime<Utc>>,
780 object_count: u32,
781 annotation_count: u32,
782 ) -> Self {
783 let metadata = EventMetadata::new(timestamp, Vec::new());
784 let payload = serde_json::json!({
785 "object_count": object_count,
786 "annotation_count": annotation_count,
787 });
788 Self::new(metadata, payload)
789 }
790
791 /// Returns the truncated GSM's reported game-object count, or `None`
792 /// if the payload was manually constructed without the field.
793 pub fn object_count(&self) -> Option<u32> {
794 self.payload()["object_count"]
795 .as_u64()
796 .and_then(|v| u32::try_from(v).ok())
797 }
798
799 /// Returns the truncated GSM's reported annotation count, or `None`
800 /// if the payload was manually constructed without the field.
801 pub fn annotation_count(&self) -> Option<u32> {
802 self.payload()["annotation_count"]
803 .as_u64()
804 .and_then(|v| u32::try_from(v).ok())
805 }
806}
807
808define_event! {
809 /// Match connection state machine transition event.
810 ///
811 /// Parsed from `[UnityCrossThreadLogger]STATE CHANGED {...}` entries.
812 /// The payload is the JSON object `{"old": "<state>", "new": "<state>"}`
813 /// where each state is one of the values observed in the MTGA match
814 /// connection state machine (e.g., `None`, `ConnectedToMatchDoor`,
815 /// `ConnectedToMatchDoor_ConnectingToGRE`,
816 /// `ConnectedToMatchDoor_ConnectedToGRE_Waiting`, `Playing`,
817 /// `MatchCompleted`, `Disconnected`).
818 ///
819 /// Feeds the desktop connection health monitor; see feature spec
820 /// `connection-health-indicator.md` **AC-DET-1**.
821 MatchConnectionStateEvent
822}
823
824define_event! {
825 /// TCP connection close event.
826 ///
827 /// Parsed from `[UnityCrossThreadLogger]Client.TcpConnection.Close {...}`
828 /// entries. The payload is the full parsed JSON from the log line and
829 /// carries at minimum `status` and `reason`; abnormal closes also
830 /// include `function`, `description`, and a nested `exception` tree
831 /// (with `InnerException.NativeErrorCode` on Windows/macOS).
832 ///
833 /// The parser is agnostic to `status` semantics — downstream consumers
834 /// classify close types per ADR-011. Bare-marker entries (no JSON
835 /// payload) do not produce this event.
836 ///
837 /// Feeds the desktop connection health monitor; see feature spec
838 /// `connection-health-indicator.md` **AC-DET-2**.
839 TcpConnectionCloseEvent
840}
841
842define_event! {
843 /// WebSocket close event.
844 ///
845 /// Parsed from
846 /// `[UnityCrossThreadLogger]GREConnection.HandleWebSocketClosed {...}`
847 /// entries. The payload is the full parsed JSON from the log line and
848 /// always includes `closeType`, `reason`, and a nested `tcpConn`
849 /// object snapshot of the paired TCP connection (host/port/timing/ping
850 /// stats).
851 ///
852 /// The parser is agnostic to `closeType` semantics — downstream
853 /// consumers classify close types per ADR-011.
854 ///
855 /// Feeds the desktop connection health monitor; see feature spec
856 /// `connection-health-indicator.md` **AC-DET-3**.
857 WebSocketClosedEvent
858}
859
860define_event! {
861 /// Connection error event (error-path markers).
862 ///
863 /// Parsed from four JSON-bearing error markers under
864 /// `[UnityCrossThreadLogger]`:
865 ///
866 /// | Marker | `error_type` |
867 /// |--------|--------------|
868 /// | `TcpConnection.ProcessRead.Exception` | `tcp_process_read_exception` |
869 /// | `Client.TcpConnection.ProcessFailure` | `tcp_process_failure_socket_error` |
870 /// | `GREConnection.MatchDoorConnectionError` | `gre_match_door_connection_error` |
871 /// | `TcpConnection.Close.Exception` | `tcp_close_exception` |
872 ///
873 /// The payload shape is
874 /// `{"error_type": "<discriminant>", "payload": <parsed>}`, where
875 /// `<parsed>` is the full parsed JSON from the log line preserved
876 /// unchanged. Bare-marker entries (no JSON payload) do not produce this
877 /// event; the paired JSON line on a subsequent entry emits it.
878 ///
879 /// The parser is agnostic to inner error-code semantics — downstream
880 /// consumers match on `error_type` per ADR-011.
881 ///
882 /// Feeds the desktop connection health monitor; see feature spec
883 /// `connection-health-indicator.md` **AC-DET-5**.
884 ConnectionErrorEvent
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890 use base64::prelude::{Engine as _, BASE64_STANDARD};
891 use chrono::{Datelike, TimeZone, Timelike};
892
893 type TestResult = Result<(), Box<dyn std::error::Error>>;
894
895 /// Helper: build an `EventMetadata` with a fixed timestamp and the
896 /// given raw bytes.
897 ///
898 /// UTC datetimes are never ambiguous so `single()` always returns
899 /// `Some`. Uses `unwrap_or_default()` because `clippy::expect_used`
900 /// is denied in `Cargo.toml [lints.clippy]` — verified: this applies
901 /// crate-wide including `#[cfg(test)]` code under `--all-targets`.
902 /// The epoch fallback (1970-01-01) would visibly fail any timestamp
903 /// assertion rather than passing silently.
904 fn make_metadata(raw: &[u8]) -> EventMetadata {
905 let timestamp = Utc
906 .with_ymd_and_hms(2026, 2, 25, 12, 0, 0)
907 .single()
908 .unwrap_or_default();
909 EventMetadata::new(Some(timestamp), raw.to_vec())
910 }
911
912 /// Helper: build all `GameEvent` variants for exhaustive testing.
913 ///
914 /// Must stay in sync with `GameEvent` variants. Compile-time
915 /// exhaustiveness is enforced by `performance_class()` and
916 /// `delegate_to_inner!`; this array is the test-only counterpart.
917 fn all_variants() -> Vec<GameEvent> {
918 let meta = make_metadata(b"test");
919 let payload = serde_json::json!({});
920 vec![
921 GameEvent::GameState(GameStateEvent::new(meta.clone(), payload.clone())),
922 GameEvent::ClientAction(ClientActionEvent::new(meta.clone(), payload.clone())),
923 GameEvent::MatchState(MatchStateEvent::new(meta.clone(), payload.clone())),
924 GameEvent::DraftBot(DraftBotEvent::new(meta.clone(), payload.clone())),
925 GameEvent::DraftHuman(DraftHumanEvent::new(meta.clone(), payload.clone())),
926 GameEvent::DraftComplete(DraftCompleteEvent::new(meta.clone(), payload.clone())),
927 GameEvent::EventLifecycle(EventLifecycleEvent::new(meta.clone(), payload.clone())),
928 GameEvent::Session(SessionEvent::new(meta.clone(), payload.clone())),
929 GameEvent::Rank(RankEvent::new(meta.clone(), payload.clone())),
930 GameEvent::DeckCollection(DeckCollectionEvent::new(meta.clone(), payload.clone())),
931 GameEvent::Inventory(InventoryEvent::new(meta.clone(), payload.clone())),
932 GameEvent::DeckSubmission(DeckSubmissionEvent::new(meta.clone(), payload.clone())),
933 GameEvent::GameResult(GameResultEvent::new(meta.clone(), payload.clone())),
934 GameEvent::LogFileRotated(LogFileRotatedEvent::new(meta.clone(), payload.clone())),
935 GameEvent::DetailedLoggingStatus(DetailedLoggingStatusEvent::new(
936 meta.clone(),
937 payload.clone(),
938 )),
939 GameEvent::MatchConnectionState(MatchConnectionStateEvent::new(
940 meta.clone(),
941 payload.clone(),
942 )),
943 GameEvent::TcpConnectionClose(TcpConnectionCloseEvent::new(
944 meta.clone(),
945 payload.clone(),
946 )),
947 GameEvent::WebSocketClosed(WebSocketClosedEvent::new(meta.clone(), payload.clone())),
948 GameEvent::ConnectionError(ConnectionErrorEvent::new(meta.clone(), payload.clone())),
949 GameEvent::Truncation(TruncationEvent::new(meta.clone(), payload.clone())),
950 ]
951 }
952
953 // -- EventMetadata construction --
954
955 #[test]
956 fn test_event_metadata_new_stores_raw_bytes() {
957 let raw = b"[UnityCrossThreadLogger]some log line";
958 let meta = make_metadata(raw);
959 assert_eq!(meta.raw_bytes(), raw);
960 }
961
962 #[test]
963 fn test_event_metadata_new_computes_raw_bytes_hash() {
964 let raw = b"test payload";
965 let meta = make_metadata(raw);
966 let expected: [u8; 32] = Sha256::digest(raw).into();
967 assert_eq!(*meta.raw_bytes_hash(), expected);
968 }
969
970 #[test]
971 fn test_event_metadata_new_stores_timestamp() {
972 let meta = make_metadata(b"data");
973 let ts = meta.local_timestamp();
974 assert!(ts.is_some());
975 let ts = ts.unwrap_or_default();
976 assert_eq!(ts.year(), 2026);
977 assert_eq!(ts.month(), 2);
978 }
979
980 #[test]
981 fn test_event_metadata_new_enforces_hash_invariant() {
982 let raw = b"important data";
983 let meta = make_metadata(raw);
984 let expected: [u8; 32] = Sha256::digest(raw).into();
985 assert_eq!(
986 *meta.raw_bytes_hash(),
987 expected,
988 "raw_bytes_hash must always be SHA-256 of raw_bytes"
989 );
990 }
991
992 // -- EventMetadata properties --
993
994 #[test]
995 fn test_different_raw_bytes_produce_different_hashes() {
996 let meta1 = make_metadata(b"payload one");
997 let meta2 = make_metadata(b"payload two");
998 assert_ne!(meta1.raw_bytes_hash(), meta2.raw_bytes_hash());
999 }
1000
1001 #[test]
1002 fn test_identical_raw_bytes_produce_same_hash() {
1003 let meta1 = make_metadata(b"same payload");
1004 let meta2 = make_metadata(b"same payload");
1005 assert_eq!(meta1.raw_bytes_hash(), meta2.raw_bytes_hash());
1006 }
1007
1008 #[test]
1009 fn test_empty_raw_bytes_valid() {
1010 let meta = make_metadata(b"");
1011 assert!(meta.raw_bytes().is_empty());
1012 let expected: [u8; 32] = Sha256::digest(b"").into();
1013 assert_eq!(*meta.raw_bytes_hash(), expected);
1014 }
1015
1016 #[test]
1017 fn test_event_metadata_clone_is_equal() {
1018 let meta = make_metadata(b"original");
1019 let cloned = meta.clone();
1020 assert_eq!(meta, cloned);
1021 }
1022
1023 #[test]
1024 fn test_event_metadata_timestamp_getter() {
1025 let meta = make_metadata(b"data");
1026 let ts = meta.local_timestamp();
1027 assert!(ts.is_some());
1028 let ts = ts.unwrap_or_default();
1029 assert_eq!(ts.year(), 2026);
1030 assert_eq!(ts.month(), 2);
1031 assert_eq!(ts.day(), 25);
1032 }
1033
1034 #[test]
1035 fn test_event_metadata_none_timestamp() {
1036 let meta = EventMetadata::new(None, b"data".to_vec());
1037 assert!(meta.local_timestamp().is_none());
1038 }
1039
1040 // -- Per-category struct field access (via accessors) --
1041
1042 #[test]
1043 fn test_game_state_event_field_access() {
1044 let event = GameStateEvent::new(
1045 make_metadata(b"gre payload"),
1046 serde_json::json!({"type": "GameStateMessage"}),
1047 );
1048 assert_eq!(event.payload()["type"], "GameStateMessage");
1049 assert_eq!(event.metadata().raw_bytes(), b"gre payload");
1050 }
1051
1052 #[test]
1053 fn test_client_action_event_field_access() {
1054 let event = ClientActionEvent::new(
1055 make_metadata(b"client action"),
1056 serde_json::json!({"type": "MulliganResp"}),
1057 );
1058 assert_eq!(event.payload()["type"], "MulliganResp");
1059 }
1060
1061 #[test]
1062 fn test_match_state_event_field_access() {
1063 let event = MatchStateEvent::new(
1064 make_metadata(b"match state"),
1065 serde_json::json!(
1066 {"matchGameRoomStateChangedEvent": {}}
1067 ),
1068 );
1069 assert!(event.payload()["matchGameRoomStateChangedEvent"].is_object());
1070 }
1071
1072 #[test]
1073 fn test_draft_bot_event_field_access() {
1074 let event = DraftBotEvent::new(
1075 make_metadata(b"bot draft"),
1076 serde_json::json!({"DraftStatus": "PickNext"}),
1077 );
1078 assert_eq!(event.payload()["DraftStatus"], "PickNext");
1079 }
1080
1081 #[test]
1082 fn test_draft_human_event_field_access() {
1083 let event = DraftHumanEvent::new(
1084 make_metadata(b"human draft"),
1085 serde_json::json!({"draft_id": "test-draft-123"}),
1086 );
1087 assert_eq!(event.payload()["draft_id"], "test-draft-123");
1088 }
1089
1090 #[test]
1091 fn test_draft_complete_event_field_access() {
1092 let event = DraftCompleteEvent::new(
1093 make_metadata(b"draft complete"),
1094 serde_json::json!({"Draft_CompleteDraft": true}),
1095 );
1096 assert_eq!(
1097 event.payload()["Draft_CompleteDraft"],
1098 serde_json::json!(true)
1099 );
1100 }
1101
1102 #[test]
1103 fn test_event_lifecycle_event_field_access() {
1104 let event = EventLifecycleEvent::new(
1105 make_metadata(b"event lifecycle"),
1106 serde_json::json!({"action": "Event_Join"}),
1107 );
1108 assert_eq!(event.payload()["action"], "Event_Join");
1109 }
1110
1111 #[test]
1112 fn test_session_event_field_access() {
1113 let event = SessionEvent::new(
1114 make_metadata(b"session data"),
1115 serde_json::json!({"DisplayName": "Player"}),
1116 );
1117 assert_eq!(event.payload()["DisplayName"], "Player");
1118 }
1119
1120 #[test]
1121 fn test_rank_event_field_access() {
1122 let event = RankEvent::new(
1123 make_metadata(b"rank data"),
1124 serde_json::json!(
1125 {"constructedClass": "Gold", "constructedLevel": 2}
1126 ),
1127 );
1128 assert_eq!(event.payload()["constructedClass"], "Gold");
1129 }
1130
1131 #[test]
1132 fn test_deck_collection_event_field_access() {
1133 let event = DeckCollectionEvent::new(
1134 make_metadata(b"deck collection"),
1135 serde_json::json!({
1136 "type": "deck_collection_snapshot",
1137 "decks": {
1138 "deck-1": {
1139 "DeckId": "deck-1",
1140 "Name": "Reanimator",
1141 "list": {"MainDeck": [{"cardId": 1, "quantity": 4}]}
1142 }
1143 }
1144 }),
1145 );
1146 assert_eq!(event.payload()["decks"]["deck-1"]["DeckId"], "deck-1");
1147 }
1148
1149 #[test]
1150 fn test_inventory_event_field_access() {
1151 let event = InventoryEvent::new(
1152 make_metadata(b"inventory"),
1153 serde_json::json!(
1154 {"gold": 5000, "gems": 200, "wcCommon": 10}
1155 ),
1156 );
1157 assert_eq!(event.payload()["gold"], 5000);
1158 }
1159
1160 #[test]
1161 fn test_game_result_event_field_access() {
1162 let event = GameResultEvent::new(
1163 make_metadata(b"game result"),
1164 serde_json::json!(
1165 {"WinningType": "Win", "GameStage": "GameOver"}
1166 ),
1167 );
1168 assert_eq!(event.payload()["WinningType"], "Win");
1169 }
1170
1171 // -- GameEvent enum --
1172
1173 #[test]
1174 fn test_game_event_all_variants_have_correct_performance_class() {
1175 let events = all_variants();
1176
1177 let expected_classes = [
1178 PerformanceClass::InteractiveDispatch, // GameState
1179 PerformanceClass::InteractiveDispatch, // ClientAction
1180 PerformanceClass::InteractiveDispatch, // MatchState
1181 PerformanceClass::DurablePerEvent, // DraftBot
1182 PerformanceClass::DurablePerEvent, // DraftHuman
1183 PerformanceClass::DurablePerEvent, // DraftComplete
1184 PerformanceClass::DurablePerEvent, // EventLifecycle
1185 PerformanceClass::DurablePerEvent, // Session
1186 PerformanceClass::DurablePerEvent, // Rank
1187 PerformanceClass::DurablePerEvent, // DeckCollection
1188 PerformanceClass::DurablePerEvent, // Inventory
1189 PerformanceClass::DurablePerEvent, // DeckSubmission
1190 PerformanceClass::PostGameBatch, // GameResult
1191 PerformanceClass::InteractiveDispatch, // LogFileRotated
1192 PerformanceClass::InteractiveDispatch, // DetailedLoggingStatus
1193 PerformanceClass::InteractiveDispatch, // MatchConnectionState
1194 PerformanceClass::InteractiveDispatch, // TcpConnectionClose
1195 PerformanceClass::InteractiveDispatch, // WebSocketClosed
1196 PerformanceClass::InteractiveDispatch, // ConnectionError
1197 PerformanceClass::InteractiveDispatch, // Truncation
1198 ];
1199
1200 assert_eq!(
1201 events.len(),
1202 expected_classes.len(),
1203 "all_variants() and expected_classes must have the same length"
1204 );
1205 for (event, expected) in events.iter().zip(expected_classes.iter()) {
1206 assert_eq!(&event.performance_class(), expected);
1207 }
1208 }
1209
1210 #[test]
1211 fn test_game_event_metadata_accessor_all_variants() {
1212 let raw = b"test";
1213 let events = all_variants();
1214 for event in &events {
1215 assert_eq!(event.metadata().raw_bytes(), raw);
1216 }
1217 }
1218
1219 #[test]
1220 fn test_game_event_payload_accessor_all_variants() {
1221 let events = all_variants();
1222 let expected = serde_json::json!({});
1223 for event in &events {
1224 assert_eq!(*event.payload(), expected);
1225 }
1226 }
1227
1228 // -- PerformanceClass --
1229
1230 #[test]
1231 fn test_performance_class_equality() {
1232 assert_eq!(
1233 PerformanceClass::InteractiveDispatch,
1234 PerformanceClass::InteractiveDispatch
1235 );
1236 assert_ne!(
1237 PerformanceClass::InteractiveDispatch,
1238 PerformanceClass::DurablePerEvent
1239 );
1240 assert_ne!(
1241 PerformanceClass::DurablePerEvent,
1242 PerformanceClass::PostGameBatch
1243 );
1244 }
1245
1246 #[test]
1247 fn test_performance_class_as_class_number_interactive_dispatch_returns_1() {
1248 assert_eq!(PerformanceClass::InteractiveDispatch.as_class_number(), 1);
1249 }
1250
1251 #[test]
1252 fn test_performance_class_as_class_number_durable_per_event_returns_2() {
1253 assert_eq!(PerformanceClass::DurablePerEvent.as_class_number(), 2);
1254 }
1255
1256 #[test]
1257 fn test_performance_class_as_class_number_post_game_batch_returns_3() {
1258 assert_eq!(PerformanceClass::PostGameBatch.as_class_number(), 3);
1259 }
1260
1261 #[test]
1262 fn test_performance_class_requires_durable_storage_class1_false() {
1263 assert!(!PerformanceClass::InteractiveDispatch.requires_durable_storage());
1264 }
1265
1266 #[test]
1267 fn test_performance_class_requires_durable_storage_class2_true() {
1268 assert!(PerformanceClass::DurablePerEvent.requires_durable_storage());
1269 }
1270
1271 #[test]
1272 fn test_performance_class_requires_durable_storage_class3_true() {
1273 assert!(PerformanceClass::PostGameBatch.requires_durable_storage());
1274 }
1275
1276 #[test]
1277 fn test_performance_class_is_batch_trigger_class1_false() {
1278 assert!(!PerformanceClass::InteractiveDispatch.is_batch_trigger());
1279 }
1280
1281 #[test]
1282 fn test_performance_class_is_batch_trigger_class2_false() {
1283 assert!(!PerformanceClass::DurablePerEvent.is_batch_trigger());
1284 }
1285
1286 #[test]
1287 fn test_performance_class_is_batch_trigger_class3_true() {
1288 assert!(PerformanceClass::PostGameBatch.is_batch_trigger());
1289 }
1290
1291 #[test]
1292 fn test_performance_class_class_number_matches_event_mapping() {
1293 // Verify the class numbers align with the event-to-class mapping:
1294 // Class 1 events map to InteractiveDispatch (number 1)
1295 // Class 2 events map to DurablePerEvent (number 2)
1296 // Class 3 events map to PostGameBatch (number 3)
1297 let events = all_variants();
1298 let expected_numbers: Vec<u8> = vec![
1299 1, // GameState
1300 1, // ClientAction
1301 1, // MatchState
1302 2, // DraftBot
1303 2, // DraftHuman
1304 2, // DraftComplete
1305 2, // EventLifecycle
1306 2, // Session
1307 2, // Rank
1308 2, // DeckCollection
1309 2, // Inventory
1310 2, // DeckSubmission
1311 3, // GameResult
1312 1, // LogFileRotated
1313 1, // DetailedLoggingStatus
1314 1, // MatchConnectionState
1315 1, // TcpConnectionClose
1316 1, // WebSocketClosed
1317 1, // ConnectionError
1318 1, // Truncation
1319 ];
1320 assert_eq!(events.len(), expected_numbers.len());
1321 for (event, expected_num) in events.iter().zip(expected_numbers.iter()) {
1322 assert_eq!(event.performance_class().as_class_number(), *expected_num);
1323 }
1324 }
1325
1326 // -- Serialization round-trip --
1327
1328 #[test]
1329 fn test_game_event_serde_round_trip_all_variants() -> TestResult {
1330 for event in all_variants() {
1331 let serialized = serde_json::to_string(&event)?;
1332 let deserialized: GameEvent = serde_json::from_str(&serialized)?;
1333 assert_eq!(deserialized, event);
1334 }
1335 Ok(())
1336 }
1337
1338 #[test]
1339 fn test_event_metadata_serde_round_trip() -> TestResult {
1340 let meta = make_metadata(b"round trip test");
1341 let serialized = serde_json::to_string(&meta)?;
1342 let deserialized: EventMetadata = serde_json::from_str(&serialized)?;
1343
1344 assert_eq!(deserialized, meta);
1345 Ok(())
1346 }
1347
1348 #[test]
1349 fn test_event_metadata_deserialize_recomputes_hash() -> TestResult {
1350 let meta = make_metadata(b"test data");
1351 let mut serialized: serde_json::Value = serde_json::to_value(&meta)?;
1352
1353 // Tamper with the serialized raw_bytes_hash (now a hex string)
1354 serialized["raw_bytes_hash"] = serde_json::json!("00".repeat(32));
1355
1356 let deserialized: EventMetadata = serde_json::from_value(serialized)?;
1357
1358 // Hash should be recomputed from raw_bytes, not the tampered
1359 // value
1360 assert_eq!(*deserialized.raw_bytes_hash(), *meta.raw_bytes_hash());
1361 assert_eq!(deserialized.raw_bytes(), meta.raw_bytes());
1362 Ok(())
1363 }
1364
1365 #[test]
1366 fn test_performance_class_serde_round_trip() -> TestResult {
1367 for class in [
1368 PerformanceClass::InteractiveDispatch,
1369 PerformanceClass::DurablePerEvent,
1370 PerformanceClass::PostGameBatch,
1371 ] {
1372 let serialized = serde_json::to_string(&class)?;
1373 let deserialized: PerformanceClass = serde_json::from_str(&serialized)?;
1374 assert_eq!(deserialized, class);
1375 }
1376 Ok(())
1377 }
1378
1379 // -- Wire format --
1380
1381 #[test]
1382 fn test_event_metadata_serializes_raw_bytes_as_base64() -> TestResult {
1383 let meta = make_metadata(b"hello world");
1384 let serialized: serde_json::Value = serde_json::to_value(&meta)?;
1385 assert_eq!(serialized["raw_bytes"], "aGVsbG8gd29ybGQ=");
1386 Ok(())
1387 }
1388
1389 #[test]
1390 fn test_event_metadata_serializes_raw_bytes_hash_as_hex() -> TestResult {
1391 let meta = make_metadata(b"hello world");
1392 let serialized: serde_json::Value = serde_json::to_value(&meta)?;
1393 let hash_str = serialized["raw_bytes_hash"]
1394 .as_str()
1395 .ok_or("raw_bytes_hash should be a string")?;
1396 // Known SHA-256 of "hello world"
1397 assert_eq!(
1398 hash_str,
1399 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
1400 );
1401 Ok(())
1402 }
1403
1404 #[test]
1405 fn test_event_metadata_deserialize_missing_raw_bytes_hash() -> TestResult {
1406 // Forward-compatibility: raw_bytes_hash absent from wire format
1407 let json = serde_json::json!({
1408 "timestamp": "2026-02-25T12:00:00Z",
1409 "raw_bytes": BASE64_STANDARD.encode(b"test data"),
1410 });
1411 let meta: EventMetadata = serde_json::from_value(json)?;
1412 let expected: [u8; 32] = Sha256::digest(b"test data").into();
1413 assert_eq!(*meta.raw_bytes_hash(), expected);
1414 assert_eq!(meta.raw_bytes(), b"test data");
1415 Ok(())
1416 }
1417
1418 #[test]
1419 fn test_event_metadata_deserialize_integer_array_raw_bytes_hash() -> TestResult {
1420 // Backward-compatibility: raw_bytes_hash in old integer array
1421 // format
1422 let json = serde_json::json!({
1423 "timestamp": "2026-02-25T12:00:00Z",
1424 "raw_bytes": BASE64_STANDARD.encode(b"data"),
1425 "raw_bytes_hash": vec![0; 32],
1426 });
1427 let meta: EventMetadata = serde_json::from_value(json)?;
1428 // Hash is recomputed, not taken from wire
1429 let expected: [u8; 32] = Sha256::digest(b"data").into();
1430 assert_eq!(*meta.raw_bytes_hash(), expected);
1431 Ok(())
1432 }
1433
1434 #[test]
1435 fn test_event_metadata_none_timestamp_serde_round_trip() -> TestResult {
1436 let meta = EventMetadata::new(None, b"no timestamp".to_vec());
1437 let serialized = serde_json::to_string(&meta)?;
1438 let deserialized: EventMetadata = serde_json::from_str(&serialized)?;
1439 assert_eq!(deserialized, meta);
1440 assert!(deserialized.local_timestamp().is_none());
1441 Ok(())
1442 }
1443
1444 #[test]
1445 fn test_event_metadata_deserialize_null_timestamp() -> TestResult {
1446 let json = serde_json::json!({
1447 "timestamp": null,
1448 "raw_bytes": BASE64_STANDARD.encode(b"data"),
1449 });
1450 let meta: EventMetadata = serde_json::from_value(json)?;
1451 assert!(meta.local_timestamp().is_none());
1452 assert_eq!(meta.raw_bytes(), b"data");
1453 Ok(())
1454 }
1455
1456 // -- instant_utc serde round-trips -----------------------------------------
1457
1458 #[test]
1459 fn test_event_metadata_with_instant_utc_serde_round_trip() -> TestResult {
1460 // A payload WITH instant_utc must survive a full serialize → deserialize
1461 // cycle with the value intact.
1462 let ts = Utc
1463 .with_ymd_and_hms(2026, 2, 25, 12, 0, 0)
1464 .single()
1465 .unwrap_or_default();
1466 let instant = Utc
1467 .with_ymd_and_hms(2026, 2, 25, 19, 0, 0)
1468 .single()
1469 .unwrap_or_default();
1470 let meta = EventMetadata::with_instant(Some(ts), Some(instant), b"match data".to_vec());
1471 let serialized = serde_json::to_string(&meta)?;
1472 let deserialized: EventMetadata = serde_json::from_str(&serialized)?;
1473 assert_eq!(deserialized, meta);
1474 assert_eq!(deserialized.instant_utc(), Some(instant));
1475 Ok(())
1476 }
1477
1478 #[test]
1479 fn test_event_metadata_without_instant_utc_deserializes_to_none() -> TestResult {
1480 // Old payloads that do not contain the `instant_utc` field must
1481 // deserialize with instant_utc = None (backward compatibility via
1482 // #[serde(default)]).
1483 let json = serde_json::json!({
1484 "timestamp": "2026-02-25T12:00:00Z",
1485 "raw_bytes": BASE64_STANDARD.encode(b"old payload"),
1486 });
1487 let meta: EventMetadata = serde_json::from_value(json)?;
1488 assert!(meta.instant_utc().is_none());
1489 assert_eq!(meta.raw_bytes(), b"old payload");
1490 Ok(())
1491 }
1492
1493 #[test]
1494 fn test_event_metadata_new_instant_utc_is_none() {
1495 // EventMetadata::new() must always leave instant_utc = None.
1496 let meta = EventMetadata::new(None, b"data".to_vec());
1497 assert!(meta.instant_utc().is_none());
1498 }
1499
1500 #[test]
1501 fn test_event_metadata_with_instant_stores_instant_utc() {
1502 // with_instant() must store the provided instant_utc.
1503 let instant = Utc
1504 .with_ymd_and_hms(2026, 6, 19, 17, 37, 13)
1505 .single()
1506 .unwrap_or_default();
1507 let meta = EventMetadata::with_instant(None, Some(instant), b"data".to_vec());
1508 assert_eq!(meta.instant_utc(), Some(instant));
1509 }
1510
1511 #[test]
1512 fn test_event_metadata_local_timestamp_returns_naive() {
1513 // local_timestamp() must return the NaiveDateTime form of the stored
1514 // header value, without any timezone information.
1515 let ts = Utc
1516 .with_ymd_and_hms(2026, 2, 25, 12, 0, 0)
1517 .single()
1518 .unwrap_or_default();
1519 let meta = EventMetadata::new(Some(ts), b"data".to_vec());
1520 let naive = meta.local_timestamp();
1521 assert!(naive.is_some());
1522 let naive = naive.unwrap_or_default();
1523 assert_eq!(naive.year(), 2026);
1524 assert_eq!(naive.month(), 2);
1525 assert_eq!(naive.day(), 25);
1526 assert_eq!(naive.hour(), 12);
1527 }
1528
1529 // -- LogFileRotatedEvent --
1530
1531 #[test]
1532 fn test_log_file_rotated_for_rotation_stores_previous_file_size() {
1533 let ts = Utc
1534 .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1535 .single()
1536 .unwrap_or_default();
1537 let event = LogFileRotatedEvent::for_rotation(ts, 42_000);
1538 assert_eq!(event.previous_file_size(), Some(42_000));
1539 }
1540
1541 #[test]
1542 fn test_log_file_rotated_for_rotation_stores_timestamp() {
1543 let ts = Utc
1544 .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1545 .single()
1546 .unwrap_or_default();
1547 let event = LogFileRotatedEvent::for_rotation(ts, 1000);
1548 assert_eq!(event.metadata().local_timestamp(), Some(ts.naive_utc()));
1549 }
1550
1551 #[test]
1552 fn test_log_file_rotated_has_empty_raw_bytes() {
1553 let ts = Utc
1554 .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1555 .single()
1556 .unwrap_or_default();
1557 let event = LogFileRotatedEvent::for_rotation(ts, 500);
1558 assert!(event.metadata().raw_bytes().is_empty());
1559 }
1560
1561 #[test]
1562 fn test_log_file_rotated_serde_round_trip() -> TestResult {
1563 let ts = Utc
1564 .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1565 .single()
1566 .unwrap_or_default();
1567 let event = GameEvent::LogFileRotated(LogFileRotatedEvent::for_rotation(ts, 12345));
1568 let serialized = serde_json::to_string(&event)?;
1569 let deserialized: GameEvent = serde_json::from_str(&serialized)?;
1570 assert_eq!(deserialized, event);
1571 Ok(())
1572 }
1573
1574 #[test]
1575 fn test_log_file_rotated_performance_class_is_interactive() {
1576 let ts = Utc
1577 .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1578 .single()
1579 .unwrap_or_default();
1580 let event = GameEvent::LogFileRotated(LogFileRotatedEvent::for_rotation(ts, 0));
1581 assert_eq!(
1582 event.performance_class(),
1583 PerformanceClass::InteractiveDispatch
1584 );
1585 }
1586
1587 // -- DetailedLoggingStatusEvent --
1588
1589 #[test]
1590 fn test_detailed_logging_status_new_status_stores_enabled_true() {
1591 let ts = Utc
1592 .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1593 .single()
1594 .unwrap_or_default();
1595 let event = DetailedLoggingStatusEvent::new_status(ts, true);
1596 assert_eq!(event.enabled(), Some(true));
1597 }
1598
1599 #[test]
1600 fn test_detailed_logging_status_new_status_stores_enabled_false() {
1601 let ts = Utc
1602 .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1603 .single()
1604 .unwrap_or_default();
1605 let event = DetailedLoggingStatusEvent::new_status(ts, false);
1606 assert_eq!(event.enabled(), Some(false));
1607 }
1608
1609 #[test]
1610 fn test_detailed_logging_status_stores_timestamp() {
1611 let ts = Utc
1612 .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1613 .single()
1614 .unwrap_or_default();
1615 let event = DetailedLoggingStatusEvent::new_status(ts, true);
1616 assert_eq!(event.metadata().local_timestamp(), Some(ts.naive_utc()));
1617 }
1618
1619 #[test]
1620 fn test_detailed_logging_status_has_empty_raw_bytes() {
1621 let ts = Utc
1622 .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1623 .single()
1624 .unwrap_or_default();
1625 let event = DetailedLoggingStatusEvent::new_status(ts, false);
1626 assert!(event.metadata().raw_bytes().is_empty());
1627 }
1628
1629 #[test]
1630 fn test_detailed_logging_status_serde_round_trip() -> TestResult {
1631 let ts = Utc
1632 .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1633 .single()
1634 .unwrap_or_default();
1635 let event =
1636 GameEvent::DetailedLoggingStatus(DetailedLoggingStatusEvent::new_status(ts, false));
1637 let serialized = serde_json::to_string(&event)?;
1638 let deserialized: GameEvent = serde_json::from_str(&serialized)?;
1639 assert_eq!(deserialized, event);
1640 Ok(())
1641 }
1642
1643 #[test]
1644 fn test_detailed_logging_status_performance_class_is_interactive() {
1645 let ts = Utc
1646 .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1647 .single()
1648 .unwrap_or_default();
1649 let event =
1650 GameEvent::DetailedLoggingStatus(DetailedLoggingStatusEvent::new_status(ts, true));
1651 assert_eq!(
1652 event.performance_class(),
1653 PerformanceClass::InteractiveDispatch
1654 );
1655 }
1656
1657 // -- TruncationEvent --
1658
1659 fn make_truncation(object_count: u32, annotation_count: u32) -> TruncationEvent {
1660 let ts = Utc
1661 .with_ymd_and_hms(2026, 5, 13, 10, 1, 12)
1662 .single()
1663 .unwrap_or_default();
1664 TruncationEvent::new_truncation(Some(ts), object_count, annotation_count)
1665 }
1666
1667 #[test]
1668 fn test_truncation_event_payload_contains_object_count() {
1669 let event = make_truncation(63, 4);
1670 assert_eq!(event.payload()["object_count"], 63);
1671 }
1672
1673 #[test]
1674 fn test_truncation_event_payload_contains_annotation_count() {
1675 let event = make_truncation(63, 4);
1676 assert_eq!(event.payload()["annotation_count"], 4);
1677 }
1678
1679 #[test]
1680 fn test_truncation_event_metadata_has_empty_raw_bytes() {
1681 // Following LogFileRotated / DetailedLoggingStatus precedent —
1682 // marker text is fixed-form, so raw_bytes adds no fingerprint value.
1683 let event = make_truncation(63, 4);
1684 assert!(event.metadata().raw_bytes().is_empty());
1685 }
1686
1687 #[test]
1688 fn test_truncation_event_metadata_carries_timestamp() {
1689 let event = make_truncation(63, 4);
1690 assert!(event.metadata().local_timestamp().is_some());
1691 }
1692
1693 #[test]
1694 fn test_truncation_event_accepts_missing_timestamp() {
1695 // Router passes Option<DateTime> through to the constructor — entries
1696 // with unparseable timestamps must still produce the event.
1697 let event = TruncationEvent::new_truncation(None, 51, 0);
1698 assert!(event.metadata().local_timestamp().is_none());
1699 assert_eq!(event.object_count(), Some(51));
1700 }
1701
1702 #[test]
1703 fn test_truncation_event_object_count_accessor() {
1704 assert_eq!(make_truncation(63, 4).object_count(), Some(63));
1705 }
1706
1707 #[test]
1708 fn test_truncation_event_annotation_count_accessor() {
1709 assert_eq!(make_truncation(63, 4).annotation_count(), Some(4));
1710 }
1711
1712 #[test]
1713 fn test_truncation_event_performance_class_is_interactive() {
1714 let event = GameEvent::Truncation(make_truncation(63, 4));
1715 assert_eq!(
1716 event.performance_class(),
1717 PerformanceClass::InteractiveDispatch
1718 );
1719 }
1720
1721 #[test]
1722 fn test_truncation_event_serialize_round_trip() -> TestResult {
1723 let event = GameEvent::Truncation(make_truncation(63, 4));
1724 let serialized = serde_json::to_string(&event)?;
1725 let deserialized: GameEvent = serde_json::from_str(&serialized)?;
1726 assert_eq!(deserialized, event);
1727 Ok(())
1728 }
1729}