diff --git a/Cargo.toml b/Cargo.toml index ab841ca..73ce92a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "runecast-protocol" -version = "0.8.2" +version = "0.8.3" edition = "2021" description = "Protocol definitions for RuneCast WebSocket communication" diff --git a/src/lib.rs b/src/lib.rs index d9b50ee..ac2563b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,10 +41,11 @@ pub use protocol::{ envelope::{Envelope, MaybeEnveloped}, server_messages::ServerMessage, types::{ - AdminGameInfo, ErrorCode, GameChange, GameConfig, GamePlayerInfo, GameSnapshot, GameState, - GameSummary, Grid, GridCell, LobbyChange, LobbyGameInfo, LobbyGamePlayerInfo, - LobbyPlayerInfo, LobbyType, Multiplier, PlayerInfo, Position, ScoreInfo, SpectatorInfo, - TimerVoteState, + AdminGameInfo, DebugBackendGameState, DebugHandlerGameState, DebugLobbyState, + DebugPlayerInfo, DebugWebsocketContext, ErrorCode, GameChange, GameConfig, GamePlayerInfo, + GameSnapshot, GameState, GameSummary, Grid, GridCell, LobbyChange, LobbyGameInfo, + LobbyGamePlayerInfo, LobbyPlayerInfo, LobbyType, Multiplier, PlayerInfo, Position, + ScoreInfo, SpectatorInfo, TimerVoteState, }, LobbySnapshot, }; diff --git a/src/protocol/client_messages.rs b/src/protocol/client_messages.rs index 4a89b8d..703b29c 100644 --- a/src/protocol/client_messages.rs +++ b/src/protocol/client_messages.rs @@ -54,6 +54,12 @@ pub enum ClientMessage { /// Server responds with `LobbySnapshot` and optionally `GameSnapshot`. RequestSync, + /// Request debug state information for diagnosing context issues. + /// + /// Returns detailed information about the player's current state + /// in the WebSocket handler, lobby, and game systems. + DebugState, + // ======================================================================== // Lobby Messages // ======================================================================== @@ -282,6 +288,7 @@ impl ClientMessage { Self::Heartbeat => "heartbeat", Self::Ack { .. } => "ack", Self::RequestSync => "request_sync", + Self::DebugState => "debug_state", Self::JoinChannelLobby { .. } => "join_channel_lobby", Self::CreateCustomLobby => "create_custom_lobby", Self::JoinCustomLobby { .. } => "join_custom_lobby", diff --git a/src/protocol/server_messages.rs b/src/protocol/server_messages.rs index 855afd2..db02ac5 100644 --- a/src/protocol/server_messages.rs +++ b/src/protocol/server_messages.rs @@ -16,9 +16,10 @@ use serde::{Deserialize, Serialize}; use crate::protocol::GameType; use super::types::{ - AdminGameInfo, ErrorCode, GameChange, GamePlayerInfo, GameSnapshot, Grid, LobbyChange, - LobbyGameInfo, LobbyPlayerInfo, LobbyType, PlayerInfo, RematchCountdownState, ScoreInfo, - SpectatorInfo, TimerVoteState, + AdminGameInfo, DebugBackendGameState, DebugHandlerGameState, DebugLobbyState, + DebugPlayerInfo, DebugWebsocketContext, ErrorCode, GameChange, GamePlayerInfo, GameSnapshot, + Grid, LobbyChange, LobbyGameInfo, LobbyPlayerInfo, LobbyType, PlayerInfo, + RematchCountdownState, ScoreInfo, SpectatorInfo, TimerVoteState, }; /// Messages sent from server to client. @@ -447,6 +448,19 @@ pub enum ServerMessage { games: Vec, }, + // ======================================================================== + // Debug Messages + // ======================================================================== + /// Debug state response with player context diagnostics. + DebugStateResponse { + timestamp: String, + player: DebugPlayerInfo, + websocket_context: DebugWebsocketContext, + lobby_state: Option, + backend_game_state: Option, + handler_game_state: Option, + }, + // ======================================================================== // Error Messages // ======================================================================== @@ -544,6 +558,7 @@ impl ServerMessage { Self::AdminGameDeleted { .. } => "admin_game_deleted", Self::GameStateUpdate { .. } => "game_state", Self::LobbyStateUpdate { .. } => "lobby_state", + Self::DebugStateResponse { .. } => "debug_state_response", Self::Error { .. } => "error", } @@ -572,6 +587,7 @@ impl ServerMessage { | Self::PlayerPoolChanged { .. } | Self::TurnTimerStarted { .. } | Self::TurnTimerExpired { .. } + | Self::DebugStateResponse { .. } ) } } @@ -761,4 +777,171 @@ mod tests { assert!(json.contains(r#""old_pool":"open""#)); assert!(json.contains(r#""new_pool":"adventure""#)); } + + #[test] + fn test_debug_state_response_serialization() { + let msg = ServerMessage::DebugStateResponse { + timestamp: "2024-01-01T12:00:00Z".to_string(), + player: DebugPlayerInfo { + user_id: 987654321, + username: "debug_user".to_string(), + }, + websocket_context: DebugWebsocketContext { + lobby_id: Some("lobby123".to_string()), + game_id: Some("game456".to_string()), + is_spectating: false, + }, + lobby_state: Some(DebugLobbyState::Found { + lobby_id: "lobby123".to_string(), + player_in_lobby: true, + lobby_player_ids: vec![111, 222, 333], + active_game_id: Some("game456".to_string()), + }), + backend_game_state: Some(DebugBackendGameState::Found { + game_id: "game456".to_string(), + player_in_session_players: true, + spectator_in_session: false, + session_player_ids: vec![111, 222], + session_spectator_ids: vec![], + lobby_id: "lobby123".to_string(), + }), + handler_game_state: Some(DebugHandlerGameState::Found { + game_id: "game456".to_string(), + player_in_handler_game: true, + handler_player_ids: vec![111, 222], + current_turn_index: 0, + round: 1, + state: "in_progress".to_string(), + }), + }; + + // Test serialization + let json = serde_json::to_string(&msg).unwrap(); + + // Verify type field + assert!(json.contains(r#""type":"debug_state_response""#)); + + // Verify ID fields are serialized as numbers + assert!(json.contains(r#""user_id":987654321"#)); + assert!(json.contains(r#""lobby_player_ids":[111,222,333]"#)); + assert!(json.contains(r#""session_player_ids":[111,222]"#)); + assert!(json.contains(r#""handler_player_ids":[111,222]"#)); + + // Verify other key fields + assert!(json.contains(r#""timestamp":"2024-01-01T12:00:00Z""#)); + assert!(json.contains(r#""username":"debug_user""#)); + assert!(json.contains(r#""lobby_id":"lobby123""#)); + assert!(json.contains(r#""game_id":"game456""#)); + assert!(json.contains(r#""is_spectating":false"#)); + assert!(json.contains(r#""player_in_lobby":true"#)); + + // Test deserialization (round-trip) + let deserialized: ServerMessage = serde_json::from_str(&json).unwrap(); + match deserialized { + ServerMessage::DebugStateResponse { + timestamp, + player, + websocket_context, + lobby_state, + backend_game_state, + handler_game_state, + } => { + assert_eq!(timestamp, "2024-01-01T12:00:00Z"); + assert_eq!(player.user_id, 987654321); + assert_eq!(player.username, "debug_user"); + assert_eq!(websocket_context.lobby_id, Some("lobby123".to_string())); + assert_eq!(websocket_context.game_id, Some("game456".to_string())); + assert!(!websocket_context.is_spectating); + assert!(lobby_state.is_some()); + assert!(backend_game_state.is_some()); + assert!(handler_game_state.is_some()); + } + _ => panic!("Expected DebugStateResponse message"), + } + } + + #[test] + fn test_debug_state_response_with_errors() { + // Test with error variants in debug states + let msg = ServerMessage::DebugStateResponse { + timestamp: "2024-01-01T12:00:00Z".to_string(), + player: DebugPlayerInfo { + user_id: 123, + username: "test".to_string(), + }, + websocket_context: DebugWebsocketContext { + lobby_id: None, + game_id: None, + is_spectating: false, + }, + lobby_state: Some(DebugLobbyState::Error { + error: "Lobby not found".to_string(), + }), + backend_game_state: Some(DebugBackendGameState::Error { + error: "Game not found".to_string(), + }), + handler_game_state: Some(DebugHandlerGameState::Error { + error: "Handler not found".to_string(), + }), + }; + + let json = serde_json::to_string(&msg).unwrap(); + + // Verify error messages are included + assert!(json.contains(r#""error":"Lobby not found""#)); + assert!(json.contains(r#""error":"Game not found""#)); + assert!(json.contains(r#""error":"Handler not found""#)); + + // Round-trip test + let deserialized: ServerMessage = serde_json::from_str(&json).unwrap(); + assert!(matches!(deserialized, ServerMessage::DebugStateResponse { .. })); + } + + #[test] + fn test_debug_state_response_minimal() { + // Test with None values for optional fields + let msg = ServerMessage::DebugStateResponse { + timestamp: "2024-01-01T12:00:00Z".to_string(), + player: DebugPlayerInfo { + user_id: 456, + username: "minimal_user".to_string(), + }, + websocket_context: DebugWebsocketContext { + lobby_id: None, + game_id: None, + is_spectating: true, + }, + lobby_state: None, + backend_game_state: None, + handler_game_state: None, + }; + + let json = serde_json::to_string(&msg).unwrap(); + + // Verify type field + assert!(json.contains(r#""type":"debug_state_response""#)); + + // Verify required fields + assert!(json.contains(r#""user_id":456"#)); + assert!(json.contains(r#""username":"minimal_user""#)); + assert!(json.contains(r#""is_spectating":true"#)); + + // Round-trip test + let deserialized: ServerMessage = serde_json::from_str(&json).unwrap(); + match deserialized { + ServerMessage::DebugStateResponse { + player, + lobby_state, + backend_game_state, + handler_game_state, + .. + } => { + assert_eq!(player.user_id, 456); + assert!(lobby_state.is_none()); + assert!(backend_game_state.is_none()); + assert!(handler_game_state.is_none()); + } + _ => panic!("Expected DebugStateResponse message"), + } + } } diff --git a/src/protocol/types.rs b/src/protocol/types.rs index 4d9c12a..ff4e99f 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -556,6 +556,74 @@ pub struct GameConfig { pub regenerate_board_each_round: bool, } +// ============================================================================ +// Debug State Types (for diagnostics) +// ============================================================================ + +/// Player info in debug state response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugPlayerInfo { + pub user_id: i64, + pub username: String, +} + +/// WebSocket connection context in debug state response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugWebsocketContext { + pub lobby_id: Option, + pub game_id: Option, + pub is_spectating: bool, +} + +/// Lobby state in debug state response. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum DebugLobbyState { + Found { + lobby_id: String, + player_in_lobby: bool, + lobby_player_ids: Vec, + active_game_id: Option, + }, + Error { + error: String, + }, +} + +/// Backend game state in debug state response. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum DebugBackendGameState { + Found { + game_id: String, + player_in_session_players: bool, + spectator_in_session: bool, + session_player_ids: Vec, + session_spectator_ids: Vec, + lobby_id: String, + }, + Error { + error: String, + }, +} + +/// Handler game state in debug state response. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum DebugHandlerGameState { + Found { + game_id: String, + player_in_handler_game: bool, + handler_player_ids: Vec, + current_turn_index: usize, + round: u8, + state: String, + }, + Error { + error: String, + }, +} + #[cfg(test)] mod tests { use super::*; @@ -719,7 +787,7 @@ mod tests { assert!(config.regenerate_board_each_round); // Test deserializing with missing field (should use default) - let json = r#"{}"#; + let json = r"{}"; let config: GameConfig = serde_json::from_str(json).unwrap(); assert!(!config.regenerate_board_each_round); }