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, 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::GameResult(e) => e.$method(),
126            Self::LogFileRotated(e) => e.$method(),
127            Self::DetailedLoggingStatus(e) => e.$method(),
128            Self::MatchConnectionState(e) => e.$method(),
129            Self::TcpConnectionClose(e) => e.$method(),
130            Self::WebSocketClosed(e) => e.$method(),
131            Self::ConnectionError(e) => e.$method(),
132        }
133    };
134}
135
136// ---------------------------------------------------------------------------
137// GameEvent enum
138// ---------------------------------------------------------------------------
139
140/// A parsed MTG Arena log event.
141///
142/// Each variant wraps a category-specific struct containing parsed fields,
143/// the original raw log bytes, and a precomputed payload hash. Consumers
144/// subscribe to the event bus and pattern-match on this enum.
145///
146/// Marked `#[non_exhaustive]` so that new event categories can be added
147/// in future releases without a breaking change for downstream consumers.
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149#[non_exhaustive]
150pub enum GameEvent {
151    /// GRE-to-client messages: `GameStateMessage`, `ConnectResp`,
152    /// `QueuedGameStateMessage`. Class 1 — interactive dispatch.
153    GameState(GameStateEvent),
154
155    /// Client-to-GRE messages: `SelectNResp`, `SubmitDeckResp`,
156    /// `MulliganResp`. Class 1 — interactive dispatch.
157    ClientAction(ClientActionEvent),
158
159    /// Match room state changes (`matchGameRoomStateChangedEvent`).
160    /// Class 1 — interactive dispatch.
161    MatchState(MatchStateEvent),
162
163    /// Bot draft events (`<== BotDraftDraftStatus`, `<== BotDraftDraftPick`,
164    /// `==> BotDraftDraftPick`).
165    /// Class 2 — durable per-event.
166    DraftBot(DraftBotEvent),
167
168    /// Human draft picks (`Draft.Notify`, `EventPlayerDraftMakePick`).
169    /// Class 2 — durable per-event.
170    DraftHuman(DraftHumanEvent),
171
172    /// Draft completion (`Draft_CompleteDraft`).
173    /// Class 2 — durable per-event.
174    DraftComplete(DraftCompleteEvent),
175
176    /// Event lifecycle: `==> EventJoin`, `==> EventClaimPrize`,
177    /// `==> EventEnterPairing`. Class 2 — durable per-event.
178    EventLifecycle(EventLifecycleEvent),
179
180    /// Session: login, account identity, logout.
181    /// Class 2 — durable per-event.
182    Session(SessionEvent),
183
184    /// Rank snapshot (`<== RankGetCombinedRankInfo`).
185    /// Class 2 — durable per-event.
186    Rank(RankEvent),
187
188    /// Deck snapshot (`<== StartHook` with `DeckSummaries` and `Decks`).
189    /// Class 2 — durable per-event.
190    DeckCollection(DeckCollectionEvent),
191
192    /// Inventory snapshot (`<== StartHook` with `InventoryInfo`):
193    /// currency, wildcards, etc. Class 2 — durable per-event.
194    Inventory(InventoryEvent),
195
196    /// Game result (`GameStage_GameOver` from GRE `GameStateMessage`).
197    /// Class 3 — triggers post-game batch assembly.
198    GameResult(GameResultEvent),
199
200    /// Log file rotation detected — `Player.log` was replaced (MTGA restart).
201    ///
202    /// Emitted by the file tailer when it detects that the log file at the
203    /// monitored path has been replaced (file size shrinkage or mtime jump).
204    /// Downstream consumers should reset their state for a new session.
205    /// Class 1 — interactive dispatch (local reset signal).
206    LogFileRotated(LogFileRotatedEvent),
207
208    /// Detailed logging status change detected.
209    ///
210    /// Emitted by the file tailer when it determines whether Arena's
211    /// "Detailed Logs (Plugin Support)" setting is enabled. `enabled: false`
212    /// is emitted after 30 seconds of observed log writes without any
213    /// `[UnityCrossThreadLogger]` or `[Client GRE]` headers. `enabled: true`
214    /// is emitted if structured headers are later detected (user enabled the
215    /// setting and restarted Arena).
216    /// Class 1 — interactive dispatch (local status signal).
217    DetailedLoggingStatus(DetailedLoggingStatusEvent),
218
219    /// Match connection state machine transition (`STATE CHANGED`).
220    ///
221    /// Parsed from `[UnityCrossThreadLogger]STATE CHANGED {"old":"...","new":"..."}`
222    /// entries. Payload is `{"old": "<state>", "new": "<state>"}`. Drives the
223    /// connection health indicator (AC-DET-1) — the definitive signal for
224    /// local-client disconnect detection.
225    /// Class 1 — interactive dispatch.
226    MatchConnectionState(MatchConnectionStateEvent),
227
228    /// TCP connection close event (`Client.TcpConnection.Close`).
229    ///
230    /// Parsed from `[UnityCrossThreadLogger]Client.TcpConnection.Close {...}`
231    /// entries. The payload is the full parsed JSON from the log line,
232    /// preserving `status`, `reason`, and abnormal-close-only fields
233    /// (`function`, `description`, `exception`). Feeds the desktop
234    /// connection health monitor (AC-DET-2); the parser is agnostic to
235    /// `status` semantics (per ADR-011).
236    /// Class 1 — interactive dispatch.
237    TcpConnectionClose(TcpConnectionCloseEvent),
238
239    /// WebSocket close event (`GREConnection.HandleWebSocketClosed`).
240    ///
241    /// Parsed from
242    /// `[UnityCrossThreadLogger]GREConnection.HandleWebSocketClosed {...}`
243    /// entries. The payload is the full parsed JSON from the log line,
244    /// which always includes `closeType`, `reason`, and a nested `tcpConn`
245    /// object snapshot of the paired TCP connection. Feeds the desktop
246    /// connection health monitor (AC-DET-3); the parser is agnostic to
247    /// `closeType` semantics (per ADR-011).
248    /// Class 1 — interactive dispatch.
249    WebSocketClosed(WebSocketClosedEvent),
250
251    /// Connection error event (error-path markers).
252    ///
253    /// Parsed from four JSON-bearing markers under `[UnityCrossThreadLogger]`:
254    /// `TcpConnection.ProcessRead.Exception`,
255    /// `Client.TcpConnection.ProcessFailure`,
256    /// `GREConnection.MatchDoorConnectionError`, and
257    /// `TcpConnection.Close.Exception`. Each variant is discriminated by a
258    /// stable `error_type` string and wraps the full parsed JSON under a
259    /// `payload` key. Feeds the desktop connection health monitor (AC-DET-5);
260    /// the parser is agnostic to inner error-code semantics (per ADR-011).
261    /// Class 1 — interactive dispatch.
262    ConnectionError(ConnectionErrorEvent),
263}
264
265impl GameEvent {
266    /// Returns the performance class for this event.
267    ///
268    /// - Class 1: interactive dispatch (local, ≤ 100 ms)
269    /// - Class 2: durable per-event upload
270    /// - Class 3: post-game batch upload trigger
271    pub fn performance_class(&self) -> PerformanceClass {
272        match self {
273            Self::GameState(_)
274            | Self::ClientAction(_)
275            | Self::MatchState(_)
276            | Self::LogFileRotated(_)
277            | Self::DetailedLoggingStatus(_)
278            | Self::MatchConnectionState(_)
279            | Self::TcpConnectionClose(_)
280            | Self::WebSocketClosed(_)
281            | Self::ConnectionError(_) => PerformanceClass::InteractiveDispatch,
282            Self::DraftBot(_)
283            | Self::DraftHuman(_)
284            | Self::DraftComplete(_)
285            | Self::EventLifecycle(_)
286            | Self::Session(_)
287            | Self::Rank(_)
288            | Self::DeckCollection(_)
289            | Self::Inventory(_) => PerformanceClass::DurablePerEvent,
290            Self::GameResult(_) => PerformanceClass::PostGameBatch,
291        }
292    }
293
294    /// Returns the shared metadata common to all events.
295    pub fn metadata(&self) -> &EventMetadata {
296        delegate_to_inner!(self, metadata)
297    }
298
299    /// Returns the parsed JSON payload of the event.
300    pub fn payload(&self) -> &serde_json::Value {
301        delegate_to_inner!(self, payload)
302    }
303}
304
305// ---------------------------------------------------------------------------
306// PerformanceClass
307// ---------------------------------------------------------------------------
308
309/// Performance class determining latency target and delivery path.
310///
311/// See the [feature spec performance classes][spec] for details.
312///
313/// [spec]: https://github.com/manasight/manasight-docs/blob/main/docs/requirements/feature-specs/log-file-parser.md#performance-classes
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
315#[non_exhaustive]
316pub enum PerformanceClass {
317    /// Class 1: local-only, ≤ 100 ms latency. Also accumulated for Class 3.
318    InteractiveDispatch,
319    /// Class 2: persisted to disk queue, uploaded individually, ≤ 1 s.
320    DurablePerEvent,
321    /// Class 3: triggers assembly and upload of the complete game batch.
322    PostGameBatch,
323}
324
325impl PerformanceClass {
326    /// Returns the numeric class identifier (1, 2, or 3).
327    ///
328    /// Useful for logging, metrics, and wire-format tagging where a compact
329    /// integer representation is preferred over the enum variant name.
330    pub fn as_class_number(&self) -> u8 {
331        match self {
332            Self::InteractiveDispatch => 1,
333            Self::DurablePerEvent => 2,
334            Self::PostGameBatch => 3,
335        }
336    }
337
338    /// Returns `true` if events in this class must be persisted to durable
339    /// storage (disk queue or disk-backed buffer) before being considered
340    /// processed.
341    ///
342    /// Class 2 events are individually persisted to a disk queue for
343    /// per-event upload. Class 3 events trigger batch assembly from a
344    /// disk-backed game buffer. Class 1 events are local-only and do not
345    /// require durable storage (though they are also accumulated into the
346    /// Class 3 buffer asynchronously).
347    pub fn requires_durable_storage(&self) -> bool {
348        match self {
349            Self::InteractiveDispatch => false,
350            Self::DurablePerEvent | Self::PostGameBatch => true,
351        }
352    }
353
354    /// Returns `true` if this class triggers post-game batch assembly.
355    ///
356    /// Only Class 3 (`PostGameBatch`) triggers the assembly and upload of
357    /// the accumulated game buffer. Downstream consumers use this to know
358    /// when to finalize and ship the game record.
359    pub fn is_batch_trigger(&self) -> bool {
360        matches!(self, Self::PostGameBatch)
361    }
362}
363
364// ---------------------------------------------------------------------------
365// EventMetadata
366// ---------------------------------------------------------------------------
367
368/// Fields shared by every event: timestamp, raw bytes, and raw-bytes hash.
369///
370/// Constructed via [`EventMetadata::new`], which computes the `raw_bytes_hash`
371/// from `raw_bytes` to enforce the invariant that the hash always matches.
372/// This is critical for server-side deduplication via event fingerprints.
373///
374/// The `timestamp` is `Option<DateTime<Utc>>` because some log entries lack
375/// a parseable timestamp in the header. `None` means "no timestamp found in
376/// the log entry" — downstream consumers must handle this explicitly rather
377/// than receiving a synthetic `Utc::now()` that would break fingerprinting
378/// and chronological ordering.
379///
380/// All fields are private to protect the hash invariant. Use the accessor
381/// methods to read them.
382///
383/// Deserialization also enforces this invariant: the hash is recomputed from
384/// `raw_bytes` during deserialization rather than trusting the serialized value.
385#[derive(Debug, Clone, PartialEq, Serialize)]
386pub struct EventMetadata {
387    /// UTC timestamp parsed from the log entry header, or `None` if the
388    /// entry did not contain a parseable timestamp.
389    timestamp: Option<DateTime<Utc>>,
390
391    /// Original log entry bytes, serialized as base64. Private to prevent
392    /// mutation that would break the `raw_bytes_hash` invariant.
393    #[serde(with = "base64_serde")]
394    raw_bytes: Vec<u8>,
395
396    /// SHA-256 hash of `raw_bytes`, serialized as lowercase hex.
397    /// Precomputed at construction time. Used as part of the event
398    /// fingerprint for server-side deduplication.
399    #[serde(with = "hex_serde")]
400    raw_bytes_hash: [u8; 32],
401}
402
403impl EventMetadata {
404    /// Creates a new `EventMetadata`, computing `raw_bytes_hash` as the
405    /// SHA-256 digest of `raw_bytes`.
406    ///
407    /// `timestamp` is `None` when the log entry header did not contain a
408    /// parseable timestamp. This preserves the distinction between "real
409    /// timestamp from the log" and "no timestamp available" for downstream
410    /// consumers.
411    pub fn new(timestamp: Option<DateTime<Utc>>, raw_bytes: Vec<u8>) -> Self {
412        let raw_bytes_hash: [u8; 32] = Sha256::digest(&raw_bytes).into();
413        Self {
414            timestamp,
415            raw_bytes,
416            raw_bytes_hash,
417        }
418    }
419
420    /// Returns the UTC timestamp parsed from the log entry header, or
421    /// `None` if the entry did not contain a parseable timestamp.
422    pub fn timestamp(&self) -> Option<DateTime<Utc>> {
423        self.timestamp
424    }
425
426    /// Returns the original log entry bytes.
427    pub fn raw_bytes(&self) -> &[u8] {
428        &self.raw_bytes
429    }
430
431    /// Returns the SHA-256 hash of `raw_bytes`.
432    pub fn raw_bytes_hash(&self) -> &[u8; 32] {
433        &self.raw_bytes_hash
434    }
435}
436
437/// Custom `Deserialize` that recomputes `raw_bytes_hash` from `raw_bytes`,
438/// ensuring the hash invariant survives serialization round-trips.
439impl<'de> Deserialize<'de> for EventMetadata {
440    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
441    where
442        D: serde::Deserializer<'de>,
443    {
444        /// Wire format for deserializing `EventMetadata`. The
445        /// `raw_bytes_hash` field is optional and discarded — the real
446        /// hash is always recomputed from `raw_bytes`.
447        #[derive(Deserialize)]
448        struct EventMetadataWire {
449            timestamp: Option<DateTime<Utc>>,
450            #[serde(with = "base64_serde")]
451            raw_bytes: Vec<u8>,
452            // Accepts any format (hex string, integer array) or absence.
453            // The value is discarded — hash is always recomputed.
454            #[serde(default, rename = "raw_bytes_hash")]
455            _raw_bytes_hash: serde::de::IgnoredAny,
456        }
457
458        let wire = EventMetadataWire::deserialize(deserializer)?;
459        Ok(EventMetadata::new(wire.timestamp, wire.raw_bytes))
460    }
461}
462
463// ---------------------------------------------------------------------------
464// Class 1: Interactive Dispatch
465// ---------------------------------------------------------------------------
466
467define_event! {
468    /// GRE-to-client game state messages.
469    ///
470    /// Covers `GameStateMessage`, `ConnectResp`, and `QueuedGameStateMessage`
471    /// payloads from `greToClientEvent` entries.
472    GameStateEvent
473}
474
475define_event! {
476    /// Client-to-GRE player actions.
477    ///
478    /// Covers `SelectNResp`, `SubmitDeckResp`, `MulliganResp`, and other
479    /// `ClientToGREMessage` payloads.
480    ClientActionEvent
481}
482
483define_event! {
484    /// Match room state transitions.
485    ///
486    /// Parsed from `matchGameRoomStateChangedEvent` entries. Signals match
487    /// start/end and triggers overlay state transitions.
488    MatchStateEvent
489}
490
491// ---------------------------------------------------------------------------
492// Class 2: Durable Per-Event
493// ---------------------------------------------------------------------------
494
495define_event! {
496    /// Bot draft events.
497    ///
498    /// Parsed from `BotDraftDraftStatus` and `BotDraftDraftPick` request and
499    /// response entries. Each pick is independently valuable and must survive
500    /// crashes.
501    DraftBotEvent
502}
503
504define_event! {
505    /// Human draft pick events.
506    ///
507    /// Parsed from `Draft.Notify`, `EventPlayerDraftMakePick`, and
508    /// `LogBusinessEvents` entries containing `PickGrpId`.
509    DraftHumanEvent
510}
511
512define_event! {
513    /// Draft completion event.
514    ///
515    /// Parsed from `Draft_CompleteDraft`. Links the draft ID to the event
516    /// and marks the draft as finished.
517    DraftCompleteEvent
518}
519
520define_event! {
521    /// Event lifecycle transitions.
522    ///
523    /// Covers `==> EventJoin`, `==> EventClaimPrize`, and
524    /// `==> EventEnterPairing`. Each is independently meaningful.
525    EventLifecycleEvent
526}
527
528define_event! {
529    /// Session identity and connection events.
530    ///
531    /// Covers `Updated account. DisplayName:`, `authenticateResponse`,
532    /// and `FrontDoorConnection.Close`. Needed to tag all subsequent events
533    /// with player identity.
534    SessionEvent
535}
536
537define_event! {
538    /// Rank snapshot.
539    ///
540    /// Parsed from `<== RankGetCombinedRankInfo`. Infrequent, small,
541    /// independently useful.
542    RankEvent
543}
544
545define_event! {
546    /// Deck snapshot.
547    ///
548    /// Parsed from `<== StartHook` responses containing both `DeckSummaries`
549    /// and `Decks`. Correlates per-deck metadata with the associated deck
550    /// list payload when a matching `DeckId` is available.
551    DeckCollectionEvent
552}
553
554define_event! {
555    /// Inventory snapshot.
556    ///
557    /// Parsed from `<== StartHook` responses containing `InventoryInfo`.
558    /// Contains currency, wildcards, boosters, and vault progress.
559    InventoryEvent
560}
561
562// ---------------------------------------------------------------------------
563// Class 3: Post-Game Batch
564// ---------------------------------------------------------------------------
565
566define_event! {
567    /// Game result event — triggers post-game batch assembly.
568    ///
569    /// Parsed from `LogBusinessEvents` with `WinningType` and
570    /// `GameStage_GameOver`. When this event fires, the desktop app
571    /// serializes the disk-backed game buffer into a single compressed
572    /// payload and uploads it.
573    GameResultEvent
574}
575
576// ---------------------------------------------------------------------------
577// Infrastructure events
578// ---------------------------------------------------------------------------
579
580define_event! {
581    /// Log file rotation event.
582    ///
583    /// Emitted when the file tailer detects that `Player.log` was replaced
584    /// (MTGA restart). The payload contains `previous_file_size` — the byte
585    /// offset in the old file at the time rotation was detected.
586    ///
587    /// Unlike parsed log events, `raw_bytes` in the metadata is empty and
588    /// the timestamp reflects when the rotation was detected (wall-clock),
589    /// not a timestamp parsed from the log.
590    LogFileRotatedEvent
591}
592
593impl LogFileRotatedEvent {
594    /// Creates a rotation event with the given detection timestamp and the
595    /// byte offset in the old file.
596    pub fn for_rotation(timestamp: DateTime<Utc>, previous_file_size: u64) -> Self {
597        let metadata = EventMetadata::new(Some(timestamp), Vec::new());
598        let payload = serde_json::json!({ "previous_file_size": previous_file_size });
599        Self::new(metadata, payload)
600    }
601
602    /// Returns the byte offset in the old file when rotation was detected.
603    ///
604    /// Returns `None` only if the payload was manually constructed without
605    /// the `previous_file_size` field (not expected in normal usage).
606    pub fn previous_file_size(&self) -> Option<u64> {
607        self.payload()["previous_file_size"].as_u64()
608    }
609}
610
611define_event! {
612    /// Detailed logging status event.
613    ///
614    /// Emitted when the file tailer detects whether Arena's "Detailed Logs
615    /// (Plugin Support)" setting is enabled. The payload contains `enabled`
616    /// — `false` after 30 seconds of log writes without structured headers,
617    /// `true` when structured headers are subsequently detected.
618    ///
619    /// Like `LogFileRotatedEvent`, `raw_bytes` in the metadata is empty and
620    /// the timestamp reflects wall-clock detection time.
621    DetailedLoggingStatusEvent
622}
623
624impl DetailedLoggingStatusEvent {
625    /// Creates a detailed logging status event.
626    pub fn new_status(timestamp: DateTime<Utc>, enabled: bool) -> Self {
627        let metadata = EventMetadata::new(Some(timestamp), Vec::new());
628        let payload = serde_json::json!({ "enabled": enabled });
629        Self::new(metadata, payload)
630    }
631
632    /// Returns whether detailed logging is enabled.
633    ///
634    /// Returns `None` only if the payload was manually constructed without
635    /// the `enabled` field (not expected in normal usage).
636    pub fn enabled(&self) -> Option<bool> {
637        self.payload()["enabled"].as_bool()
638    }
639}
640
641define_event! {
642    /// Match connection state machine transition event.
643    ///
644    /// Parsed from `[UnityCrossThreadLogger]STATE CHANGED {...}` entries.
645    /// The payload is the JSON object `{"old": "<state>", "new": "<state>"}`
646    /// where each state is one of the values observed in the MTGA match
647    /// connection state machine (e.g., `None`, `ConnectedToMatchDoor`,
648    /// `ConnectedToMatchDoor_ConnectingToGRE`,
649    /// `ConnectedToMatchDoor_ConnectedToGRE_Waiting`, `Playing`,
650    /// `MatchCompleted`, `Disconnected`).
651    ///
652    /// Feeds the desktop connection health monitor; see feature spec
653    /// `connection-health-indicator.md` **AC-DET-1**.
654    MatchConnectionStateEvent
655}
656
657define_event! {
658    /// TCP connection close event.
659    ///
660    /// Parsed from `[UnityCrossThreadLogger]Client.TcpConnection.Close {...}`
661    /// entries. The payload is the full parsed JSON from the log line and
662    /// carries at minimum `status` and `reason`; abnormal closes also
663    /// include `function`, `description`, and a nested `exception` tree
664    /// (with `InnerException.NativeErrorCode` on Windows/macOS).
665    ///
666    /// The parser is agnostic to `status` semantics — downstream consumers
667    /// classify close types per ADR-011. Bare-marker entries (no JSON
668    /// payload) do not produce this event.
669    ///
670    /// Feeds the desktop connection health monitor; see feature spec
671    /// `connection-health-indicator.md` **AC-DET-2**.
672    TcpConnectionCloseEvent
673}
674
675define_event! {
676    /// WebSocket close event.
677    ///
678    /// Parsed from
679    /// `[UnityCrossThreadLogger]GREConnection.HandleWebSocketClosed {...}`
680    /// entries. The payload is the full parsed JSON from the log line and
681    /// always includes `closeType`, `reason`, and a nested `tcpConn`
682    /// object snapshot of the paired TCP connection (host/port/timing/ping
683    /// stats).
684    ///
685    /// The parser is agnostic to `closeType` semantics — downstream
686    /// consumers classify close types per ADR-011.
687    ///
688    /// Feeds the desktop connection health monitor; see feature spec
689    /// `connection-health-indicator.md` **AC-DET-3**.
690    WebSocketClosedEvent
691}
692
693define_event! {
694    /// Connection error event (error-path markers).
695    ///
696    /// Parsed from four JSON-bearing error markers under
697    /// `[UnityCrossThreadLogger]`:
698    ///
699    /// | Marker | `error_type` |
700    /// |--------|--------------|
701    /// | `TcpConnection.ProcessRead.Exception` | `tcp_process_read_exception` |
702    /// | `Client.TcpConnection.ProcessFailure` | `tcp_process_failure_socket_error` |
703    /// | `GREConnection.MatchDoorConnectionError` | `gre_match_door_connection_error` |
704    /// | `TcpConnection.Close.Exception` | `tcp_close_exception` |
705    ///
706    /// The payload shape is
707    /// `{"error_type": "<discriminant>", "payload": <parsed>}`, where
708    /// `<parsed>` is the full parsed JSON from the log line preserved
709    /// unchanged. Bare-marker entries (no JSON payload) do not produce this
710    /// event; the paired JSON line on a subsequent entry emits it.
711    ///
712    /// The parser is agnostic to inner error-code semantics — downstream
713    /// consumers match on `error_type` per ADR-011.
714    ///
715    /// Feeds the desktop connection health monitor; see feature spec
716    /// `connection-health-indicator.md` **AC-DET-5**.
717    ConnectionErrorEvent
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use base64::prelude::{Engine as _, BASE64_STANDARD};
724    use chrono::{Datelike, TimeZone};
725
726    type TestResult = Result<(), Box<dyn std::error::Error>>;
727
728    /// Helper: build an `EventMetadata` with a fixed timestamp and the
729    /// given raw bytes.
730    ///
731    /// UTC datetimes are never ambiguous so `single()` always returns
732    /// `Some`. Uses `unwrap_or_default()` because `clippy::expect_used`
733    /// is denied in `Cargo.toml [lints.clippy]` — verified: this applies
734    /// crate-wide including `#[cfg(test)]` code under `--all-targets`.
735    /// The epoch fallback (1970-01-01) would visibly fail any timestamp
736    /// assertion rather than passing silently.
737    fn make_metadata(raw: &[u8]) -> EventMetadata {
738        let timestamp = Utc
739            .with_ymd_and_hms(2026, 2, 25, 12, 0, 0)
740            .single()
741            .unwrap_or_default();
742        EventMetadata::new(Some(timestamp), raw.to_vec())
743    }
744
745    /// Helper: build all `GameEvent` variants for exhaustive testing.
746    ///
747    /// Must stay in sync with `GameEvent` variants. Compile-time
748    /// exhaustiveness is enforced by `performance_class()` and
749    /// `delegate_to_inner!`; this array is the test-only counterpart.
750    fn all_variants() -> Vec<GameEvent> {
751        let meta = make_metadata(b"test");
752        let payload = serde_json::json!({});
753        vec![
754            GameEvent::GameState(GameStateEvent::new(meta.clone(), payload.clone())),
755            GameEvent::ClientAction(ClientActionEvent::new(meta.clone(), payload.clone())),
756            GameEvent::MatchState(MatchStateEvent::new(meta.clone(), payload.clone())),
757            GameEvent::DraftBot(DraftBotEvent::new(meta.clone(), payload.clone())),
758            GameEvent::DraftHuman(DraftHumanEvent::new(meta.clone(), payload.clone())),
759            GameEvent::DraftComplete(DraftCompleteEvent::new(meta.clone(), payload.clone())),
760            GameEvent::EventLifecycle(EventLifecycleEvent::new(meta.clone(), payload.clone())),
761            GameEvent::Session(SessionEvent::new(meta.clone(), payload.clone())),
762            GameEvent::Rank(RankEvent::new(meta.clone(), payload.clone())),
763            GameEvent::DeckCollection(DeckCollectionEvent::new(meta.clone(), payload.clone())),
764            GameEvent::Inventory(InventoryEvent::new(meta.clone(), payload.clone())),
765            GameEvent::GameResult(GameResultEvent::new(meta.clone(), payload.clone())),
766            GameEvent::LogFileRotated(LogFileRotatedEvent::new(meta.clone(), payload.clone())),
767            GameEvent::DetailedLoggingStatus(DetailedLoggingStatusEvent::new(
768                meta.clone(),
769                payload.clone(),
770            )),
771            GameEvent::MatchConnectionState(MatchConnectionStateEvent::new(
772                meta.clone(),
773                payload.clone(),
774            )),
775            GameEvent::TcpConnectionClose(TcpConnectionCloseEvent::new(
776                meta.clone(),
777                payload.clone(),
778            )),
779            GameEvent::WebSocketClosed(WebSocketClosedEvent::new(meta.clone(), payload.clone())),
780            GameEvent::ConnectionError(ConnectionErrorEvent::new(meta.clone(), payload.clone())),
781        ]
782    }
783
784    // -- EventMetadata construction --
785
786    #[test]
787    fn test_event_metadata_new_stores_raw_bytes() {
788        let raw = b"[UnityCrossThreadLogger]some log line";
789        let meta = make_metadata(raw);
790        assert_eq!(meta.raw_bytes(), raw);
791    }
792
793    #[test]
794    fn test_event_metadata_new_computes_raw_bytes_hash() {
795        let raw = b"test payload";
796        let meta = make_metadata(raw);
797        let expected: [u8; 32] = Sha256::digest(raw).into();
798        assert_eq!(*meta.raw_bytes_hash(), expected);
799    }
800
801    #[test]
802    fn test_event_metadata_new_stores_timestamp() {
803        let meta = make_metadata(b"data");
804        let ts = meta.timestamp();
805        assert!(ts.is_some());
806        let ts = ts.unwrap_or_default();
807        assert_eq!(ts.year(), 2026);
808        assert_eq!(ts.month(), 2);
809    }
810
811    #[test]
812    fn test_event_metadata_new_enforces_hash_invariant() {
813        let raw = b"important data";
814        let meta = make_metadata(raw);
815        let expected: [u8; 32] = Sha256::digest(raw).into();
816        assert_eq!(
817            *meta.raw_bytes_hash(),
818            expected,
819            "raw_bytes_hash must always be SHA-256 of raw_bytes"
820        );
821    }
822
823    // -- EventMetadata properties --
824
825    #[test]
826    fn test_different_raw_bytes_produce_different_hashes() {
827        let meta1 = make_metadata(b"payload one");
828        let meta2 = make_metadata(b"payload two");
829        assert_ne!(meta1.raw_bytes_hash(), meta2.raw_bytes_hash());
830    }
831
832    #[test]
833    fn test_identical_raw_bytes_produce_same_hash() {
834        let meta1 = make_metadata(b"same payload");
835        let meta2 = make_metadata(b"same payload");
836        assert_eq!(meta1.raw_bytes_hash(), meta2.raw_bytes_hash());
837    }
838
839    #[test]
840    fn test_empty_raw_bytes_valid() {
841        let meta = make_metadata(b"");
842        assert!(meta.raw_bytes().is_empty());
843        let expected: [u8; 32] = Sha256::digest(b"").into();
844        assert_eq!(*meta.raw_bytes_hash(), expected);
845    }
846
847    #[test]
848    fn test_event_metadata_clone_is_equal() {
849        let meta = make_metadata(b"original");
850        let cloned = meta.clone();
851        assert_eq!(meta, cloned);
852    }
853
854    #[test]
855    fn test_event_metadata_timestamp_getter() {
856        let meta = make_metadata(b"data");
857        let ts = meta.timestamp();
858        assert!(ts.is_some());
859        let ts = ts.unwrap_or_default();
860        assert_eq!(ts.year(), 2026);
861        assert_eq!(ts.month(), 2);
862        assert_eq!(ts.day(), 25);
863    }
864
865    #[test]
866    fn test_event_metadata_none_timestamp() {
867        let meta = EventMetadata::new(None, b"data".to_vec());
868        assert!(meta.timestamp().is_none());
869    }
870
871    // -- Per-category struct field access (via accessors) --
872
873    #[test]
874    fn test_game_state_event_field_access() {
875        let event = GameStateEvent::new(
876            make_metadata(b"gre payload"),
877            serde_json::json!({"type": "GameStateMessage"}),
878        );
879        assert_eq!(event.payload()["type"], "GameStateMessage");
880        assert_eq!(event.metadata().raw_bytes(), b"gre payload");
881    }
882
883    #[test]
884    fn test_client_action_event_field_access() {
885        let event = ClientActionEvent::new(
886            make_metadata(b"client action"),
887            serde_json::json!({"type": "MulliganResp"}),
888        );
889        assert_eq!(event.payload()["type"], "MulliganResp");
890    }
891
892    #[test]
893    fn test_match_state_event_field_access() {
894        let event = MatchStateEvent::new(
895            make_metadata(b"match state"),
896            serde_json::json!(
897                {"matchGameRoomStateChangedEvent": {}}
898            ),
899        );
900        assert!(event.payload()["matchGameRoomStateChangedEvent"].is_object());
901    }
902
903    #[test]
904    fn test_draft_bot_event_field_access() {
905        let event = DraftBotEvent::new(
906            make_metadata(b"bot draft"),
907            serde_json::json!({"DraftStatus": "PickNext"}),
908        );
909        assert_eq!(event.payload()["DraftStatus"], "PickNext");
910    }
911
912    #[test]
913    fn test_draft_human_event_field_access() {
914        let event = DraftHumanEvent::new(
915            make_metadata(b"human draft"),
916            serde_json::json!({"PickGrpId": 12345}),
917        );
918        assert_eq!(event.payload()["PickGrpId"], 12345);
919    }
920
921    #[test]
922    fn test_draft_complete_event_field_access() {
923        let event = DraftCompleteEvent::new(
924            make_metadata(b"draft complete"),
925            serde_json::json!({"Draft_CompleteDraft": true}),
926        );
927        assert_eq!(
928            event.payload()["Draft_CompleteDraft"],
929            serde_json::json!(true)
930        );
931    }
932
933    #[test]
934    fn test_event_lifecycle_event_field_access() {
935        let event = EventLifecycleEvent::new(
936            make_metadata(b"event lifecycle"),
937            serde_json::json!({"action": "Event_Join"}),
938        );
939        assert_eq!(event.payload()["action"], "Event_Join");
940    }
941
942    #[test]
943    fn test_session_event_field_access() {
944        let event = SessionEvent::new(
945            make_metadata(b"session data"),
946            serde_json::json!({"DisplayName": "Player"}),
947        );
948        assert_eq!(event.payload()["DisplayName"], "Player");
949    }
950
951    #[test]
952    fn test_rank_event_field_access() {
953        let event = RankEvent::new(
954            make_metadata(b"rank data"),
955            serde_json::json!(
956                {"constructedClass": "Gold", "constructedLevel": 2}
957            ),
958        );
959        assert_eq!(event.payload()["constructedClass"], "Gold");
960    }
961
962    #[test]
963    fn test_deck_collection_event_field_access() {
964        let event = DeckCollectionEvent::new(
965            make_metadata(b"deck collection"),
966            serde_json::json!({
967                "type": "deck_collection_snapshot",
968                "decks": {
969                    "deck-1": {
970                        "DeckId": "deck-1",
971                        "Name": "Reanimator",
972                        "list": {"MainDeck": [{"cardId": 1, "quantity": 4}]}
973                    }
974                }
975            }),
976        );
977        assert_eq!(event.payload()["decks"]["deck-1"]["DeckId"], "deck-1");
978    }
979
980    #[test]
981    fn test_inventory_event_field_access() {
982        let event = InventoryEvent::new(
983            make_metadata(b"inventory"),
984            serde_json::json!(
985                {"gold": 5000, "gems": 200, "wcCommon": 10}
986            ),
987        );
988        assert_eq!(event.payload()["gold"], 5000);
989    }
990
991    #[test]
992    fn test_game_result_event_field_access() {
993        let event = GameResultEvent::new(
994            make_metadata(b"game result"),
995            serde_json::json!(
996                {"WinningType": "Win", "GameStage": "GameOver"}
997            ),
998        );
999        assert_eq!(event.payload()["WinningType"], "Win");
1000    }
1001
1002    // -- GameEvent enum --
1003
1004    #[test]
1005    fn test_game_event_all_variants_have_correct_performance_class() {
1006        let events = all_variants();
1007
1008        let expected_classes = [
1009            PerformanceClass::InteractiveDispatch, // GameState
1010            PerformanceClass::InteractiveDispatch, // ClientAction
1011            PerformanceClass::InteractiveDispatch, // MatchState
1012            PerformanceClass::DurablePerEvent,     // DraftBot
1013            PerformanceClass::DurablePerEvent,     // DraftHuman
1014            PerformanceClass::DurablePerEvent,     // DraftComplete
1015            PerformanceClass::DurablePerEvent,     // EventLifecycle
1016            PerformanceClass::DurablePerEvent,     // Session
1017            PerformanceClass::DurablePerEvent,     // Rank
1018            PerformanceClass::DurablePerEvent,     // DeckCollection
1019            PerformanceClass::DurablePerEvent,     // Inventory
1020            PerformanceClass::PostGameBatch,       // GameResult
1021            PerformanceClass::InteractiveDispatch, // LogFileRotated
1022            PerformanceClass::InteractiveDispatch, // DetailedLoggingStatus
1023            PerformanceClass::InteractiveDispatch, // MatchConnectionState
1024            PerformanceClass::InteractiveDispatch, // TcpConnectionClose
1025            PerformanceClass::InteractiveDispatch, // WebSocketClosed
1026            PerformanceClass::InteractiveDispatch, // ConnectionError
1027        ];
1028
1029        assert_eq!(
1030            events.len(),
1031            expected_classes.len(),
1032            "all_variants() and expected_classes must have the same length"
1033        );
1034        for (event, expected) in events.iter().zip(expected_classes.iter()) {
1035            assert_eq!(&event.performance_class(), expected);
1036        }
1037    }
1038
1039    #[test]
1040    fn test_game_event_metadata_accessor_all_variants() {
1041        let raw = b"test";
1042        let events = all_variants();
1043        for event in &events {
1044            assert_eq!(event.metadata().raw_bytes(), raw);
1045        }
1046    }
1047
1048    #[test]
1049    fn test_game_event_payload_accessor_all_variants() {
1050        let events = all_variants();
1051        let expected = serde_json::json!({});
1052        for event in &events {
1053            assert_eq!(*event.payload(), expected);
1054        }
1055    }
1056
1057    // -- PerformanceClass --
1058
1059    #[test]
1060    fn test_performance_class_equality() {
1061        assert_eq!(
1062            PerformanceClass::InteractiveDispatch,
1063            PerformanceClass::InteractiveDispatch
1064        );
1065        assert_ne!(
1066            PerformanceClass::InteractiveDispatch,
1067            PerformanceClass::DurablePerEvent
1068        );
1069        assert_ne!(
1070            PerformanceClass::DurablePerEvent,
1071            PerformanceClass::PostGameBatch
1072        );
1073    }
1074
1075    #[test]
1076    fn test_performance_class_as_class_number_interactive_dispatch_returns_1() {
1077        assert_eq!(PerformanceClass::InteractiveDispatch.as_class_number(), 1);
1078    }
1079
1080    #[test]
1081    fn test_performance_class_as_class_number_durable_per_event_returns_2() {
1082        assert_eq!(PerformanceClass::DurablePerEvent.as_class_number(), 2);
1083    }
1084
1085    #[test]
1086    fn test_performance_class_as_class_number_post_game_batch_returns_3() {
1087        assert_eq!(PerformanceClass::PostGameBatch.as_class_number(), 3);
1088    }
1089
1090    #[test]
1091    fn test_performance_class_requires_durable_storage_class1_false() {
1092        assert!(!PerformanceClass::InteractiveDispatch.requires_durable_storage());
1093    }
1094
1095    #[test]
1096    fn test_performance_class_requires_durable_storage_class2_true() {
1097        assert!(PerformanceClass::DurablePerEvent.requires_durable_storage());
1098    }
1099
1100    #[test]
1101    fn test_performance_class_requires_durable_storage_class3_true() {
1102        assert!(PerformanceClass::PostGameBatch.requires_durable_storage());
1103    }
1104
1105    #[test]
1106    fn test_performance_class_is_batch_trigger_class1_false() {
1107        assert!(!PerformanceClass::InteractiveDispatch.is_batch_trigger());
1108    }
1109
1110    #[test]
1111    fn test_performance_class_is_batch_trigger_class2_false() {
1112        assert!(!PerformanceClass::DurablePerEvent.is_batch_trigger());
1113    }
1114
1115    #[test]
1116    fn test_performance_class_is_batch_trigger_class3_true() {
1117        assert!(PerformanceClass::PostGameBatch.is_batch_trigger());
1118    }
1119
1120    #[test]
1121    fn test_performance_class_class_number_matches_event_mapping() {
1122        // Verify the class numbers align with the event-to-class mapping:
1123        // Class 1 events map to InteractiveDispatch (number 1)
1124        // Class 2 events map to DurablePerEvent (number 2)
1125        // Class 3 events map to PostGameBatch (number 3)
1126        let events = all_variants();
1127        let expected_numbers: Vec<u8> = vec![
1128            1, // GameState
1129            1, // ClientAction
1130            1, // MatchState
1131            2, // DraftBot
1132            2, // DraftHuman
1133            2, // DraftComplete
1134            2, // EventLifecycle
1135            2, // Session
1136            2, // Rank
1137            2, // DeckCollection
1138            2, // Inventory
1139            3, // GameResult
1140            1, // LogFileRotated
1141            1, // DetailedLoggingStatus
1142            1, // MatchConnectionState
1143            1, // TcpConnectionClose
1144            1, // WebSocketClosed
1145            1, // ConnectionError
1146        ];
1147        assert_eq!(events.len(), expected_numbers.len());
1148        for (event, expected_num) in events.iter().zip(expected_numbers.iter()) {
1149            assert_eq!(event.performance_class().as_class_number(), *expected_num);
1150        }
1151    }
1152
1153    // -- Serialization round-trip --
1154
1155    #[test]
1156    fn test_game_event_serde_round_trip_all_variants() -> TestResult {
1157        for event in all_variants() {
1158            let serialized = serde_json::to_string(&event)?;
1159            let deserialized: GameEvent = serde_json::from_str(&serialized)?;
1160            assert_eq!(deserialized, event);
1161        }
1162        Ok(())
1163    }
1164
1165    #[test]
1166    fn test_event_metadata_serde_round_trip() -> TestResult {
1167        let meta = make_metadata(b"round trip test");
1168        let serialized = serde_json::to_string(&meta)?;
1169        let deserialized: EventMetadata = serde_json::from_str(&serialized)?;
1170
1171        assert_eq!(deserialized, meta);
1172        Ok(())
1173    }
1174
1175    #[test]
1176    fn test_event_metadata_deserialize_recomputes_hash() -> TestResult {
1177        let meta = make_metadata(b"test data");
1178        let mut serialized: serde_json::Value = serde_json::to_value(&meta)?;
1179
1180        // Tamper with the serialized raw_bytes_hash (now a hex string)
1181        serialized["raw_bytes_hash"] = serde_json::json!("00".repeat(32));
1182
1183        let deserialized: EventMetadata = serde_json::from_value(serialized)?;
1184
1185        // Hash should be recomputed from raw_bytes, not the tampered
1186        // value
1187        assert_eq!(*deserialized.raw_bytes_hash(), *meta.raw_bytes_hash());
1188        assert_eq!(deserialized.raw_bytes(), meta.raw_bytes());
1189        Ok(())
1190    }
1191
1192    #[test]
1193    fn test_performance_class_serde_round_trip() -> TestResult {
1194        for class in [
1195            PerformanceClass::InteractiveDispatch,
1196            PerformanceClass::DurablePerEvent,
1197            PerformanceClass::PostGameBatch,
1198        ] {
1199            let serialized = serde_json::to_string(&class)?;
1200            let deserialized: PerformanceClass = serde_json::from_str(&serialized)?;
1201            assert_eq!(deserialized, class);
1202        }
1203        Ok(())
1204    }
1205
1206    // -- Wire format --
1207
1208    #[test]
1209    fn test_event_metadata_serializes_raw_bytes_as_base64() -> TestResult {
1210        let meta = make_metadata(b"hello world");
1211        let serialized: serde_json::Value = serde_json::to_value(&meta)?;
1212        assert_eq!(serialized["raw_bytes"], "aGVsbG8gd29ybGQ=");
1213        Ok(())
1214    }
1215
1216    #[test]
1217    fn test_event_metadata_serializes_raw_bytes_hash_as_hex() -> TestResult {
1218        let meta = make_metadata(b"hello world");
1219        let serialized: serde_json::Value = serde_json::to_value(&meta)?;
1220        let hash_str = serialized["raw_bytes_hash"]
1221            .as_str()
1222            .ok_or("raw_bytes_hash should be a string")?;
1223        // Known SHA-256 of "hello world"
1224        assert_eq!(
1225            hash_str,
1226            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
1227        );
1228        Ok(())
1229    }
1230
1231    #[test]
1232    fn test_event_metadata_deserialize_missing_raw_bytes_hash() -> TestResult {
1233        // Forward-compatibility: raw_bytes_hash absent from wire format
1234        let json = serde_json::json!({
1235            "timestamp": "2026-02-25T12:00:00Z",
1236            "raw_bytes": BASE64_STANDARD.encode(b"test data"),
1237        });
1238        let meta: EventMetadata = serde_json::from_value(json)?;
1239        let expected: [u8; 32] = Sha256::digest(b"test data").into();
1240        assert_eq!(*meta.raw_bytes_hash(), expected);
1241        assert_eq!(meta.raw_bytes(), b"test data");
1242        Ok(())
1243    }
1244
1245    #[test]
1246    fn test_event_metadata_deserialize_integer_array_raw_bytes_hash() -> TestResult {
1247        // Backward-compatibility: raw_bytes_hash in old integer array
1248        // format
1249        let json = serde_json::json!({
1250            "timestamp": "2026-02-25T12:00:00Z",
1251            "raw_bytes": BASE64_STANDARD.encode(b"data"),
1252            "raw_bytes_hash": vec![0; 32],
1253        });
1254        let meta: EventMetadata = serde_json::from_value(json)?;
1255        // Hash is recomputed, not taken from wire
1256        let expected: [u8; 32] = Sha256::digest(b"data").into();
1257        assert_eq!(*meta.raw_bytes_hash(), expected);
1258        Ok(())
1259    }
1260
1261    #[test]
1262    fn test_event_metadata_none_timestamp_serde_round_trip() -> TestResult {
1263        let meta = EventMetadata::new(None, b"no timestamp".to_vec());
1264        let serialized = serde_json::to_string(&meta)?;
1265        let deserialized: EventMetadata = serde_json::from_str(&serialized)?;
1266        assert_eq!(deserialized, meta);
1267        assert!(deserialized.timestamp().is_none());
1268        Ok(())
1269    }
1270
1271    #[test]
1272    fn test_event_metadata_deserialize_null_timestamp() -> TestResult {
1273        let json = serde_json::json!({
1274            "timestamp": null,
1275            "raw_bytes": BASE64_STANDARD.encode(b"data"),
1276        });
1277        let meta: EventMetadata = serde_json::from_value(json)?;
1278        assert!(meta.timestamp().is_none());
1279        assert_eq!(meta.raw_bytes(), b"data");
1280        Ok(())
1281    }
1282
1283    // -- LogFileRotatedEvent --
1284
1285    #[test]
1286    fn test_log_file_rotated_for_rotation_stores_previous_file_size() {
1287        let ts = Utc
1288            .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1289            .single()
1290            .unwrap_or_default();
1291        let event = LogFileRotatedEvent::for_rotation(ts, 42_000);
1292        assert_eq!(event.previous_file_size(), Some(42_000));
1293    }
1294
1295    #[test]
1296    fn test_log_file_rotated_for_rotation_stores_timestamp() {
1297        let ts = Utc
1298            .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1299            .single()
1300            .unwrap_or_default();
1301        let event = LogFileRotatedEvent::for_rotation(ts, 1000);
1302        assert_eq!(event.metadata().timestamp(), Some(ts));
1303    }
1304
1305    #[test]
1306    fn test_log_file_rotated_has_empty_raw_bytes() {
1307        let ts = Utc
1308            .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1309            .single()
1310            .unwrap_or_default();
1311        let event = LogFileRotatedEvent::for_rotation(ts, 500);
1312        assert!(event.metadata().raw_bytes().is_empty());
1313    }
1314
1315    #[test]
1316    fn test_log_file_rotated_serde_round_trip() -> TestResult {
1317        let ts = Utc
1318            .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1319            .single()
1320            .unwrap_or_default();
1321        let event = GameEvent::LogFileRotated(LogFileRotatedEvent::for_rotation(ts, 12345));
1322        let serialized = serde_json::to_string(&event)?;
1323        let deserialized: GameEvent = serde_json::from_str(&serialized)?;
1324        assert_eq!(deserialized, event);
1325        Ok(())
1326    }
1327
1328    #[test]
1329    fn test_log_file_rotated_performance_class_is_interactive() {
1330        let ts = Utc
1331            .with_ymd_and_hms(2026, 3, 7, 10, 0, 0)
1332            .single()
1333            .unwrap_or_default();
1334        let event = GameEvent::LogFileRotated(LogFileRotatedEvent::for_rotation(ts, 0));
1335        assert_eq!(
1336            event.performance_class(),
1337            PerformanceClass::InteractiveDispatch
1338        );
1339    }
1340
1341    // -- DetailedLoggingStatusEvent --
1342
1343    #[test]
1344    fn test_detailed_logging_status_new_status_stores_enabled_true() {
1345        let ts = Utc
1346            .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1347            .single()
1348            .unwrap_or_default();
1349        let event = DetailedLoggingStatusEvent::new_status(ts, true);
1350        assert_eq!(event.enabled(), Some(true));
1351    }
1352
1353    #[test]
1354    fn test_detailed_logging_status_new_status_stores_enabled_false() {
1355        let ts = Utc
1356            .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1357            .single()
1358            .unwrap_or_default();
1359        let event = DetailedLoggingStatusEvent::new_status(ts, false);
1360        assert_eq!(event.enabled(), Some(false));
1361    }
1362
1363    #[test]
1364    fn test_detailed_logging_status_stores_timestamp() {
1365        let ts = Utc
1366            .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1367            .single()
1368            .unwrap_or_default();
1369        let event = DetailedLoggingStatusEvent::new_status(ts, true);
1370        assert_eq!(event.metadata().timestamp(), Some(ts));
1371    }
1372
1373    #[test]
1374    fn test_detailed_logging_status_has_empty_raw_bytes() {
1375        let ts = Utc
1376            .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1377            .single()
1378            .unwrap_or_default();
1379        let event = DetailedLoggingStatusEvent::new_status(ts, false);
1380        assert!(event.metadata().raw_bytes().is_empty());
1381    }
1382
1383    #[test]
1384    fn test_detailed_logging_status_serde_round_trip() -> TestResult {
1385        let ts = Utc
1386            .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1387            .single()
1388            .unwrap_or_default();
1389        let event =
1390            GameEvent::DetailedLoggingStatus(DetailedLoggingStatusEvent::new_status(ts, false));
1391        let serialized = serde_json::to_string(&event)?;
1392        let deserialized: GameEvent = serde_json::from_str(&serialized)?;
1393        assert_eq!(deserialized, event);
1394        Ok(())
1395    }
1396
1397    #[test]
1398    fn test_detailed_logging_status_performance_class_is_interactive() {
1399        let ts = Utc
1400            .with_ymd_and_hms(2026, 3, 15, 10, 0, 0)
1401            .single()
1402            .unwrap_or_default();
1403        let event =
1404            GameEvent::DetailedLoggingStatus(DetailedLoggingStatusEvent::new_status(ts, true));
1405        assert_eq!(
1406            event.performance_class(),
1407            PerformanceClass::InteractiveDispatch
1408        );
1409    }
1410}