Skip to main content

runecast_protocol/protocol/
server_messages.rs

1//! Server-to-client messages.
2//!
3//! All messages that can be sent from the backend server to frontend clients.
4//! Messages are tagged with `type` field for JSON serialization.
5//!
6//! # Categories
7//!
8//! - **Connection**: Handshake responses, heartbeat acks
9//! - **Lobby State**: Snapshots and deltas for lobby state
10//! - **Game State**: Snapshots and deltas for game state
11//! - **Events**: Discrete events (player joined, word scored, etc.)
12//! - **Errors**: Error responses with codes and messages
13
14use serde::{Deserialize, Serialize};
15
16use crate::protocol::GameType;
17
18use super::types::{
19    AdminGameInfo, AdventureEventKind, CustomMeta, DebugBackendGameState, DebugHandlerGameState,
20    DebugLobbyState, DebugPlayerInfo, DebugWebsocketContext, ErrorCode, GameChange, GameConfig,
21    GamePlayerInfo, GameSnapshot, Grid, LobbyChange, LobbyGameInfo, LobbyPlayerInfo, LobbyType,
22    NewsItemPayload, PlayerInfo, PlayerName, Position, RematchCountdownState, RuneBoxStanding,
23    ScoreInfo, SpectatorInfo, TimerVoteState,
24};
25
26/// Messages sent from server to client.
27// Servers box outgoing messages; boxing fields here would change the public API.
28#[allow(clippy::large_enum_variant)]
29#[serde_with::serde_as]
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(tag = "type", rename_all = "snake_case")]
32pub enum ServerMessage {
33    // ========================================================================
34    // Connection Messages
35    // ========================================================================
36    /// Initial server greeting after WebSocket connect.
37    ///
38    /// Sent immediately upon connection, before Identify.
39    Hello {
40        /// Recommended heartbeat interval in milliseconds
41        heartbeat_interval_ms: u32,
42        /// Server version for compatibility checks
43        #[serde(skip_serializing_if = "Option::is_none")]
44        server_version: Option<String>,
45    },
46
47    /// Successful authentication response.
48    ///
49    /// Contains the full initial state snapshot.
50    Ready {
51        /// Unique session ID for this connection
52        session_id: String,
53        /// The authenticated player's user ID
54        #[serde_as(as = "serde_with::DisplayFromStr")]
55        player_id: i64,
56        /// Full lobby state (if in a lobby)
57        #[serde(skip_serializing_if = "Option::is_none")]
58        lobby: Option<LobbySnapshot>,
59        /// Full game state (if in a game)
60        #[serde(skip_serializing_if = "Option::is_none")]
61        game: Option<GameSnapshot>,
62    },
63
64    /// Session resumed successfully after reconnect.
65    ///
66    /// Contains any events missed during disconnection.
67    Resumed {
68        /// Events that occurred while disconnected
69        missed_events: Vec<ServerMessage>,
70    },
71
72    /// Heartbeat response.
73    ///
74    /// Echoes back for latency calculation.
75    HeartbeatAck {
76        /// Server timestamp when heartbeat was received
77        server_time: u64,
78    },
79
80    /// Session is invalid or expired.
81    ///
82    /// Client should re-authenticate.
83    InvalidSession { reason: String },
84
85    // ========================================================================
86    // Lobby State Messages
87    // ========================================================================
88    /// Sent when successfully joining a lobby.
89    LobbyJoined {
90        lobby_id: String,
91        /// 6-char code for custom lobbies
92        #[serde(skip_serializing_if = "Option::is_none")]
93        lobby_code: Option<String>,
94        /// Full lobby state
95        lobby: LobbySnapshot,
96    },
97
98    /// Full lobby state snapshot.
99    ///
100    /// Sent on initial join or when delta sync fails.
101    LobbySnapshot { lobby: LobbySnapshot },
102
103    /// Incremental lobby state update.
104    ///
105    /// More efficient than full snapshots for small changes.
106    LobbyDelta { changes: Vec<LobbyChange> },
107
108    /// Confirmation of leaving lobby.
109    LobbyLeft,
110
111    /// Custom lobby was created successfully.
112    CustomLobbyCreated {
113        lobby_id: String,
114        lobby_code: String,
115    },
116    // ========================================================================
117    // Game Lifecycle Messages
118    // ========================================================================
119    /// A new game has started.
120    ///
121    /// Contains initial game state for all participants.
122    GameStarted {
123        game_id: String,
124        /// Which mode this game is — the same field, and the same reasoning, as
125        /// [`GameSnapshot::game_type`].
126        ///
127        /// v0.18.0 put the mode on the snapshot and stopped there, which left
128        /// the JOIN path still inferring it. That is the worse half to leave
129        /// undone: a snapshot is a reconnect/spectate message, so a player who
130        /// joins normally never receives one, and every bug that has hidden in
131        /// this mode so far hid on exactly this path.
132        #[serde(default)]
133        game_type: GameType,
134        grid: Grid,
135        players: Vec<GamePlayerInfo>,
136        /// Your turn order (0-indexed)
137        your_turn_order: u8,
138        /// Who goes first
139        #[serde_as(as = "serde_with::DisplayFromStr")]
140        current_turn: i64,
141        round: u8,
142        max_rounds: u8,
143        /// Turn time limit in seconds (if configured)
144        #[serde(skip_serializing_if = "Option::is_none")]
145        turn_time_limit: Option<u32>,
146        /// `Some` marks this as an unranked custom (sandbox) game so every
147        /// participant — not just the host — can render the unranked badge.
148        /// Omitted for FFA/Adventure (wire format unchanged).
149        #[serde(default, skip_serializing_if = "Option::is_none")]
150        custom: Option<CustomMeta>,
151        /// Server-authoritative time left in the run, in milliseconds. Set for
152        /// Daily Challenge runs (incl. resumes); omitted otherwise.
153        #[serde(default, skip_serializing_if = "Option::is_none")]
154        time_remaining_ms: Option<u64>,
155    },
156
157    /// Full game state snapshot.
158    ///
159    /// Sent when joining as spectator or when delta sync fails.
160    GameSnapshot { game_id: String, game: GameSnapshot },
161
162    /// Incremental game state update.
163    GameDelta {
164        game_id: String,
165        changes: Vec<GameChange>,
166    },
167
168    /// Game has ended normally.
169    GameOver {
170        game_id: String,
171        /// Final scores, sorted by rank
172        final_scores: Vec<ScoreInfo>,
173        /// Winner's user ID
174        #[serde_as(as = "serde_with::DisplayFromStr")]
175        winner_id: i64,
176        /// Whether it was a draw
177        #[serde(default)]
178        is_draw: bool,
179    },
180
181    /// Game was cancelled (not enough players, host left, etc.).
182    GameCancelled { game_id: String, reason: String },
183
184    /// Adventure Mode level ended. Sent after `GameOver` for sessions that
185    /// had `adventure_level.is_some()` in their config. The client uses
186    /// this to render the level-complete modal; the server has already
187    /// upserted the row in `adventure_progress` by the time this is sent.
188    AdventureLevelResult {
189        /// Which level was played (1..=50).
190        level: u32,
191        /// Human player's final score for this run.
192        score: i32,
193        /// Stars awarded (0..=3). 0 = target not hit; 1+ = hit one_star/two_star/three_star.
194        stars: u8,
195        /// True iff `score` exceeded the prior `high_score` for this user + level.
196        personal_best: bool,
197        /// If this run unlocked a new level, the newly-unlocked level id.
198        /// `None` if no new level was unlocked (level was already completed
199        /// or was the campaign finale).
200        #[serde(skip_serializing_if = "Option::is_none")]
201        unlocked_level: Option<u32>,
202        /// This run's duration in milliseconds (from game creation to
203        /// game_over). Lets the client render "Time: 2:34" in the result
204        /// modal without fetching progress. Unsigned since durations
205        /// are non-negative, and consistent with other duration /
206        /// interval fields in the protocol (`heartbeat_interval_ms`,
207        /// `server_time`).
208        #[serde(skip_serializing_if = "Option::is_none")]
209        duration_ms: Option<u64>,
210    },
211
212    /// Daily Challenge run finished (deadline reached or player ended early).
213    /// The server has already recorded the attempt + move log by send time.
214    DailyResult {
215        /// The day's seed (deterministic from the UTC date).
216        seed: i64,
217        /// This run's final score.
218        score: i32,
219        /// The player's best score across today's attempts.
220        personal_best: i32,
221        /// 1-based rank on today's shared leaderboard.
222        rank: u32,
223        /// Attempts used so far today (incl. this one).
224        attempts_used: u8,
225        /// Max attempts today for this user (1 free, 3 premium).
226        attempts_max: u8,
227    },
228
229    /// Adventure Mode random event (bomb / snake / UFO). Broadcast by
230    /// the event scheduler when a roll succeeds at round change.
231    /// `affected_positions` is the set of grid cells the client should
232    /// animate; `new_grid` is the post-effect board so the client can
233    /// swap in the updated tiles atomically with the animation.
234    AdventureEvent {
235        game_id: String,
236        kind: AdventureEventKind,
237        affected_positions: Vec<Position>,
238        /// Full post-effect grid. Clients apply this as an authoritative
239        /// snapshot — no delta needed.
240        new_grid: Grid,
241        /// Short human-readable label for the event, already localized
242        /// server-side. Rendered in a brief toast alongside the animation.
243        label: String,
244    },
245
246    // ========================================================================
247    // Game Event Messages
248    // ========================================================================
249    /// A player joined the lobby.
250    PlayerJoined { player: LobbyPlayerInfo },
251
252    /// A player left the lobby.
253    PlayerLeft {
254        #[serde_as(as = "serde_with::DisplayFromStr")]
255        player_id: i64,
256        #[serde(skip_serializing_if = "Option::is_none")]
257        reason: Option<String>,
258    },
259
260    /// A player reconnected after disconnection.
261    PlayerReconnected {
262        #[serde_as(as = "serde_with::DisplayFromStr")]
263        player_id: i64,
264    },
265
266    /// A player disconnected (may reconnect).
267    PlayerDisconnected {
268        #[serde(skip_serializing_if = "Option::is_none")]
269        game_id: Option<String>,
270        #[serde_as(as = "serde_with::DisplayFromStr")]
271        player_id: i64,
272        /// Grace period in seconds before they're removed
273        grace_period_seconds: u32,
274    },
275
276    /// A word was successfully scored.
277    WordScored {
278        #[serde_as(as = "serde_with::DisplayFromStr")]
279        player_id: i64,
280        game_id: String,
281        word: String,
282        score: i32,
283        /// Positions that formed the word
284        path: Vec<super::types::Position>,
285        /// New total score
286        total_score: i32,
287        /// Gems earned from this word
288        gems_earned: i32,
289        /// New gem total
290        total_gems: i32,
291        /// Updated grid (letters replaced)
292        new_grid: Grid,
293    },
294
295    /// RuneBox: a word was accepted — sent to the **submitting player only**.
296    ///
297    /// Deliberately not a reuse of [`ServerMessage::WordScored`]. That variant
298    /// carries gems and a full `new_grid`, neither of which exists in RuneBox
299    /// (the board is static and there are no gems), so both would be dead weight
300    /// re-sent on every word — and its `word` field would leak to opponents.
301    RuneBoxWordAccepted {
302        /// Game this word was played in.
303        game_id: String,
304        /// The accepted word, echoed back so the client can list it.
305        word: String,
306        /// Letters gained — equal to the word's length. Sent explicitly so the
307        /// client never re-derives scoring rules locally.
308        letters: i32,
309        /// The submitting player's new running total.
310        total_score: i32,
311    },
312
313    /// RuneBox: another player's score moved — broadcast to everyone else.
314    ///
315    /// Carries **no word, and must never gain one**. RuneBox found-sets are
316    /// independent, so a word another player found is still worth full value to
317    /// you; naming it would hand you the answer. Opponents see the score move
318    /// and nothing more.
319    RuneBoxScoreChanged {
320        /// Whose score changed.
321        #[serde_as(as = "serde_with::DisplayFromStr")]
322        player_id: i64,
323        /// Game the score belongs to.
324        game_id: String,
325        /// That player's new running total.
326        total_score: i32,
327    },
328
329    /// RuneBox: a **round** ended and the match continues.
330    ///
331    /// Deliberately distinct from [`ServerMessage::GameOver`], which keeps its
332    /// existing meaning: the MATCH is over, no further play. That separation is
333    /// what makes this addition non-breaking — every other mode plays exactly
334    /// one round, so it only ever sends `GameOver` and never sees this message.
335    ///
336    /// A client that receives this should present standings and wait, NOT tear
337    /// the game screen down.
338    ///
339    /// Carries no word lists, and must never gain any: the anti-leak rule that
340    /// splits [`ServerMessage::RuneBoxWordAccepted`] from
341    /// [`ServerMessage::RuneBoxScoreChanged`] applies just as much between
342    /// rounds, because an eliminated player may still be watching.
343    RuneBoxRoundEnded {
344        /// Match this round belongs to.
345        game_id: String,
346        /// Which round just finished, 1-based.
347        round: u8,
348        /// How many rounds this match will play at most. `N - 1` for an
349        /// elimination match of N players; 3 for a two-player best-of-3, which
350        /// can still finish in 2.
351        max_rounds: u8,
352        /// THIS round's scores alone, highest first. Not cumulative — the
353        /// running match total lives on `standings`.
354        round_scores: Vec<ScoreInfo>,
355        /// Players eliminated by this round's result.
356        ///
357        /// Empty in a two-player best-of-3, where nobody is eliminated until
358        /// the match itself ends. Normally one id, but the field is a list
359        /// because the rules do not forbid a round that eliminates more than
360        /// one player.
361        #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
362        eliminated: Vec<i64>,
363        /// Every player's match standing after this round — survivors and
364        /// eliminated alike, so a client renders the whole picture without
365        /// accumulating round results itself. A client that missed a round
366        /// (reconnect) is therefore still correct.
367        standings: Vec<RuneBoxStanding>,
368        /// When the next round's board goes live, for the client's countdown.
369        ///
370        /// `None` means the server has not scheduled it yet; it does NOT mean
371        /// the match is over — that is `GameOver`.
372        #[serde(skip_serializing_if = "Option::is_none")]
373        next_round_starts_at: Option<chrono::DateTime<chrono::Utc>>,
374    },
375
376    /// Turn changed to another player.
377    TurnChanged {
378        #[serde_as(as = "serde_with::DisplayFromStr")]
379        player_id: i64,
380        game_id: String,
381        round: u8,
382        /// Time remaining for this turn (if timer active)
383        #[serde(skip_serializing_if = "Option::is_none")]
384        time_remaining: Option<u32>,
385    },
386
387    /// A player passed their turn.
388    TurnPassed {
389        #[serde_as(as = "serde_with::DisplayFromStr")]
390        player_id: i64,
391        game_id: String,
392    },
393
394    /// Round number changed.
395    RoundChanged {
396        game_id: String,
397        round: u8,
398        max_rounds: u8,
399        /// New grid if board was regenerated at the start of this round.
400        /// Only present when the game is configured with `regenerate_board_each_round: true`.
401        #[serde(skip_serializing_if = "Option::is_none")]
402        new_grid: Option<Grid>,
403    },
404
405    /// Board was shuffled.
406    BoardShuffled {
407        #[serde_as(as = "serde_with::DisplayFromStr")]
408        player_id: i64,
409        game_id: String,
410        new_grid: Grid,
411        gems_spent: i32,
412        /// Player's remaining gems after shuffle
413        total_gems: i32,
414    },
415
416    /// A tile was swapped.
417    TileSwapped {
418        #[serde_as(as = "serde_with::DisplayFromStr")]
419        player_id: i64,
420        game_id: String,
421        row: usize,
422        col: usize,
423        old_letter: char,
424        new_letter: char,
425        gems_spent: i32,
426        /// Player's remaining gems after swap
427        total_gems: i32,
428    },
429
430    /// Player entered swap mode (for animation).
431    SwapModeEntered {
432        #[serde_as(as = "serde_with::DisplayFromStr")]
433        player_id: i64,
434        game_id: String,
435    },
436
437    /// Player exited swap mode.
438    SwapModeExited {
439        #[serde_as(as = "serde_with::DisplayFromStr")]
440        player_id: i64,
441        game_id: String,
442    },
443
444    // ========================================================================
445    // Spectator Messages
446    // ========================================================================
447    /// Successfully joined as spectator.
448    SpectatorJoined {
449        game_id: String,
450        /// Full game state
451        game: GameSnapshot,
452    },
453
454    /// A new spectator joined (broadcast to others).
455    SpectatorAdded {
456        spectator: SpectatorInfo,
457        game_id: String,
458    },
459
460    /// A spectator left.
461    SpectatorRemoved {
462        #[serde_as(as = "serde_with::DisplayFromStr")]
463        spectator_id: i64,
464        game_id: String,
465    },
466
467    /// Spectator joined as player.
468    SpectatorBecamePlayer {
469        #[serde_as(as = "serde_with::DisplayFromStr")]
470        player_id: i64,
471        /// The player's raw Discord username: the unique handle. Display
472        /// `name` instead when it is present.
473        username: String,
474        /// What to call this player. Absent from servers older than protocol
475        /// 0.20; fall back to `username`.
476        #[serde(default, skip_serializing_if = "Option::is_none")]
477        name: Option<PlayerName>,
478        game_id: String,
479    },
480
481    /// Confirmation of leaving spectator mode.
482    SpectatorLeft,
483
484    // ========================================================================
485    // Live Update Messages
486    // ========================================================================
487    /// Another player's tile selection (for live preview).
488    SelectionUpdate {
489        #[serde_as(as = "serde_with::DisplayFromStr")]
490        player_id: i64,
491        game_id: String,
492        positions: Vec<super::types::Position>,
493    },
494
495    // ========================================================================
496    // Timer Vote Messages
497    // ========================================================================
498    /// Timer vote state changed.
499    TimerVoteUpdate {
500        state: TimerVoteState,
501        game_id: String,
502    },
503
504    /// Turn timer started (vote passed).
505    TurnTimerStarted {
506        #[serde_as(as = "serde_with::DisplayFromStr")]
507        target_player_id: i64,
508        game_id: String,
509        seconds: u32,
510    },
511
512    /// Turn timer expired - player auto-passed.
513    TurnTimerExpired {
514        #[serde_as(as = "serde_with::DisplayFromStr")]
515        player_id: i64,
516        game_id: String,
517    },
518
519    // ========================================================================
520    // Rematch Countdown Messages
521    // ========================================================================
522    /// Rematch countdown state update.
523    ///
524    /// Sent to all players on the results screen after a game ends.
525    RematchCountdownUpdate {
526        /// Current countdown state
527        state: RematchCountdownState,
528        /// The game that just ended
529        previous_game_id: String,
530    },
531
532    /// A player opted out of rematch pool.
533    ///
534    /// Broadcast to remaining players so they can update the player list.
535    PlayerLeftRematch {
536        #[serde_as(as = "serde_with::DisplayFromStr")]
537        player_id: i64,
538        /// The game they left from
539        previous_game_id: String,
540    },
541
542    /// Rematch is starting (sent right before `GameStarted`).
543    ///
544    /// Allows frontend to show "Starting..." before the new game begins.
545    RematchStarting {
546        /// Who triggered the early start (None if countdown expired naturally)
547        #[serde(skip_serializing_if = "Option::is_none")]
548        #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
549        triggered_by: Option<i64>,
550        /// The previous game that ended
551        previous_game_id: String,
552    },
553
554    // ========================================================================
555    // Pool
556    // ========================================================================
557    //
558    PlayerPoolChanged {
559        #[serde_as(as = "serde_with::DisplayFromStr")]
560        player_id: i64,
561        old_pool: Option<GameType>,
562        new_pool: Option<GameType>,
563    },
564
565    // ========================================================================
566    // Game Pool Messages (Legacy)
567    // ========================================================================
568    /// Player joined the game pool.
569    PoolJoined {
570        position: i32,
571        total_in_pool: i32,
572        game_id: String,
573    },
574
575    /// Pool position updated.
576    PoolUpdate {
577        position: i32,
578        total_in_pool: i32,
579        game_id: String,
580    },
581
582    /// Left the pool.
583    PoolLeft,
584
585    // ========================================================================
586    // Admin Messages
587    // ========================================================================
588    /// Response to admin game list request.
589    AdminGamesList { games: Vec<AdminGameInfo> },
590
591    /// Game was deleted by admin.
592    AdminGameDeleted { game_id: String },
593
594    // ========================================================================
595    // Legacy Compatibility Messages
596    // ========================================================================
597    /// Generic state update (legacy format).
598    ///
599    /// Used for backward compatibility with existing frontend.
600    #[serde(rename = "game_state")]
601    GameStateUpdate {
602        game_id: String,
603        state: String,
604        grid: Grid,
605        players: Vec<PlayerInfo>,
606        current_turn: i64,
607        round: i32,
608        max_rounds: i32,
609        used_words: Vec<String>,
610        spectators: Vec<SpectatorInfo>,
611        timer_vote_state: TimerVoteState,
612    },
613
614    /// Lobby state update (legacy format).
615    #[serde(rename = "lobby_state")]
616    LobbyStateUpdate {
617        lobby_id: String,
618        players: Vec<LobbyPlayerInfo>,
619        games: Vec<LobbyGameInfo>,
620    },
621
622    // ========================================================================
623    // Debug Messages
624    // ========================================================================
625    /// Debug state response with player context diagnostics.
626    DebugStateResponse {
627        timestamp: String,
628        player: DebugPlayerInfo,
629        websocket_context: DebugWebsocketContext,
630        lobby_state: Option<DebugLobbyState>,
631        backend_game_state: Option<DebugBackendGameState>,
632        handler_game_state: Option<DebugHandlerGameState>,
633    },
634
635    // ========================================================================
636    // News / Maintenance Announcements
637    // ========================================================================
638    /// A news/notification item was created or activated by an admin.
639    ///
640    /// Pushed to every connected client when an admin posts a new item
641    /// via the news admin endpoints, or when an existing item is
642    /// updated in a way that should re-surface it (becoming active,
643    /// extending expires_at, etc.). Clients should treat it the same
644    /// as if they had just fetched `/api/news` and seen this entry —
645    /// in particular, maintenance items with a future `expires_at`
646    /// drive the corner-countdown toast, while announcement/update
647    /// items go through the existing full-screen overlay.
648    NewsAnnounced { item: NewsItemPayload },
649
650    // ========================================================================
651    // Error Messages
652    // ========================================================================
653    /// Error response.
654    Error {
655        code: ErrorCode,
656        message: String,
657        /// Additional context (e.g., which field was invalid)
658        #[serde(skip_serializing_if = "Option::is_none")]
659        details: Option<serde_json::Value>,
660    },
661}
662
663impl ServerMessage {
664    /// Create an error message from an error code.
665    #[must_use]
666    pub fn error(code: ErrorCode) -> Self {
667        Self::Error {
668            message: code.message().to_string(),
669            code,
670            details: None,
671        }
672    }
673
674    /// Create an error message with custom message.
675    pub fn error_with_message(code: ErrorCode, message: impl Into<String>) -> Self {
676        Self::Error {
677            code,
678            message: message.into(),
679            details: None,
680        }
681    }
682
683    /// Create an error message with details.
684    pub fn error_with_details(
685        code: ErrorCode,
686        message: impl Into<String>,
687        details: serde_json::Value,
688    ) -> Self {
689        Self::Error {
690            code,
691            message: message.into(),
692            details: Some(details),
693        }
694    }
695
696    /// Get the message type as a string (for logging/debugging).
697    #[must_use]
698    pub fn message_type(&self) -> &'static str {
699        match self {
700            Self::Hello { .. } => "hello",
701            Self::Ready { .. } => "ready",
702            Self::Resumed { .. } => "resumed",
703            Self::HeartbeatAck { .. } => "heartbeat_ack",
704            Self::InvalidSession { .. } => "invalid_session",
705            Self::LobbyJoined { .. } => "lobby_joined",
706            Self::LobbySnapshot { .. } => "lobby_snapshot",
707            Self::LobbyDelta { .. } => "lobby_delta",
708            Self::LobbyLeft => "lobby_left",
709            Self::CustomLobbyCreated { .. } => "custom_lobby_created",
710            Self::GameStarted { .. } => "game_started",
711            Self::GameSnapshot { .. } => "game_snapshot",
712            Self::GameDelta { .. } => "game_delta",
713            Self::GameOver { .. } => "game_over",
714            Self::GameCancelled { .. } => "game_cancelled",
715            Self::AdventureLevelResult { .. } => "adventure_level_result",
716            Self::DailyResult { .. } => "daily_result",
717            Self::AdventureEvent { .. } => "adventure_event",
718            Self::PlayerJoined { .. } => "player_joined",
719            Self::PlayerLeft { .. } => "player_left",
720            Self::PlayerReconnected { .. } => "player_reconnected",
721            Self::PlayerDisconnected { .. } => "player_disconnected",
722            Self::PlayerPoolChanged { .. } => "player_pool_changed",
723            Self::WordScored { .. } => "word_scored",
724            Self::RuneBoxWordAccepted { .. } => "rune_box_word_accepted",
725            Self::RuneBoxScoreChanged { .. } => "rune_box_score_changed",
726            Self::RuneBoxRoundEnded { .. } => "rune_box_round_ended",
727            Self::TurnChanged { .. } => "turn_changed",
728            Self::TurnPassed { .. } => "turn_passed",
729            Self::RoundChanged { .. } => "round_changed",
730            Self::BoardShuffled { .. } => "board_shuffled",
731            Self::TileSwapped { .. } => "tile_swapped",
732            Self::SwapModeEntered { .. } => "swap_mode_entered",
733            Self::SwapModeExited { .. } => "swap_mode_exited",
734            Self::SpectatorJoined { .. } => "spectator_joined",
735            Self::SpectatorAdded { .. } => "spectator_added",
736            Self::SpectatorRemoved { .. } => "spectator_removed",
737            Self::SpectatorLeft => "spectator_left",
738            Self::SpectatorBecamePlayer { .. } => "spectator_became_player",
739            Self::SelectionUpdate { .. } => "selection_update",
740            Self::TimerVoteUpdate { .. } => "timer_vote_update",
741            Self::TurnTimerStarted { .. } => "turn_timer_started",
742            Self::TurnTimerExpired { .. } => "turn_timer_expired",
743            Self::RematchCountdownUpdate { .. } => "rematch_countdown_update",
744            Self::PlayerLeftRematch { .. } => "player_left_rematch",
745            Self::RematchStarting { .. } => "rematch_starting",
746            Self::PoolJoined { .. } => "pool_joined",
747            Self::PoolUpdate { .. } => "pool_update",
748            Self::PoolLeft => "pool_left",
749            Self::AdminGamesList { .. } => "admin_games_list",
750            Self::AdminGameDeleted { .. } => "admin_game_deleted",
751            Self::GameStateUpdate { .. } => "game_state",
752            Self::LobbyStateUpdate { .. } => "lobby_state",
753            Self::DebugStateResponse { .. } => "debug_state_response",
754
755            Self::NewsAnnounced { .. } => "news_announced",
756
757            Self::Error { .. } => "error",
758        }
759    }
760
761    /// Check if this is an error message.
762    #[must_use]
763    pub fn is_error(&self) -> bool {
764        matches!(self, Self::Error { .. })
765    }
766
767    /// Check if this message should be stored for reconnection replay.
768    ///
769    /// Some messages are transient and don't need to be replayed.
770    #[must_use]
771    pub fn should_store_for_replay(&self) -> bool {
772        !matches!(
773            self,
774            Self::Hello { .. }
775                | Self::HeartbeatAck { .. }
776                | Self::SelectionUpdate { .. }
777                | Self::TimerVoteUpdate {
778                    state: TimerVoteState::Idle,
779                    ..
780                }
781                | Self::PlayerPoolChanged { .. }
782                | Self::TurnTimerStarted { .. }
783                | Self::TurnTimerExpired { .. }
784                | Self::DebugStateResponse { .. }
785                // News is re-fetched from /api/news on (re)connect, so
786                // replaying a stale announcement would either duplicate
787                // a still-active item or surface one that has since
788                // expired/been deleted. Skip replay; the HTTP fetch is
789                // authoritative.
790                | Self::NewsAnnounced { .. }
791        )
792    }
793}
794
795/// Convert a `ServerMessage` to a `serde_json::Value`.
796impl From<ServerMessage> for serde_json::Value {
797    fn from(msg: ServerMessage) -> Self {
798        serde_json::to_value(msg).unwrap()
799    }
800}
801
802/// Convert a `serde_json::Value` to a `ServerMessage`.
803impl TryFrom<serde_json::Value> for ServerMessage {
804    type Error = serde_json::Error;
805
806    fn try_from(
807        value: serde_json::Value,
808    ) -> Result<Self, <ServerMessage as TryFrom<serde_json::Value>>::Error> {
809        serde_json::from_value(value)
810    }
811}
812// ============================================================================
813// Snapshot Types
814// ============================================================================
815
816/// Complete lobby state snapshot.
817#[serde_with::serde_as]
818#[derive(Debug, Clone, Serialize, Deserialize)]
819pub struct LobbySnapshot {
820    pub lobby_id: String,
821    pub lobby_type: LobbyType,
822    #[serde(skip_serializing_if = "Option::is_none")]
823    pub lobby_code: Option<String>,
824    pub players: Vec<LobbyPlayerInfo>,
825    pub games: Vec<LobbyGameInfo>,
826    /// Maximum players allowed
827    #[serde(default = "default_max_players")]
828    pub max_players: u8,
829    /// Current sandbox config for the lobby, if a host has set one.
830    /// Absent for normal (non-sandbox) lobbies.
831    #[serde(default, skip_serializing_if = "Option::is_none")]
832    pub sandbox_config: Option<GameConfig>,
833    /// The lobby's current host (the player who controls sandbox config),
834    /// if one has been assigned. Lets clients show host-only controls and a
835    /// read-only mirror for non-hosts. Updated live on host transfer.
836    /// Serialized as a STRING (like every other id) so JS doesn't lose
837    /// precision on snowflake-magnitude ids (>2^53).
838    #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
839    #[serde(default, skip_serializing_if = "Option::is_none")]
840    pub host_id: Option<i64>,
841}
842
843fn default_max_players() -> u8 {
844    6
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850    // Test-only: the lib build has no other use of this, so importing it at
851    // module scope makes it an unused import outside `cfg(test)`.
852    use crate::protocol::types::NewsNotificationType;
853    use crate::protocol::{types, GameType};
854
855    #[test]
856    fn spectator_became_player_carries_the_name_on_the_wire() {
857        let msg = ServerMessage::SpectatorBecamePlayer {
858            player_id: 1_234_567_890_123_456_789,
859            username: "ada".to_string(),
860            name: Some(PlayerName::new("Ada", Some("Rune Queen".into()))),
861            game_id: "g1".to_string(),
862        };
863        let json = serde_json::to_string(&msg).unwrap();
864        assert!(json.contains(r#""name":{"discord":"Ada","runecast":"Rune Queen"}"#));
865        assert!(json.contains(r#""player_id":"1234567890123456789""#));
866    }
867
868    #[test]
869    fn spectator_became_player_without_a_name_parses_as_none() {
870        let raw = r#"{"type":"spectator_became_player","player_id":"42","username":"ada","game_id":"g1"}"#;
871        match serde_json::from_str::<ServerMessage>(raw).unwrap() {
872            ServerMessage::SpectatorBecamePlayer {
873                player_id, name, ..
874            } => {
875                assert_eq!(player_id, 42);
876                assert!(name.is_none());
877            }
878            other => panic!("wrong variant: {other:?}"),
879        }
880    }
881
882    #[test]
883    fn test_server_message_into_json() {
884        let msg = ServerMessage::BoardShuffled {
885            player_id: 1_234_567_890,
886            game_id: "1234567890".to_string(),
887            new_grid: Grid::new(),
888            gems_spent: 0,
889            total_gems: 0,
890        };
891        let json = serde_json::to_string(&msg).unwrap();
892        assert!(json.contains(r#""type":"board_shuffled""#));
893        assert!(json.contains(r#""player_id":"1234567890""#));
894        assert!(json.contains(r#""game_id":"1234567890""#));
895        assert!(json.contains(r#""new_grid":[]"#));
896        assert!(json.contains(r#""gems_spent":0"#));
897        assert!(json.contains(r#""total_gems":0"#));
898    }
899
900    #[test]
901    fn test_heartbeat_ack_serialization() {
902        let msg = ServerMessage::HeartbeatAck {
903            server_time: 1_701_234_567_890,
904        };
905        let json = serde_json::to_string(&msg).unwrap();
906        assert!(json.contains(r#""type":"heartbeat_ack""#));
907        assert!(json.contains(r#""server_time":1701234567890"#));
908    }
909
910    #[test]
911    fn test_error_message_creation() {
912        let msg = ServerMessage::error(ErrorCode::NotYourTurn);
913        match msg {
914            ServerMessage::Error { code, message, .. } => {
915                assert_eq!(code, ErrorCode::NotYourTurn);
916                assert_eq!(message, "It's not your turn");
917            }
918            _ => panic!("Expected error message"),
919        }
920    }
921
922    #[test]
923    fn test_error_with_details() {
924        let msg = ServerMessage::error_with_details(
925            ErrorCode::InvalidPath,
926            "Tiles must be adjacent",
927            serde_json::json!({"positions": [[0,0], [2,2]]}),
928        );
929        match msg {
930            ServerMessage::Error { details, .. } => {
931                assert!(details.is_some());
932            }
933            _ => panic!("Expected error message"),
934        }
935    }
936
937    #[test]
938    fn test_player_joined_serialization() {
939        let msg = ServerMessage::PlayerJoined {
940            player: LobbyPlayerInfo {
941                user_id: 123,
942                username: "TestPlayer".to_string(),
943                name: None,
944                avatar_url: None,
945                banner_url: None,
946                accent_color: None,
947                current_game_pool: None,
948                active_game_id: None,
949                spectate_game_id: None,
950            },
951        };
952        let json = serde_json::to_string(&msg).unwrap();
953        assert!(json.contains(r#""type":"player_joined""#));
954        assert!(json.contains(r#""user_id":"123""#));
955    }
956
957    #[test]
958    fn test_game_state_update_legacy() {
959        // Verify legacy format still works
960        let json = r#"{"type":"game_state","game_id":"abc","state":"in_progress","grid":[],"players":[],"current_turn":123,"round":1,"max_rounds":5,"used_words":[],"spectators":[],"timer_vote_state":{"status":"idle"}}"#;
961        let msg: ServerMessage = serde_json::from_str(json).unwrap();
962        assert!(matches!(msg, ServerMessage::GameStateUpdate { .. }));
963    }
964
965    #[test]
966    fn test_should_store_for_replay() {
967        assert!(!ServerMessage::HeartbeatAck { server_time: 0 }.should_store_for_replay());
968        assert!(ServerMessage::PlayerJoined {
969            player: LobbyPlayerInfo {
970                user_id: 1,
971                username: "x".into(),
972                name: None,
973                avatar_url: None,
974                banner_url: None,
975                accent_color: None,
976                current_game_pool: None,
977                active_game_id: None,
978                spectate_game_id: None,
979            }
980        }
981        .should_store_for_replay());
982    }
983
984    #[test]
985    fn test_message_type() {
986        assert_eq!(
987            ServerMessage::HeartbeatAck { server_time: 0 }.message_type(),
988            "heartbeat_ack"
989        );
990        assert_eq!(
991            ServerMessage::error(ErrorCode::NotYourTurn).message_type(),
992            "error"
993        );
994
995        // Adventure-mode variants — keep these assertions in sync with
996        // any future additions so the match in `message_type()` can't
997        // silently fall out of step with the enum.
998        assert_eq!(
999            ServerMessage::AdventureLevelResult {
1000                level: 1,
1001                score: 10,
1002                stars: 1,
1003                personal_best: true,
1004                unlocked_level: None,
1005                duration_ms: None,
1006            }
1007            .message_type(),
1008            "adventure_level_result",
1009        );
1010        assert_eq!(
1011            ServerMessage::AdventureEvent {
1012                game_id: "g".to_string(),
1013                kind: types::AdventureEventKind::Bomb,
1014                affected_positions: vec![],
1015                new_grid: vec![],
1016                label: String::new(),
1017            }
1018            .message_type(),
1019            "adventure_event",
1020        );
1021    }
1022
1023    #[test]
1024    fn test_adventure_level_result_serialization() {
1025        // With both optional fields absent: they should be omitted
1026        // entirely via skip_serializing_if so older clients don't
1027        // see unexpected nulls.
1028        let msg = ServerMessage::AdventureLevelResult {
1029            level: 3,
1030            score: 120,
1031            stars: 2,
1032            personal_best: false,
1033            unlocked_level: None,
1034            duration_ms: None,
1035        };
1036        let json = serde_json::to_string(&msg).unwrap();
1037        assert!(json.contains(r#""type":"adventure_level_result""#));
1038        assert!(json.contains(r#""level":3"#));
1039        assert!(json.contains(r#""score":120"#));
1040        assert!(json.contains(r#""stars":2"#));
1041        assert!(json.contains(r#""personal_best":false"#));
1042        assert!(
1043            !json.contains("unlocked_level"),
1044            "unlocked_level should be omitted when None (got: {json})"
1045        );
1046        assert!(
1047            !json.contains("duration_ms"),
1048            "duration_ms should be omitted when None (got: {json})"
1049        );
1050
1051        // Both optional fields present: serialize with their canonical
1052        // field names. duration_ms must be a plain JSON number (u64);
1053        // JSON-wise that looks the same as a signed int, but exercising
1054        // the path ensures the u64 type didn't accidentally become a
1055        // stringified-int somewhere.
1056        let msg = ServerMessage::AdventureLevelResult {
1057            level: 3,
1058            score: 120,
1059            stars: 3,
1060            personal_best: true,
1061            unlocked_level: Some(4),
1062            duration_ms: Some(123_456),
1063        };
1064        let json = serde_json::to_string(&msg).unwrap();
1065        assert!(json.contains(r#""unlocked_level":4"#));
1066        assert!(json.contains(r#""duration_ms":123456"#));
1067
1068        // Round-trip — the deserialize path needs to accept the
1069        // serialized form and reproduce the same structure.
1070        let parsed: ServerMessage = serde_json::from_str(&json).unwrap();
1071        match parsed {
1072            ServerMessage::AdventureLevelResult {
1073                level,
1074                score,
1075                stars,
1076                personal_best,
1077                unlocked_level,
1078                duration_ms,
1079            } => {
1080                assert_eq!(level, 3);
1081                assert_eq!(score, 120);
1082                assert_eq!(stars, 3);
1083                assert!(personal_best);
1084                assert_eq!(unlocked_level, Some(4));
1085                assert_eq!(duration_ms, Some(123_456));
1086            }
1087            other => panic!("expected AdventureLevelResult, got {other:?}"),
1088        }
1089    }
1090
1091    #[test]
1092    fn daily_result_serializes() {
1093        let msg = ServerMessage::DailyResult {
1094            seed: 1_592_668_382,
1095            score: 240,
1096            personal_best: 240,
1097            rank: 3,
1098            attempts_used: 1,
1099            attempts_max: 1,
1100        };
1101        assert_eq!(msg.message_type(), "daily_result");
1102        let s = serde_json::to_string(&msg).unwrap();
1103        assert!(s.contains("\"type\":\"daily_result\""));
1104        assert!(s.contains("\"rank\":3"));
1105        let back: ServerMessage = serde_json::from_str(&s).unwrap();
1106        assert!(matches!(back, ServerMessage::DailyResult { rank: 3, .. }));
1107    }
1108
1109    #[test]
1110    fn game_started_time_remaining_optional() {
1111        let msg = ServerMessage::GameStarted {
1112            game_type: GameType::Open,
1113            game_id: "g1".into(),
1114            grid: vec![],
1115            players: vec![],
1116            your_turn_order: 0,
1117            current_turn: 42,
1118            round: 1,
1119            max_rounds: 1,
1120            turn_time_limit: None,
1121            custom: None,
1122            time_remaining_ms: None,
1123        };
1124        let s = serde_json::to_string(&msg).unwrap();
1125        assert!(!s.contains("time_remaining_ms"));
1126    }
1127
1128    #[test]
1129    fn test_adventure_event_serialization() {
1130        let msg = ServerMessage::AdventureEvent {
1131            game_id: "abc".to_string(),
1132            kind: types::AdventureEventKind::Bomb,
1133            affected_positions: vec![
1134                types::Position { row: 1, col: 2 },
1135                types::Position { row: 2, col: 2 },
1136            ],
1137            new_grid: vec![vec![
1138                // One plain cell + one hole cell — this lets the test
1139                // assert GridCell's is_hole flag on AdventureEvent's
1140                // embedded Grid field stays wire-compatible.
1141                types::GridCell {
1142                    letter: 'A',
1143                    value: 1,
1144                    multiplier: None,
1145                    has_gem: false,
1146                    is_hole: false,
1147                    is_poisoned: false,
1148                    is_abducted: false,
1149                },
1150                types::GridCell {
1151                    letter: ' ',
1152                    value: 0,
1153                    multiplier: None,
1154                    has_gem: false,
1155                    is_hole: true,
1156                    is_poisoned: false,
1157                    is_abducted: false,
1158                },
1159            ]],
1160            label: "💣 Bomb!".to_string(),
1161        };
1162
1163        let json = serde_json::to_string(&msg).unwrap();
1164        assert!(json.contains(r#""type":"adventure_event""#));
1165        assert!(json.contains(r#""game_id":"abc""#));
1166        // kind serializes via the enum's snake_case rename.
1167        assert!(json.contains(r#""kind":"bomb""#));
1168        // Affected positions are plain { row, col } objects.
1169        assert!(json.contains(r#""affected_positions":[{"row":1,"col":2}"#));
1170        // new_grid's plain cell omits is_hole; hole cell carries it.
1171        assert!(json.contains(r#""letter":"A""#));
1172        assert!(json.contains(r#""is_hole":true"#));
1173
1174        // Round-trip so we know the type tag + enum field decode correctly.
1175        let parsed: ServerMessage = serde_json::from_str(&json).unwrap();
1176        match parsed {
1177            ServerMessage::AdventureEvent {
1178                game_id,
1179                kind,
1180                affected_positions,
1181                new_grid,
1182                label,
1183            } => {
1184                assert_eq!(game_id, "abc");
1185                assert_eq!(kind, types::AdventureEventKind::Bomb);
1186                assert_eq!(affected_positions.len(), 2);
1187                assert_eq!(new_grid[0].len(), 2);
1188                assert!(new_grid[0][1].is_hole);
1189                assert_eq!(label, "💣 Bomb!");
1190            }
1191            other => panic!("expected AdventureEvent, got {other:?}"),
1192        }
1193    }
1194
1195    #[test]
1196    fn test_selection_update_serialization() {
1197        let msg = ServerMessage::SelectionUpdate {
1198            player_id: 11,
1199            game_id: "game1".to_string(),
1200            positions: vec![
1201                types::Position { row: 0, col: 0 },
1202                types::Position { row: 0, col: 1 },
1203            ],
1204        };
1205        let json = serde_json::to_string(&msg).unwrap();
1206        assert!(json.contains(r#""type":"selection_update""#));
1207        assert!(json.contains(r#""player_id":"11""#));
1208        assert!(json.contains(r#""game_id":"game1""#));
1209    }
1210
1211    #[test]
1212    fn test_player_queue_changed_serialization() {
1213        let msg = ServerMessage::PlayerPoolChanged {
1214            player_id: 123,
1215            old_pool: Some(GameType::Open),
1216            new_pool: Some(GameType::Adventure),
1217        };
1218        let json = serde_json::to_string(&msg).unwrap();
1219        assert!(json.contains(r#""type":"player_pool_changed""#));
1220        assert!(json.contains(r#""player_id":"123""#));
1221        assert!(json.contains(r#""old_pool":"open""#));
1222        assert!(json.contains(r#""new_pool":"adventure""#));
1223    }
1224
1225    #[test]
1226    fn test_debug_state_response_serialization() {
1227        let msg = ServerMessage::DebugStateResponse {
1228            timestamp: "2024-01-01T12:00:00Z".to_string(),
1229            player: DebugPlayerInfo {
1230                user_id: 987654321,
1231                username: "debug_user".to_string(),
1232            },
1233            websocket_context: DebugWebsocketContext {
1234                lobby_id: Some("lobby123".to_string()),
1235                game_id: Some("game456".to_string()),
1236                is_spectating: false,
1237            },
1238            lobby_state: Some(DebugLobbyState::Found {
1239                lobby_id: "lobby123".to_string(),
1240                player_in_lobby: true,
1241                lobby_player_ids: vec![111, 222, 333],
1242                active_game_id: Some("game456".to_string()),
1243            }),
1244            backend_game_state: Some(DebugBackendGameState::Found {
1245                game_id: "game456".to_string(),
1246                player_in_session_players: true,
1247                spectator_in_session: false,
1248                session_player_ids: vec![111, 222],
1249                session_spectator_ids: vec![],
1250                lobby_id: "lobby123".to_string(),
1251            }),
1252            handler_game_state: Some(DebugHandlerGameState::Found {
1253                game_id: "game456".to_string(),
1254                player_in_handler_game: true,
1255                handler_player_ids: vec![111, 222],
1256                current_turn_index: 0,
1257                round: 1,
1258                state: "in_progress".to_string(),
1259            }),
1260        };
1261
1262        // Test serialization
1263        let json = serde_json::to_string(&msg).unwrap();
1264
1265        // Verify type field
1266        assert!(json.contains(r#""type":"debug_state_response""#));
1267
1268        // Verify ID fields are serialized as numbers
1269        assert!(json.contains(r#""user_id":987654321"#));
1270        assert!(json.contains(r#""lobby_player_ids":[111,222,333]"#));
1271        assert!(json.contains(r#""session_player_ids":[111,222]"#));
1272        assert!(json.contains(r#""handler_player_ids":[111,222]"#));
1273
1274        // Verify other key fields
1275        assert!(json.contains(r#""timestamp":"2024-01-01T12:00:00Z""#));
1276        assert!(json.contains(r#""username":"debug_user""#));
1277        assert!(json.contains(r#""lobby_id":"lobby123""#));
1278        assert!(json.contains(r#""game_id":"game456""#));
1279        assert!(json.contains(r#""is_spectating":false"#));
1280        assert!(json.contains(r#""player_in_lobby":true"#));
1281
1282        // Test deserialization (round-trip)
1283        let deserialized: ServerMessage = serde_json::from_str(&json).unwrap();
1284        match deserialized {
1285            ServerMessage::DebugStateResponse {
1286                timestamp,
1287                player,
1288                websocket_context,
1289                lobby_state,
1290                backend_game_state,
1291                handler_game_state,
1292            } => {
1293                assert_eq!(timestamp, "2024-01-01T12:00:00Z");
1294                assert_eq!(player.user_id, 987654321);
1295                assert_eq!(player.username, "debug_user");
1296                assert_eq!(websocket_context.lobby_id, Some("lobby123".to_string()));
1297                assert_eq!(websocket_context.game_id, Some("game456".to_string()));
1298                assert!(!websocket_context.is_spectating);
1299                assert!(lobby_state.is_some());
1300                assert!(backend_game_state.is_some());
1301                assert!(handler_game_state.is_some());
1302            }
1303            _ => panic!("Expected DebugStateResponse message"),
1304        }
1305    }
1306
1307    #[test]
1308    fn test_debug_state_response_with_errors() {
1309        // Test with error variants in debug states
1310        let msg = ServerMessage::DebugStateResponse {
1311            timestamp: "2024-01-01T12:00:00Z".to_string(),
1312            player: DebugPlayerInfo {
1313                user_id: 123,
1314                username: "test".to_string(),
1315            },
1316            websocket_context: DebugWebsocketContext {
1317                lobby_id: None,
1318                game_id: None,
1319                is_spectating: false,
1320            },
1321            lobby_state: Some(DebugLobbyState::Error {
1322                error: "Lobby not found".to_string(),
1323            }),
1324            backend_game_state: Some(DebugBackendGameState::Error {
1325                error: "Game not found".to_string(),
1326            }),
1327            handler_game_state: Some(DebugHandlerGameState::Error {
1328                error: "Handler not found".to_string(),
1329            }),
1330        };
1331
1332        let json = serde_json::to_string(&msg).unwrap();
1333
1334        // Verify error messages are included
1335        assert!(json.contains(r#""error":"Lobby not found""#));
1336        assert!(json.contains(r#""error":"Game not found""#));
1337        assert!(json.contains(r#""error":"Handler not found""#));
1338
1339        // Round-trip test
1340        let deserialized: ServerMessage = serde_json::from_str(&json).unwrap();
1341        assert!(matches!(
1342            deserialized,
1343            ServerMessage::DebugStateResponse { .. }
1344        ));
1345    }
1346
1347    #[test]
1348    fn test_debug_state_response_minimal() {
1349        // Test with None values for optional fields
1350        let msg = ServerMessage::DebugStateResponse {
1351            timestamp: "2024-01-01T12:00:00Z".to_string(),
1352            player: DebugPlayerInfo {
1353                user_id: 456,
1354                username: "minimal_user".to_string(),
1355            },
1356            websocket_context: DebugWebsocketContext {
1357                lobby_id: None,
1358                game_id: None,
1359                is_spectating: true,
1360            },
1361            lobby_state: None,
1362            backend_game_state: None,
1363            handler_game_state: None,
1364        };
1365
1366        let json = serde_json::to_string(&msg).unwrap();
1367
1368        // Verify type field
1369        assert!(json.contains(r#""type":"debug_state_response""#));
1370
1371        // Verify required fields
1372        assert!(json.contains(r#""user_id":456"#));
1373        assert!(json.contains(r#""username":"minimal_user""#));
1374        assert!(json.contains(r#""is_spectating":true"#));
1375
1376        // Round-trip test
1377        let deserialized: ServerMessage = serde_json::from_str(&json).unwrap();
1378        match deserialized {
1379            ServerMessage::DebugStateResponse {
1380                player,
1381                lobby_state,
1382                backend_game_state,
1383                handler_game_state,
1384                ..
1385            } => {
1386                assert_eq!(player.user_id, 456);
1387                assert!(lobby_state.is_none());
1388                assert!(backend_game_state.is_none());
1389                assert!(handler_game_state.is_none());
1390            }
1391            _ => panic!("Expected DebugStateResponse message"),
1392        }
1393    }
1394
1395    #[test]
1396    fn test_news_announced_round_trip() {
1397        let now = chrono::Utc::now();
1398        let expires = now + chrono::Duration::hours(1);
1399        let original = ServerMessage::NewsAnnounced {
1400            item: NewsItemPayload {
1401                id: "abc-123".to_string(),
1402                title: "Scheduled Maintenance".to_string(),
1403                message: "Servers will restart for ~5 minutes.".to_string(),
1404                notification_type: NewsNotificationType::Maintenance,
1405                created_at: now,
1406                expires_at: Some(expires),
1407                auto_hide_seconds: 0,
1408                refresh_on_every_login: false,
1409                priority: 100,
1410            },
1411        };
1412
1413        let json = serde_json::to_string(&original).unwrap();
1414        assert!(json.contains(r#""type":"news_announced""#));
1415        assert!(json.contains(r#""notification_type":"maintenance""#));
1416
1417        let parsed: ServerMessage = serde_json::from_str(&json).unwrap();
1418        match parsed {
1419            ServerMessage::NewsAnnounced { item } => {
1420                assert_eq!(item.id, "abc-123");
1421                assert_eq!(item.notification_type, NewsNotificationType::Maintenance);
1422                assert_eq!(item.priority, 100);
1423                assert_eq!(item.expires_at, Some(expires));
1424            }
1425            _ => panic!("Expected NewsAnnounced"),
1426        }
1427    }
1428
1429    #[test]
1430    fn test_news_announced_excluded_from_replay() {
1431        // News is re-fetched from /api/news on reconnect, so replaying
1432        // the original announcement would duplicate or surface stale.
1433        let msg = ServerMessage::NewsAnnounced {
1434            item: NewsItemPayload {
1435                id: "x".to_string(),
1436                title: "t".to_string(),
1437                message: "m".to_string(),
1438                notification_type: NewsNotificationType::Announcement,
1439                created_at: chrono::Utc::now(),
1440                expires_at: None,
1441                auto_hide_seconds: 0,
1442                refresh_on_every_login: false,
1443                priority: 0,
1444            },
1445        };
1446        assert!(!msg.should_store_for_replay());
1447    }
1448
1449    #[test]
1450    fn game_started_custom_marker_roundtrips_and_omits_when_none() {
1451        use crate::protocol::types::CustomMeta;
1452
1453        // Build a GameStarted with custom: Some(CustomMeta {})
1454        let msg = ServerMessage::GameStarted {
1455            game_type: GameType::Open,
1456            game_id: "game-sandbox-1".to_string(),
1457            grid: vec![],
1458            players: vec![types::GamePlayerInfo {
1459                user_id: 111_222_333_444_555_666,
1460                username: "Player1".to_string(),
1461                name: None,
1462                avatar_url: None,
1463                turn_order: 0,
1464                score: 0,
1465                gems: 0,
1466                is_connected: true,
1467                team: None,
1468            }],
1469            your_turn_order: 0,
1470            current_turn: 111_222_333_444_555_666,
1471            round: 1,
1472            max_rounds: 5,
1473            turn_time_limit: None,
1474            custom: Some(CustomMeta {}),
1475            time_remaining_ms: None,
1476        };
1477        let json = serde_json::to_string(&msg).unwrap();
1478        assert!(
1479            json.contains("\"custom\""),
1480            "custom should serialize when Some (got: {json})"
1481        );
1482        let back: ServerMessage = serde_json::from_str(&json).unwrap();
1483        assert!(
1484            matches!(
1485                back,
1486                ServerMessage::GameStarted {
1487                    custom: Some(_),
1488                    ..
1489                }
1490            ),
1491            "custom should deserialize as Some"
1492        );
1493
1494        // Same shape but custom: None — field must be absent on the wire
1495        let ffa = ServerMessage::GameStarted {
1496            game_type: GameType::Open,
1497            game_id: "game-ffa-1".to_string(),
1498            grid: vec![],
1499            players: vec![],
1500            your_turn_order: 0,
1501            current_turn: 111_222_333_444_555_666,
1502            round: 1,
1503            max_rounds: 5,
1504            turn_time_limit: None,
1505            custom: None,
1506            time_remaining_ms: None,
1507        };
1508        let json2 = serde_json::to_string(&ffa).unwrap();
1509        assert!(
1510            !json2.contains("\"custom\""),
1511            "custom should be omitted when None (got: {json2})"
1512        );
1513    }
1514
1515    #[test]
1516    fn lobby_snapshot_carries_sandbox_config() {
1517        // Build a minimal LobbySnapshot with sandbox_config set.
1518        let mut snap = LobbySnapshot {
1519            lobby_id: "test-lobby".to_string(),
1520            lobby_type: LobbyType::Custom,
1521            lobby_code: None,
1522            players: vec![],
1523            games: vec![],
1524            max_players: 6,
1525            sandbox_config: None,
1526            host_id: None,
1527        };
1528        snap.sandbox_config = Some(GameConfig::default());
1529
1530        let json = serde_json::to_string(&snap).unwrap();
1531        assert!(
1532            json.contains("sandbox_config"),
1533            "sandbox_config should be present when Some (got: {json})"
1534        );
1535        let back: LobbySnapshot = serde_json::from_str(&json).unwrap();
1536        assert!(back.sandbox_config.is_some());
1537
1538        // Confirm it's omitted on the wire when None (back-compat).
1539        let plain = LobbySnapshot {
1540            lobby_id: "plain-lobby".to_string(),
1541            lobby_type: LobbyType::Custom,
1542            lobby_code: None,
1543            players: vec![],
1544            games: vec![],
1545            max_players: 6,
1546            sandbox_config: None,
1547            host_id: None,
1548        };
1549        assert!(
1550            !serde_json::to_string(&plain)
1551                .unwrap()
1552                .contains("sandbox_config"),
1553            "sandbox_config should be omitted when None"
1554        );
1555        assert!(
1556            !serde_json::to_string(&plain).unwrap().contains("host_id"),
1557            "host_id should be omitted when None"
1558        );
1559    }
1560
1561    #[test]
1562    fn lobby_snapshot_host_id_serializes_as_string_snowflake_safe() {
1563        // host_id must serialize as a STRING (like every other id) so JS
1564        // doesn't lose precision on snowflake-magnitude ids (>2^53). A
1565        // JSON-number would round and break host detection on the client.
1566        let snowflake: i64 = 123_456_789_012_345_678; // 18 digits, > 2^53
1567        let snap = LobbySnapshot {
1568            lobby_id: "lobby".to_string(),
1569            lobby_type: LobbyType::Custom,
1570            lobby_code: None,
1571            players: vec![],
1572            games: vec![],
1573            max_players: 6,
1574            sandbox_config: None,
1575            host_id: Some(snowflake),
1576        };
1577        let json = serde_json::to_string(&snap).unwrap();
1578        // Quoted = string on the wire; an unquoted number would be the bug.
1579        assert!(
1580            json.contains(r#""host_id":"123456789012345678""#),
1581            "host_id must serialize as a quoted string, got: {json}"
1582        );
1583        // Exact round-trip (no precision loss).
1584        let back: LobbySnapshot = serde_json::from_str(&json).unwrap();
1585        assert_eq!(back.host_id, Some(snowflake));
1586    }
1587
1588    #[test]
1589    fn runebox_word_accepted_round_trips() {
1590        let msg = ServerMessage::RuneBoxWordAccepted {
1591            game_id: "g1".to_string(),
1592            word: "RUNES".to_string(),
1593            letters: 5,
1594            total_score: 17,
1595        };
1596        let json = serde_json::to_string(&msg).unwrap();
1597        assert!(json.contains(r#""type":"rune_box_word_accepted""#));
1598        let back: ServerMessage = serde_json::from_str(&json).unwrap();
1599        match back {
1600            ServerMessage::RuneBoxWordAccepted {
1601                word,
1602                letters,
1603                total_score,
1604                ..
1605            } => {
1606                assert_eq!(word, "RUNES");
1607                assert_eq!(letters, 5);
1608                assert_eq!(total_score, 17);
1609            }
1610            other => panic!("wrong variant: {other:?}"),
1611        }
1612    }
1613
1614    #[test]
1615    fn runebox_score_changed_carries_no_word() {
1616        // THE anti-leak assertion. RuneBox found-sets are independent, so a word
1617        // another player found is still worth full value to you — telling you
1618        // what it was would hand you the answer. This variant having no `word`
1619        // field is what enforces that, so assert on the serialized form: a
1620        // future field addition fails here rather than silently shipping.
1621        let msg = ServerMessage::RuneBoxScoreChanged {
1622            player_id: 1_234_567_890_123_456_789,
1623            game_id: "g1".to_string(),
1624            total_score: 12,
1625        };
1626        let json = serde_json::to_string(&msg).unwrap();
1627        assert!(
1628            !json.contains("word"),
1629            "opponent broadcast leaked a word: {json}"
1630        );
1631        // Snowflakes ride as strings or they corrupt above 2^53 in JS.
1632        assert!(
1633            json.contains(r#""player_id":"1234567890123456789""#),
1634            "player_id must serialize as a quoted string, got: {json}"
1635        );
1636        let back: ServerMessage = serde_json::from_str(&json).unwrap();
1637        match back {
1638            ServerMessage::RuneBoxScoreChanged {
1639                player_id,
1640                total_score,
1641                ..
1642            } => {
1643                assert_eq!(player_id, 1_234_567_890_123_456_789);
1644                assert_eq!(total_score, 12);
1645            }
1646            other => panic!("wrong variant: {other:?}"),
1647        }
1648    }
1649
1650    #[test]
1651    fn runebox_messages_have_distinct_wire_types() {
1652        let accepted = ServerMessage::RuneBoxWordAccepted {
1653            game_id: "g1".to_string(),
1654            word: "RUNES".to_string(),
1655            letters: 5,
1656            total_score: 5,
1657        };
1658        let changed = ServerMessage::RuneBoxScoreChanged {
1659            player_id: 1,
1660            game_id: "g1".to_string(),
1661            total_score: 5,
1662        };
1663        assert_eq!(accepted.message_type(), "rune_box_word_accepted");
1664        assert_eq!(changed.message_type(), "rune_box_score_changed");
1665    }
1666
1667    /// The JOIN path must be self-describing too.
1668    ///
1669    /// v0.18.0 put the mode on `GameSnapshot` and stopped, which fixed the
1670    /// reconnect/spectate path and left the one a normal player actually takes
1671    /// still inferring. Both halves or neither.
1672    #[test]
1673    fn game_started_carries_its_game_type() {
1674        let msg = ServerMessage::GameStarted {
1675            game_type: GameType::RuneBox,
1676            game_id: "g1".to_string(),
1677            grid: vec![],
1678            players: vec![],
1679            your_turn_order: 0,
1680            current_turn: 1,
1681            round: 1,
1682            max_rounds: 3,
1683            turn_time_limit: None,
1684            custom: None,
1685            time_remaining_ms: Some(90_000),
1686        };
1687        let json = serde_json::to_string(&msg).unwrap();
1688        assert!(
1689            json.contains(r#""game_type":"rune_box""#),
1690            "game_started must name the mode, got: {json}"
1691        );
1692        let back: ServerMessage = serde_json::from_str(&json).unwrap();
1693        match back {
1694            ServerMessage::GameStarted { game_type, .. } => {
1695                assert_eq!(game_type, GameType::RuneBox);
1696            }
1697            other => panic!("wrong variant: {other:?}"),
1698        }
1699    }
1700
1701    /// A two-round match state used by the round-end tests below.
1702    fn round_ended_sample(
1703        next_round_starts_at: Option<chrono::DateTime<chrono::Utc>>,
1704    ) -> ServerMessage {
1705        ServerMessage::RuneBoxRoundEnded {
1706            game_id: "g1".to_string(),
1707            round: 1,
1708            max_rounds: 2,
1709            round_scores: vec![
1710                ScoreInfo {
1711                    user_id: 1_234_567_890_123_456_789,
1712                    username: "Ada".to_string(),
1713                    name: None,
1714                    score: 41,
1715                },
1716                ScoreInfo {
1717                    user_id: 8_876_543_210_987_654_321_i64,
1718                    username: "Bo".to_string(),
1719                    name: None,
1720                    score: 12,
1721                },
1722            ],
1723            eliminated: vec![8_876_543_210_987_654_321_i64],
1724            standings: vec![RuneBoxStanding {
1725                player_id: 1_234_567_890_123_456_789,
1726                username: "Ada".to_string(),
1727                name: None,
1728                round_wins: 1,
1729                total_score: 41,
1730                eliminated: false,
1731            }],
1732            next_round_starts_at,
1733        }
1734    }
1735
1736    /// Round-end must be its OWN message. `GameOver` keeps meaning "the match is
1737    /// over" — that is what makes this addition non-breaking for the modes that
1738    /// play exactly one round and only ever send `GameOver`.
1739    #[test]
1740    fn round_end_is_a_distinct_message_type_from_game_over() {
1741        let round_end = round_ended_sample(None);
1742        let match_end = ServerMessage::GameOver {
1743            game_id: "g1".to_string(),
1744            final_scores: vec![],
1745            winner_id: 1,
1746            is_draw: false,
1747        };
1748        assert_eq!(round_end.message_type(), "rune_box_round_ended");
1749        assert_eq!(match_end.message_type(), "game_over");
1750        assert_ne!(round_end.message_type(), match_end.message_type());
1751    }
1752
1753    #[test]
1754    fn round_ended_round_trips_with_snowflakes_as_strings() {
1755        let json = serde_json::to_string(&round_ended_sample(None)).unwrap();
1756        assert!(json.contains(r#""type":"rune_box_round_ended""#));
1757        // Every snowflake on this message rides as a quoted string, including
1758        // the ones inside the `eliminated` list — a bare i64 there corrupts
1759        // above 2^53 in JS exactly like a top-level one would.
1760        assert!(
1761            json.contains(r#""eliminated":["8876543210987654321"]"#),
1762            "eliminated ids must be quoted strings, got: {json}"
1763        );
1764        assert!(
1765            json.contains(r#""player_id":"1234567890123456789""#),
1766            "standings player_id must be a quoted string, got: {json}"
1767        );
1768        assert!(
1769            json.contains(r#""user_id":"1234567890123456789""#),
1770            "round score user_id must be a quoted string, got: {json}"
1771        );
1772
1773        let back: ServerMessage = serde_json::from_str(&json).unwrap();
1774        match back {
1775            ServerMessage::RuneBoxRoundEnded {
1776                round,
1777                max_rounds,
1778                round_scores,
1779                eliminated,
1780                standings,
1781                next_round_starts_at,
1782                ..
1783            } => {
1784                assert_eq!(round, 1);
1785                assert_eq!(max_rounds, 2);
1786                assert_eq!(round_scores.len(), 2);
1787                assert_eq!(eliminated, vec![8_876_543_210_987_654_321_i64]);
1788                assert_eq!(standings[0].round_wins, 1);
1789                assert_eq!(standings[0].total_score, 41);
1790                assert!(!standings[0].eliminated);
1791                assert!(next_round_starts_at.is_none());
1792            }
1793            other => panic!("wrong variant: {other:?}"),
1794        }
1795    }
1796
1797    /// The SP1 anti-leak rule does not lapse between rounds. An eliminated
1798    /// player is still watching, and the survivors keep playing boards they
1799    /// remember, so a round-end summary must never name anyone's words.
1800    #[test]
1801    fn round_ended_leaks_no_words() {
1802        let json = serde_json::to_string(&round_ended_sample(None)).unwrap();
1803        assert!(
1804            !json.contains("word"),
1805            "round-end summary leaked a word field: {json}"
1806        );
1807    }
1808
1809    /// `None` means "not scheduled yet", not "match over" — so it must be
1810    /// omitted rather than serialized as an explicit null a client could
1811    /// misread as a value.
1812    #[test]
1813    fn round_ended_omits_unscheduled_next_round() {
1814        let json = serde_json::to_string(&round_ended_sample(None)).unwrap();
1815        assert!(!json.contains("next_round_starts_at"), "got: {json}");
1816
1817        let at = chrono::DateTime::parse_from_rfc3339("2026-07-29T12:00:00Z")
1818            .unwrap()
1819            .with_timezone(&chrono::Utc);
1820        let json = serde_json::to_string(&round_ended_sample(Some(at))).unwrap();
1821        assert!(json.contains("next_round_starts_at"), "got: {json}");
1822        let back: ServerMessage = serde_json::from_str(&json).unwrap();
1823        match back {
1824            ServerMessage::RuneBoxRoundEnded {
1825                next_round_starts_at,
1826                ..
1827            } => assert_eq!(next_round_starts_at, Some(at)),
1828            other => panic!("wrong variant: {other:?}"),
1829        }
1830    }
1831}