Skip to main content

manasight_parser/
events.rs

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