runecast_protocol/protocol/
mod.rs

1//! Protocol module for `RuneCast` WebSocket communication.
2//!
3//! This module defines all message types exchanged between the frontend client
4//! and backend server over WebSocket connections.
5//!
6//! # Architecture
7//!
8//! ```text
9//! ┌─────────────────────────────────────────────────────────────────────┐
10//! │                           Protocol Layer                             │
11//! ├─────────────────────────────────────────────────────────────────────┤
12//! │  envelope.rs     - Message wrapper with seq/ack/timestamp           │
13//! │  types.rs        - Shared data types (Grid, Position, etc.)         │
14//! │  client_messages - Client → Server message definitions              │
15//! │  server_messages - Server → Client message definitions              │
16//! └─────────────────────────────────────────────────────────────────────┘
17//! ```
18//!
19//! # Message Flow
20//!
21//! ```text
22//! Client                                Server
23//!   │                                      │
24//!   │──── Identify ──────────────────────▶│
25//!   │◀─── Ready (LobbySnapshot) ──────────│
26//!   │                                      │
27//!   │──── JoinChannelLobby ──────────────▶│
28//!   │◀─── LobbyJoined ────────────────────│
29//!   │                                      │
30//!   │──── StartGame ─────────────────────▶│
31//!   │◀─── GameStarted ────────────────────│
32//!   │                                      │
33//!   │──── SubmitWord ────────────────────▶│
34//!   │◀─── WordScored ─────────────────────│
35//!   │◀─── TurnChanged ────────────────────│
36//! ```
37//!
38//! # Envelope Format (Optional)
39//!
40//! Messages can be sent raw (legacy) or wrapped in an envelope (new protocol):
41//!
42//! ```json
43//! // Legacy (still supported)
44//! {"type": "heartbeat"}
45//!
46//! // With envelope
47//! {"seq": 42, "ack": 41, "ts": 1701234567890, "payload": {"type": "heartbeat"}}
48//! ```
49//!
50//! # Migration Strategy
51//!
52//! This module is designed to coexist with the legacy `websocket/messages.rs`.
53//! During migration:
54//!
55//! 1. New code imports from `protocol::`
56//! 2. Compatibility functions convert between old and new formats
57//! 3. Once migration is complete, remove legacy module
58
59pub mod client_messages;
60pub mod envelope;
61pub mod server_messages;
62pub mod types;
63
64// Re-export main types for convenient access
65pub use client_messages::ClientMessage;
66pub use envelope::{Envelope, MaybeEnveloped};
67pub use server_messages::{LobbySnapshot, ServerMessage};
68pub use types::*;
69
70// ============================================================================
71// Protocol Constants
72// ============================================================================
73
74/// Recommended heartbeat interval (client should send heartbeat this often).
75pub const HEARTBEAT_INTERVAL_MS: u32 = 30_000;
76
77/// Heartbeat timeout (server closes connection if no heartbeat received).
78pub const HEARTBEAT_TIMEOUT_MS: u32 = 45_000;
79
80/// Grace period for reconnection before session expires.
81pub const RECONNECT_GRACE_MS: u32 = 60_000;
82
83/// Maximum message size in bytes.
84pub const MAX_MESSAGE_SIZE: usize = 64 * 1024; // 64 KB
85
86/// Protocol version for compatibility checks.
87pub const PROTOCOL_VERSION: &str = "1.0.0";
88
89// ============================================================================
90// Compatibility Layer
91// ============================================================================
92
93/// Module for converting between legacy and new message formats.
94///
95/// This allows gradual migration without breaking the existing frontend.
96pub mod compat {
97    use super::{ClientMessage, Envelope, GameSnapshot, GameState, MaybeEnveloped, ServerMessage};
98    use serde_json::Value;
99
100    /// Parse a raw JSON message, handling both legacy and new formats.
101    ///
102    /// Returns the parsed message and whether it was enveloped.
103    ///
104    /// # Errors
105    ///
106    /// Returns a `serde_json::Error` if the message cannot be parsed.
107    pub fn parse_client_message(
108        json: &str,
109    ) -> Result<(ClientMessage, Option<u64>, Option<u64>), serde_json::Error> {
110        // First try to parse as enveloped
111        if let Ok(enveloped) = serde_json::from_str::<MaybeEnveloped<ClientMessage>>(json) {
112            match enveloped {
113                MaybeEnveloped::Enveloped(env) => {
114                    return Ok((env.payload, Some(env.seq), env.ack));
115                }
116                MaybeEnveloped::Raw(msg) => {
117                    return Ok((msg, None, None));
118                }
119            }
120        }
121
122        // Fall back to legacy parsing
123        let msg: ClientMessage = serde_json::from_str(json)?;
124        Ok((msg, None, None))
125    }
126
127    /// Serialize a server message, optionally wrapping in an envelope.
128    ///
129    /// # Errors
130    ///
131    /// Returns a `serde_json::Error` if the message cannot be serialized.
132    pub fn serialize_server_message(
133        msg: &ServerMessage,
134        seq: Option<u64>,
135        ack: Option<u64>,
136    ) -> Result<String, serde_json::Error> {
137        match seq {
138            Some(seq) => {
139                let envelope = match ack {
140                    Some(ack) => Envelope::with_ack(seq, ack, msg),
141                    None => Envelope::new(seq, msg),
142                };
143                serde_json::to_string(&envelope)
144            }
145            None => serde_json::to_string(msg),
146        }
147    }
148
149    /// Convert legacy game state JSON to new `GameSnapshot` format.
150    ///
151    /// This handles the transition from the flat `game_state` message
152    /// to the structured `GameSnapshot` type.
153    ///
154    /// # Panics
155    ///
156    /// Panics if the `state` field is not a valid game state.
157    #[must_use]
158    pub fn legacy_game_state_to_snapshot(value: &Value) -> Option<GameSnapshot> {
159        // Extract fields from legacy format
160        let game_id = value.get("game_id")?.as_str()?.to_string();
161        let state_str = value.get("state")?.as_str()?;
162
163        let state = match state_str {
164            "idle" => GameState::Idle,
165            "queueing" => GameState::Queueing,
166            "starting" => GameState::Starting,
167            "in_progress" => GameState::InProgress,
168            "finished" => GameState::Finished,
169            "cancelled" => GameState::Cancelled,
170            _ => return None,
171        };
172
173        Some(GameSnapshot {
174            game_id,
175            state,
176            grid: serde_json::from_value(value.get("grid")?.clone()).ok()?,
177            players: serde_json::from_value(value.get("players")?.clone()).ok()?,
178            spectators: serde_json::from_value(value.get("spectators")?.clone())
179                .unwrap_or_default(),
180            current_turn: value.get("current_turn").and_then(|v| {
181                v.as_i64()
182                    .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
183            })?,
184            round: u8::try_from(value.get("round")?.as_i64()?).ok()?,
185            max_rounds: u8::try_from(value.get("max_rounds")?.as_i64()?).ok()?,
186            used_words: serde_json::from_value(value.get("used_words")?.clone())
187                .unwrap_or_default(),
188            timer_vote_state: serde_json::from_value(
189                value
190                    .get("timer_vote_state")
191                    .cloned()
192                    .unwrap_or(Value::Null),
193            )
194            .unwrap_or_default(),
195            your_player: None,
196            timer_expiration_time: None,
197        })
198    }
199
200    /// Convert new `GameSnapshot` to legacy `game_state` message format.
201    ///
202    /// Used when sending to clients that haven't upgraded yet.
203    #[must_use]
204    pub fn snapshot_to_legacy_game_state(snapshot: &GameSnapshot) -> ServerMessage {
205        let state_str = match snapshot.state {
206            GameState::Idle => "idle",
207            GameState::Queueing => "queueing",
208            GameState::Starting => "starting",
209            GameState::InProgress => "in_progress",
210            GameState::Finished => "finished",
211            GameState::Cancelled => "cancelled",
212        };
213
214        ServerMessage::GameStateUpdate {
215            game_id: snapshot.game_id.clone(),
216            state: state_str.to_string(),
217            grid: snapshot.grid.clone(),
218            players: snapshot.players.clone(),
219            current_turn: snapshot.current_turn,
220            round: i32::from(snapshot.round),
221            max_rounds: i32::from(snapshot.max_rounds),
222            used_words: snapshot.used_words.clone(),
223            spectators: snapshot.spectators.clone(),
224            timer_vote_state: snapshot.timer_vote_state.clone(),
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_parse_legacy_heartbeat() {
235        let json = r#"{"type":"heartbeat"}"#;
236        let (msg, seq, ack) = compat::parse_client_message(json).unwrap();
237        assert!(matches!(msg, ClientMessage::Heartbeat));
238        assert!(seq.is_none());
239        assert!(ack.is_none());
240    }
241
242    #[test]
243    fn test_parse_enveloped_heartbeat() {
244        let json = r#"{"seq":42,"ack":41,"ts":12345,"payload":{"type":"heartbeat"}}"#;
245        let (msg, seq, ack) = compat::parse_client_message(json).unwrap();
246        assert!(matches!(msg, ClientMessage::Heartbeat));
247        assert_eq!(seq, Some(42));
248        assert_eq!(ack, Some(41));
249    }
250
251    #[test]
252    fn test_serialize_without_envelope() {
253        let msg = ServerMessage::HeartbeatAck { server_time: 12345 };
254        let json = compat::serialize_server_message(&msg, None, None).unwrap();
255        assert!(!json.contains("seq"));
256        assert!(json.contains("heartbeat_ack"));
257    }
258
259    #[test]
260    fn test_serialize_with_envelope() {
261        let msg = ServerMessage::HeartbeatAck { server_time: 12345 };
262        let json = compat::serialize_server_message(&msg, Some(1), Some(0)).unwrap();
263        assert!(json.contains(r#""seq":1"#));
264        assert!(json.contains(r#""ack":0"#));
265        assert!(json.contains("payload"));
266    }
267
268    #[test]
269    fn test_constants() {
270        assert_eq!(HEARTBEAT_INTERVAL_MS, 30_000);
271        const {
272            assert!(HEARTBEAT_TIMEOUT_MS > HEARTBEAT_INTERVAL_MS);
273        }
274        const {
275            assert!(RECONNECT_GRACE_MS > HEARTBEAT_TIMEOUT_MS);
276        }
277    }
278}