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