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