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