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