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