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, DebugBackendGameState, DebugHandlerGameState, DebugLobbyState,
20    DebugPlayerInfo, DebugWebsocketContext, ErrorCode, GameChange, GamePlayerInfo, GameSnapshot,
21    Grid, LobbyChange, LobbyGameInfo, LobbyPlayerInfo, LobbyType, PlayerInfo,
22    RematchCountdownState, ScoreInfo, SpectatorInfo, TimerVoteState,
23};
24
25/// Messages sent from server to client.
26#[serde_with::serde_as]
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(tag = "type", rename_all = "snake_case")]
29pub enum ServerMessage {
30    // ========================================================================
31    // Connection Messages
32    // ========================================================================
33    /// Initial server greeting after WebSocket connect.
34    ///
35    /// Sent immediately upon connection, before Identify.
36    Hello {
37        /// Recommended heartbeat interval in milliseconds
38        heartbeat_interval_ms: u32,
39        /// Server version for compatibility checks
40        #[serde(skip_serializing_if = "Option::is_none")]
41        server_version: Option<String>,
42    },
43
44    /// Successful authentication response.
45    ///
46    /// Contains the full initial state snapshot.
47    Ready {
48        /// Unique session ID for this connection
49        session_id: String,
50        /// The authenticated player's user ID
51        #[serde_as(as = "serde_with::DisplayFromStr")]
52        player_id: i64,
53        /// Full lobby state (if in a lobby)
54        #[serde(skip_serializing_if = "Option::is_none")]
55        lobby: Option<LobbySnapshot>,
56        /// Full game state (if in a game)
57        #[serde(skip_serializing_if = "Option::is_none")]
58        game: Option<GameSnapshot>,
59    },
60
61    /// Session resumed successfully after reconnect.
62    ///
63    /// Contains any events missed during disconnection.
64    Resumed {
65        /// Events that occurred while disconnected
66        missed_events: Vec<ServerMessage>,
67    },
68
69    /// Heartbeat response.
70    ///
71    /// Echoes back for latency calculation.
72    HeartbeatAck {
73        /// Server timestamp when heartbeat was received
74        server_time: u64,
75    },
76
77    /// Session is invalid or expired.
78    ///
79    /// Client should re-authenticate.
80    InvalidSession { reason: String },
81
82    // ========================================================================
83    // Lobby State Messages
84    // ========================================================================
85    /// Sent when successfully joining a lobby.
86    LobbyJoined {
87        lobby_id: String,
88        /// 6-char code for custom lobbies
89        #[serde(skip_serializing_if = "Option::is_none")]
90        lobby_code: Option<String>,
91        /// Full lobby state
92        lobby: LobbySnapshot,
93    },
94
95    /// Full lobby state snapshot.
96    ///
97    /// Sent on initial join or when delta sync fails.
98    LobbySnapshot { lobby: LobbySnapshot },
99
100    /// Incremental lobby state update.
101    ///
102    /// More efficient than full snapshots for small changes.
103    LobbyDelta { changes: Vec<LobbyChange> },
104
105    /// Confirmation of leaving lobby.
106    LobbyLeft,
107
108    /// Custom lobby was created successfully.
109    CustomLobbyCreated {
110        lobby_id: String,
111        lobby_code: String,
112    },
113    // ========================================================================
114    // Game Lifecycle Messages
115    // ========================================================================
116    /// A new game has started.
117    ///
118    /// Contains initial game state for all participants.
119    GameStarted {
120        game_id: String,
121        grid: Grid,
122        players: Vec<GamePlayerInfo>,
123        /// Your turn order (0-indexed)
124        your_turn_order: u8,
125        /// Who goes first
126        #[serde_as(as = "serde_with::DisplayFromStr")]
127        current_turn: i64,
128        round: u8,
129        max_rounds: u8,
130        /// Turn time limit in seconds (if configured)
131        #[serde(skip_serializing_if = "Option::is_none")]
132        turn_time_limit: Option<u32>,
133    },
134
135    /// Full game state snapshot.
136    ///
137    /// Sent when joining as spectator or when delta sync fails.
138    GameSnapshot { game_id: String, game: GameSnapshot },
139
140    /// Incremental game state update.
141    GameDelta {
142        game_id: String,
143        changes: Vec<GameChange>,
144    },
145
146    /// Game has ended normally.
147    GameOver {
148        game_id: String,
149        /// Final scores, sorted by rank
150        final_scores: Vec<ScoreInfo>,
151        /// Winner's user ID
152        #[serde_as(as = "serde_with::DisplayFromStr")]
153        winner_id: i64,
154        /// Whether it was a draw
155        #[serde(default)]
156        is_draw: bool,
157    },
158
159    /// Game was cancelled (not enough players, host left, etc.).
160    GameCancelled { game_id: String, reason: String },
161
162    // ========================================================================
163    // Game Event Messages
164    // ========================================================================
165    /// A player joined the lobby.
166    PlayerJoined { player: LobbyPlayerInfo },
167
168    /// A player left the lobby.
169    PlayerLeft {
170        #[serde_as(as = "serde_with::DisplayFromStr")]
171        player_id: i64,
172        #[serde(skip_serializing_if = "Option::is_none")]
173        reason: Option<String>,
174    },
175
176    /// A player reconnected after disconnection.
177    PlayerReconnected {
178        #[serde_as(as = "serde_with::DisplayFromStr")]
179        player_id: i64,
180    },
181
182    /// A player disconnected (may reconnect).
183    PlayerDisconnected {
184        #[serde(skip_serializing_if = "Option::is_none")]
185        game_id: Option<String>,
186        #[serde_as(as = "serde_with::DisplayFromStr")]
187        player_id: i64,
188        /// Grace period in seconds before they're removed
189        grace_period_seconds: u32,
190    },
191
192    /// A word was successfully scored.
193    WordScored {
194        #[serde_as(as = "serde_with::DisplayFromStr")]
195        player_id: i64,
196        game_id: String,
197        word: String,
198        score: i32,
199        /// Positions that formed the word
200        path: Vec<super::types::Position>,
201        /// New total score
202        total_score: i32,
203        /// Gems earned from this word
204        gems_earned: i32,
205        /// New gem total
206        total_gems: i32,
207        /// Updated grid (letters replaced)
208        new_grid: Grid,
209    },
210
211    /// Turn changed to another player.
212    TurnChanged {
213        #[serde_as(as = "serde_with::DisplayFromStr")]
214        player_id: i64,
215        game_id: String,
216        round: u8,
217        /// Time remaining for this turn (if timer active)
218        #[serde(skip_serializing_if = "Option::is_none")]
219        time_remaining: Option<u32>,
220    },
221
222    /// A player passed their turn.
223    TurnPassed {
224        #[serde_as(as = "serde_with::DisplayFromStr")]
225        player_id: i64,
226        game_id: String,
227    },
228
229    /// Round number changed.
230    RoundChanged {
231        game_id: String,
232        round: u8,
233        max_rounds: u8,
234        /// New grid if board was regenerated at the start of this round.
235        /// Only present when the game is configured with `regenerate_board_each_round: true`.
236        #[serde(skip_serializing_if = "Option::is_none")]
237        new_grid: Option<Grid>,
238    },
239
240    /// Board was shuffled.
241    BoardShuffled {
242        #[serde_as(as = "serde_with::DisplayFromStr")]
243        player_id: i64,
244        game_id: String,
245        new_grid: Grid,
246        gems_spent: i32,
247        /// Player's remaining gems after shuffle
248        total_gems: i32,
249    },
250
251    /// A tile was swapped.
252    TileSwapped {
253        #[serde_as(as = "serde_with::DisplayFromStr")]
254        player_id: i64,
255        game_id: String,
256        row: usize,
257        col: usize,
258        old_letter: char,
259        new_letter: char,
260        gems_spent: i32,
261        /// Player's remaining gems after swap
262        total_gems: i32,
263    },
264
265    /// Player entered swap mode (for animation).
266    SwapModeEntered {
267        #[serde_as(as = "serde_with::DisplayFromStr")]
268        player_id: i64,
269        game_id: String,
270    },
271
272    /// Player exited swap mode.
273    SwapModeExited {
274        #[serde_as(as = "serde_with::DisplayFromStr")]
275        player_id: i64,
276        game_id: String,
277    },
278
279    // ========================================================================
280    // Spectator Messages
281    // ========================================================================
282    /// Successfully joined as spectator.
283    SpectatorJoined {
284        game_id: String,
285        /// Full game state
286        game: GameSnapshot,
287    },
288
289    /// A new spectator joined (broadcast to others).
290    SpectatorAdded {
291        spectator: SpectatorInfo,
292        game_id: String,
293    },
294
295    /// A spectator left.
296    SpectatorRemoved {
297        #[serde_as(as = "serde_with::DisplayFromStr")]
298        spectator_id: i64,
299        game_id: String,
300    },
301
302    /// Spectator joined as player.
303    SpectatorBecamePlayer {
304        #[serde_as(as = "serde_with::DisplayFromStr")]
305        player_id: i64,
306        username: String,
307        game_id: String,
308    },
309
310    /// Confirmation of leaving spectator mode.
311    SpectatorLeft,
312
313    // ========================================================================
314    // Live Update Messages
315    // ========================================================================
316    /// Another player's tile selection (for live preview).
317    SelectionUpdate {
318        #[serde_as(as = "serde_with::DisplayFromStr")]
319        player_id: i64,
320        game_id: String,
321        positions: Vec<super::types::Position>,
322    },
323
324    // ========================================================================
325    // Timer Vote Messages
326    // ========================================================================
327    /// Timer vote state changed.
328    TimerVoteUpdate {
329        state: TimerVoteState,
330        game_id: String,
331    },
332
333    /// Turn timer started (vote passed).
334    TurnTimerStarted {
335        #[serde_as(as = "serde_with::DisplayFromStr")]
336        target_player_id: i64,
337        game_id: String,
338        seconds: u32,
339    },
340
341    /// Turn timer expired - player auto-passed.
342    TurnTimerExpired {
343        #[serde_as(as = "serde_with::DisplayFromStr")]
344        player_id: i64,
345        game_id: String,
346    },
347
348    // ========================================================================
349    // Rematch Countdown Messages
350    // ========================================================================
351    /// Rematch countdown state update.
352    ///
353    /// Sent to all players on the results screen after a game ends.
354    RematchCountdownUpdate {
355        /// Current countdown state
356        state: RematchCountdownState,
357        /// The game that just ended
358        previous_game_id: String,
359    },
360
361    /// A player opted out of rematch pool.
362    ///
363    /// Broadcast to remaining players so they can update the player list.
364    PlayerLeftRematch {
365        #[serde_as(as = "serde_with::DisplayFromStr")]
366        player_id: i64,
367        /// The game they left from
368        previous_game_id: String,
369    },
370
371    /// Rematch is starting (sent right before `GameStarted`).
372    ///
373    /// Allows frontend to show "Starting..." before the new game begins.
374    RematchStarting {
375        /// Who triggered the early start (None if countdown expired naturally)
376        #[serde(skip_serializing_if = "Option::is_none")]
377        #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
378        triggered_by: Option<i64>,
379        /// The previous game that ended
380        previous_game_id: String,
381    },
382
383    // ========================================================================
384    // Pool
385    // ========================================================================
386    //
387    PlayerPoolChanged {
388        #[serde_as(as = "serde_with::DisplayFromStr")]
389        player_id: i64,
390        old_pool: Option<GameType>,
391        new_pool: Option<GameType>,
392    },
393
394    // ========================================================================
395    // Game Pool Messages (Legacy)
396    // ========================================================================
397    /// Player joined the game pool.
398    PoolJoined {
399        position: i32,
400        total_in_pool: i32,
401        game_id: String,
402    },
403
404    /// Pool position updated.
405    PoolUpdate {
406        position: i32,
407        total_in_pool: i32,
408        game_id: String,
409    },
410
411    /// Left the pool.
412    PoolLeft,
413
414    // ========================================================================
415    // Admin Messages
416    // ========================================================================
417    /// Response to admin game list request.
418    AdminGamesList { games: Vec<AdminGameInfo> },
419
420    /// Game was deleted by admin.
421    AdminGameDeleted { game_id: String },
422
423    // ========================================================================
424    // Legacy Compatibility Messages
425    // ========================================================================
426    /// Generic state update (legacy format).
427    ///
428    /// Used for backward compatibility with existing frontend.
429    #[serde(rename = "game_state")]
430    GameStateUpdate {
431        game_id: String,
432        state: String,
433        grid: Grid,
434        players: Vec<PlayerInfo>,
435        current_turn: i64,
436        round: i32,
437        max_rounds: i32,
438        used_words: Vec<String>,
439        spectators: Vec<SpectatorInfo>,
440        timer_vote_state: TimerVoteState,
441    },
442
443    /// Lobby state update (legacy format).
444    #[serde(rename = "lobby_state")]
445    LobbyStateUpdate {
446        lobby_id: String,
447        players: Vec<LobbyPlayerInfo>,
448        games: Vec<LobbyGameInfo>,
449    },
450
451    // ========================================================================
452    // Debug Messages
453    // ========================================================================
454    /// Debug state response with player context diagnostics.
455    DebugStateResponse {
456        timestamp: String,
457        player: DebugPlayerInfo,
458        websocket_context: DebugWebsocketContext,
459        lobby_state: Option<DebugLobbyState>,
460        backend_game_state: Option<DebugBackendGameState>,
461        handler_game_state: Option<DebugHandlerGameState>,
462    },
463
464    // ========================================================================
465    // Error Messages
466    // ========================================================================
467    /// Error response.
468    Error {
469        code: ErrorCode,
470        message: String,
471        /// Additional context (e.g., which field was invalid)
472        #[serde(skip_serializing_if = "Option::is_none")]
473        details: Option<serde_json::Value>,
474    },
475}
476
477impl ServerMessage {
478    /// Create an error message from an error code.
479    #[must_use]
480    pub fn error(code: ErrorCode) -> Self {
481        Self::Error {
482            message: code.message().to_string(),
483            code,
484            details: None,
485        }
486    }
487
488    /// Create an error message with custom message.
489    pub fn error_with_message(code: ErrorCode, message: impl Into<String>) -> Self {
490        Self::Error {
491            code,
492            message: message.into(),
493            details: None,
494        }
495    }
496
497    /// Create an error message with details.
498    pub fn error_with_details(
499        code: ErrorCode,
500        message: impl Into<String>,
501        details: serde_json::Value,
502    ) -> Self {
503        Self::Error {
504            code,
505            message: message.into(),
506            details: Some(details),
507        }
508    }
509
510    /// Get the message type as a string (for logging/debugging).
511    #[must_use]
512    pub fn message_type(&self) -> &'static str {
513        match self {
514            Self::Hello { .. } => "hello",
515            Self::Ready { .. } => "ready",
516            Self::Resumed { .. } => "resumed",
517            Self::HeartbeatAck { .. } => "heartbeat_ack",
518            Self::InvalidSession { .. } => "invalid_session",
519            Self::LobbyJoined { .. } => "lobby_joined",
520            Self::LobbySnapshot { .. } => "lobby_snapshot",
521            Self::LobbyDelta { .. } => "lobby_delta",
522            Self::LobbyLeft => "lobby_left",
523            Self::CustomLobbyCreated { .. } => "custom_lobby_created",
524            Self::GameStarted { .. } => "game_started",
525            Self::GameSnapshot { .. } => "game_snapshot",
526            Self::GameDelta { .. } => "game_delta",
527            Self::GameOver { .. } => "game_over",
528            Self::GameCancelled { .. } => "game_cancelled",
529            Self::PlayerJoined { .. } => "player_joined",
530            Self::PlayerLeft { .. } => "player_left",
531            Self::PlayerReconnected { .. } => "player_reconnected",
532            Self::PlayerDisconnected { .. } => "player_disconnected",
533            Self::PlayerPoolChanged { .. } => "player_pool_changed",
534            Self::WordScored { .. } => "word_scored",
535            Self::TurnChanged { .. } => "turn_changed",
536            Self::TurnPassed { .. } => "turn_passed",
537            Self::RoundChanged { .. } => "round_changed",
538            Self::BoardShuffled { .. } => "board_shuffled",
539            Self::TileSwapped { .. } => "tile_swapped",
540            Self::SwapModeEntered { .. } => "swap_mode_entered",
541            Self::SwapModeExited { .. } => "swap_mode_exited",
542            Self::SpectatorJoined { .. } => "spectator_joined",
543            Self::SpectatorAdded { .. } => "spectator_added",
544            Self::SpectatorRemoved { .. } => "spectator_removed",
545            Self::SpectatorLeft => "spectator_left",
546            Self::SpectatorBecamePlayer { .. } => "spectator_became_player",
547            Self::SelectionUpdate { .. } => "selection_update",
548            Self::TimerVoteUpdate { .. } => "timer_vote_update",
549            Self::TurnTimerStarted { .. } => "turn_timer_started",
550            Self::TurnTimerExpired { .. } => "turn_timer_expired",
551            Self::RematchCountdownUpdate { .. } => "rematch_countdown_update",
552            Self::PlayerLeftRematch { .. } => "player_left_rematch",
553            Self::RematchStarting { .. } => "rematch_starting",
554            Self::PoolJoined { .. } => "pool_joined",
555            Self::PoolUpdate { .. } => "pool_update",
556            Self::PoolLeft => "pool_left",
557            Self::AdminGamesList { .. } => "admin_games_list",
558            Self::AdminGameDeleted { .. } => "admin_game_deleted",
559            Self::GameStateUpdate { .. } => "game_state",
560            Self::LobbyStateUpdate { .. } => "lobby_state",
561            Self::DebugStateResponse { .. } => "debug_state_response",
562
563            Self::Error { .. } => "error",
564        }
565    }
566
567    /// Check if this is an error message.
568    #[must_use]
569    pub fn is_error(&self) -> bool {
570        matches!(self, Self::Error { .. })
571    }
572
573    /// Check if this message should be stored for reconnection replay.
574    ///
575    /// Some messages are transient and don't need to be replayed.
576    #[must_use]
577    pub fn should_store_for_replay(&self) -> bool {
578        !matches!(
579            self,
580            Self::Hello { .. }
581                | Self::HeartbeatAck { .. }
582                | Self::SelectionUpdate { .. }
583                | Self::TimerVoteUpdate {
584                    state: TimerVoteState::Idle,
585                    ..
586                }
587                | Self::PlayerPoolChanged { .. }
588                | Self::TurnTimerStarted { .. }
589                | Self::TurnTimerExpired { .. }
590                | Self::DebugStateResponse { .. }
591        )
592    }
593}
594
595/// Convert a `ServerMessage` to a `serde_json::Value`.
596impl From<ServerMessage> for serde_json::Value {
597    fn from(msg: ServerMessage) -> Self {
598        serde_json::to_value(msg).unwrap()
599    }
600}
601
602/// Convert a `serde_json::Value` to a `ServerMessage`.
603impl TryFrom<serde_json::Value> for ServerMessage {
604    type Error = serde_json::Error;
605
606    fn try_from(
607        value: serde_json::Value,
608    ) -> Result<Self, <ServerMessage as TryFrom<serde_json::Value>>::Error> {
609        serde_json::from_value(value)
610    }
611}
612// ============================================================================
613// Snapshot Types
614// ============================================================================
615
616/// Complete lobby state snapshot.
617#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct LobbySnapshot {
619    pub lobby_id: String,
620    pub lobby_type: LobbyType,
621    #[serde(skip_serializing_if = "Option::is_none")]
622    pub lobby_code: Option<String>,
623    pub players: Vec<LobbyPlayerInfo>,
624    pub games: Vec<LobbyGameInfo>,
625    /// Maximum players allowed
626    #[serde(default = "default_max_players")]
627    pub max_players: u8,
628}
629
630fn default_max_players() -> u8 {
631    6
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use crate::protocol::{types, GameType};
638
639    #[test]
640    fn test_server_message_into_json() {
641        let msg = ServerMessage::BoardShuffled {
642            player_id: 1_234_567_890,
643            game_id: "1234567890".to_string(),
644            new_grid: Grid::new(),
645            gems_spent: 0,
646            total_gems: 0,
647        };
648        let json = serde_json::to_string(&msg).unwrap();
649        assert!(json.contains(r#""type":"board_shuffled""#));
650        assert!(json.contains(r#""player_id":"1234567890""#));
651        assert!(json.contains(r#""game_id":"1234567890""#));
652        assert!(json.contains(r#""new_grid":[]"#));
653        assert!(json.contains(r#""gems_spent":0"#));
654        assert!(json.contains(r#""total_gems":0"#));
655    }
656
657    #[test]
658    fn test_heartbeat_ack_serialization() {
659        let msg = ServerMessage::HeartbeatAck {
660            server_time: 1_701_234_567_890,
661        };
662        let json = serde_json::to_string(&msg).unwrap();
663        assert!(json.contains(r#""type":"heartbeat_ack""#));
664        assert!(json.contains(r#""server_time":1701234567890"#));
665    }
666
667    #[test]
668    fn test_error_message_creation() {
669        let msg = ServerMessage::error(ErrorCode::NotYourTurn);
670        match msg {
671            ServerMessage::Error { code, message, .. } => {
672                assert_eq!(code, ErrorCode::NotYourTurn);
673                assert_eq!(message, "It's not your turn");
674            }
675            _ => panic!("Expected error message"),
676        }
677    }
678
679    #[test]
680    fn test_error_with_details() {
681        let msg = ServerMessage::error_with_details(
682            ErrorCode::InvalidPath,
683            "Tiles must be adjacent",
684            serde_json::json!({"positions": [[0,0], [2,2]]}),
685        );
686        match msg {
687            ServerMessage::Error { details, .. } => {
688                assert!(details.is_some());
689            }
690            _ => panic!("Expected error message"),
691        }
692    }
693
694    #[test]
695    fn test_player_joined_serialization() {
696        let msg = ServerMessage::PlayerJoined {
697            player: LobbyPlayerInfo {
698                user_id: 123,
699                username: "TestPlayer".to_string(),
700                avatar_url: None,
701                banner_url: None,
702                accent_color: None,
703                current_game_pool: None,
704                active_game_id: None,
705                spectate_game_id: None,
706            },
707        };
708        let json = serde_json::to_string(&msg).unwrap();
709        assert!(json.contains(r#""type":"player_joined""#));
710        assert!(json.contains(r#""user_id":"123""#));
711    }
712
713    #[test]
714    fn test_game_state_update_legacy() {
715        // Verify legacy format still works
716        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"}}"#;
717        let msg: ServerMessage = serde_json::from_str(json).unwrap();
718        assert!(matches!(msg, ServerMessage::GameStateUpdate { .. }));
719    }
720
721    #[test]
722    fn test_should_store_for_replay() {
723        assert!(!ServerMessage::HeartbeatAck { server_time: 0 }.should_store_for_replay());
724        assert!(ServerMessage::PlayerJoined {
725            player: LobbyPlayerInfo {
726                user_id: 1,
727                username: "x".into(),
728                avatar_url: None,
729                banner_url: None,
730                accent_color: None,
731                current_game_pool: None,
732                active_game_id: None,
733                spectate_game_id: None,
734            }
735        }
736        .should_store_for_replay());
737    }
738
739    #[test]
740    fn test_message_type() {
741        assert_eq!(
742            ServerMessage::HeartbeatAck { server_time: 0 }.message_type(),
743            "heartbeat_ack"
744        );
745        assert_eq!(
746            ServerMessage::error(ErrorCode::NotYourTurn).message_type(),
747            "error"
748        );
749    }
750
751    #[test]
752    fn test_selection_update_serialization() {
753        let msg = ServerMessage::SelectionUpdate {
754            player_id: 11,
755            game_id: "game1".to_string(),
756            positions: vec![
757                types::Position { row: 0, col: 0 },
758                types::Position { row: 0, col: 1 },
759            ],
760        };
761        let json = serde_json::to_string(&msg).unwrap();
762        assert!(json.contains(r#""type":"selection_update""#));
763        assert!(json.contains(r#""player_id":"11""#));
764        assert!(json.contains(r#""game_id":"game1""#));
765    }
766
767    #[test]
768    fn test_player_queue_changed_serialization() {
769        let msg = ServerMessage::PlayerPoolChanged {
770            player_id: 123,
771            old_pool: Some(GameType::Open),
772            new_pool: Some(GameType::Adventure),
773        };
774        let json = serde_json::to_string(&msg).unwrap();
775        assert!(json.contains(r#""type":"player_pool_changed""#));
776        assert!(json.contains(r#""player_id":"123""#));
777        assert!(json.contains(r#""old_pool":"open""#));
778        assert!(json.contains(r#""new_pool":"adventure""#));
779    }
780
781    #[test]
782    fn test_debug_state_response_serialization() {
783        let msg = ServerMessage::DebugStateResponse {
784            timestamp: "2024-01-01T12:00:00Z".to_string(),
785            player: DebugPlayerInfo {
786                user_id: 987654321,
787                username: "debug_user".to_string(),
788            },
789            websocket_context: DebugWebsocketContext {
790                lobby_id: Some("lobby123".to_string()),
791                game_id: Some("game456".to_string()),
792                is_spectating: false,
793            },
794            lobby_state: Some(DebugLobbyState::Found {
795                lobby_id: "lobby123".to_string(),
796                player_in_lobby: true,
797                lobby_player_ids: vec![111, 222, 333],
798                active_game_id: Some("game456".to_string()),
799            }),
800            backend_game_state: Some(DebugBackendGameState::Found {
801                game_id: "game456".to_string(),
802                player_in_session_players: true,
803                spectator_in_session: false,
804                session_player_ids: vec![111, 222],
805                session_spectator_ids: vec![],
806                lobby_id: "lobby123".to_string(),
807            }),
808            handler_game_state: Some(DebugHandlerGameState::Found {
809                game_id: "game456".to_string(),
810                player_in_handler_game: true,
811                handler_player_ids: vec![111, 222],
812                current_turn_index: 0,
813                round: 1,
814                state: "in_progress".to_string(),
815            }),
816        };
817
818        // Test serialization
819        let json = serde_json::to_string(&msg).unwrap();
820        
821        // Verify type field
822        assert!(json.contains(r#""type":"debug_state_response""#));
823        
824        // Verify ID fields are serialized as numbers
825        assert!(json.contains(r#""user_id":987654321"#));
826        assert!(json.contains(r#""lobby_player_ids":[111,222,333]"#));
827        assert!(json.contains(r#""session_player_ids":[111,222]"#));
828        assert!(json.contains(r#""handler_player_ids":[111,222]"#));
829        
830        // Verify other key fields
831        assert!(json.contains(r#""timestamp":"2024-01-01T12:00:00Z""#));
832        assert!(json.contains(r#""username":"debug_user""#));
833        assert!(json.contains(r#""lobby_id":"lobby123""#));
834        assert!(json.contains(r#""game_id":"game456""#));
835        assert!(json.contains(r#""is_spectating":false"#));
836        assert!(json.contains(r#""player_in_lobby":true"#));
837        
838        // Test deserialization (round-trip)
839        let deserialized: ServerMessage = serde_json::from_str(&json).unwrap();
840        match deserialized {
841            ServerMessage::DebugStateResponse {
842                timestamp,
843                player,
844                websocket_context,
845                lobby_state,
846                backend_game_state,
847                handler_game_state,
848            } => {
849                assert_eq!(timestamp, "2024-01-01T12:00:00Z");
850                assert_eq!(player.user_id, 987654321);
851                assert_eq!(player.username, "debug_user");
852                assert_eq!(websocket_context.lobby_id, Some("lobby123".to_string()));
853                assert_eq!(websocket_context.game_id, Some("game456".to_string()));
854                assert!(!websocket_context.is_spectating);
855                assert!(lobby_state.is_some());
856                assert!(backend_game_state.is_some());
857                assert!(handler_game_state.is_some());
858            }
859            _ => panic!("Expected DebugStateResponse message"),
860        }
861    }
862
863    #[test]
864    fn test_debug_state_response_with_errors() {
865        // Test with error variants in debug states
866        let msg = ServerMessage::DebugStateResponse {
867            timestamp: "2024-01-01T12:00:00Z".to_string(),
868            player: DebugPlayerInfo {
869                user_id: 123,
870                username: "test".to_string(),
871            },
872            websocket_context: DebugWebsocketContext {
873                lobby_id: None,
874                game_id: None,
875                is_spectating: false,
876            },
877            lobby_state: Some(DebugLobbyState::Error {
878                error: "Lobby not found".to_string(),
879            }),
880            backend_game_state: Some(DebugBackendGameState::Error {
881                error: "Game not found".to_string(),
882            }),
883            handler_game_state: Some(DebugHandlerGameState::Error {
884                error: "Handler not found".to_string(),
885            }),
886        };
887
888        let json = serde_json::to_string(&msg).unwrap();
889        
890        // Verify error messages are included
891        assert!(json.contains(r#""error":"Lobby not found""#));
892        assert!(json.contains(r#""error":"Game not found""#));
893        assert!(json.contains(r#""error":"Handler not found""#));
894        
895        // Round-trip test
896        let deserialized: ServerMessage = serde_json::from_str(&json).unwrap();
897        assert!(matches!(deserialized, ServerMessage::DebugStateResponse { .. }));
898    }
899
900    #[test]
901    fn test_debug_state_response_minimal() {
902        // Test with None values for optional fields
903        let msg = ServerMessage::DebugStateResponse {
904            timestamp: "2024-01-01T12:00:00Z".to_string(),
905            player: DebugPlayerInfo {
906                user_id: 456,
907                username: "minimal_user".to_string(),
908            },
909            websocket_context: DebugWebsocketContext {
910                lobby_id: None,
911                game_id: None,
912                is_spectating: true,
913            },
914            lobby_state: None,
915            backend_game_state: None,
916            handler_game_state: None,
917        };
918
919        let json = serde_json::to_string(&msg).unwrap();
920        
921        // Verify type field
922        assert!(json.contains(r#""type":"debug_state_response""#));
923        
924        // Verify required fields
925        assert!(json.contains(r#""user_id":456"#));
926        assert!(json.contains(r#""username":"minimal_user""#));
927        assert!(json.contains(r#""is_spectating":true"#));
928        
929        // Round-trip test
930        let deserialized: ServerMessage = serde_json::from_str(&json).unwrap();
931        match deserialized {
932            ServerMessage::DebugStateResponse {
933                player,
934                lobby_state,
935                backend_game_state,
936                handler_game_state,
937                ..
938            } => {
939                assert_eq!(player.user_id, 456);
940                assert!(lobby_state.is_none());
941                assert!(backend_game_state.is_none());
942                assert!(handler_game_state.is_none());
943            }
944            _ => panic!("Expected DebugStateResponse message"),
945        }
946    }
947}