Skip to main content

manasight_parser/
events.rs

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