Skip to main content

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}