Skip to main content

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            // The legacy flat format predates `game_type`, so this is normally
176            // absent and decodes to `Unknown` — the honest answer for a payload
177            // that never carried a mode. Read it anyway for the case where a
178            // newer producer still speaks the legacy shape.
179            game_type: value
180                .get("game_type")
181                .and_then(|v| serde_json::from_value(v.clone()).ok())
182                .unwrap_or_default(),
183            state,
184            grid: serde_json::from_value(value.get("grid")?.clone()).ok()?,
185            players: serde_json::from_value(value.get("players")?.clone()).ok()?,
186            spectators: serde_json::from_value(value.get("spectators")?.clone())
187                .unwrap_or_default(),
188            current_turn: value.get("current_turn").and_then(|v| {
189                v.as_i64()
190                    .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
191            })?,
192            round: u8::try_from(value.get("round")?.as_i64()?).ok()?,
193            max_rounds: u8::try_from(value.get("max_rounds")?.as_i64()?).ok()?,
194            used_words: serde_json::from_value(value.get("used_words")?.clone())
195                .unwrap_or_default(),
196            timer_vote_state: serde_json::from_value(
197                value
198                    .get("timer_vote_state")
199                    .cloned()
200                    .unwrap_or(Value::Null),
201            )
202            .unwrap_or_default(),
203            your_player: None,
204            timer_expiration_time: None,
205            custom: None,
206            time_remaining_ms: None,
207            your_found_words: None,
208        })
209    }
210
211    /// Convert new `GameSnapshot` to legacy `game_state` message format.
212    ///
213    /// Used when sending to clients that haven't upgraded yet.
214    #[must_use]
215    pub fn snapshot_to_legacy_game_state(snapshot: &GameSnapshot) -> ServerMessage {
216        let state_str = match snapshot.state {
217            GameState::Idle => "idle",
218            GameState::Queueing => "queueing",
219            GameState::Starting => "starting",
220            GameState::InProgress => "in_progress",
221            GameState::Finished => "finished",
222            GameState::Cancelled => "cancelled",
223        };
224
225        ServerMessage::GameStateUpdate {
226            game_id: snapshot.game_id.clone(),
227            state: state_str.to_string(),
228            grid: snapshot.grid.clone(),
229            players: snapshot.players.clone(),
230            current_turn: snapshot.current_turn,
231            round: i32::from(snapshot.round),
232            max_rounds: i32::from(snapshot.max_rounds),
233            used_words: snapshot.used_words.clone(),
234            spectators: snapshot.spectators.clone(),
235            timer_vote_state: snapshot.timer_vote_state.clone(),
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_parse_legacy_heartbeat() {
246        let json = r#"{"type":"heartbeat"}"#;
247        let (msg, seq, ack) = compat::parse_client_message(json).unwrap();
248        assert!(matches!(msg, ClientMessage::Heartbeat));
249        assert!(seq.is_none());
250        assert!(ack.is_none());
251    }
252
253    #[test]
254    fn test_parse_enveloped_heartbeat() {
255        let json = r#"{"seq":42,"ack":41,"ts":12345,"payload":{"type":"heartbeat"}}"#;
256        let (msg, seq, ack) = compat::parse_client_message(json).unwrap();
257        assert!(matches!(msg, ClientMessage::Heartbeat));
258        assert_eq!(seq, Some(42));
259        assert_eq!(ack, Some(41));
260    }
261
262    #[test]
263    fn test_serialize_without_envelope() {
264        let msg = ServerMessage::HeartbeatAck { server_time: 12345 };
265        let json = compat::serialize_server_message(&msg, None, None).unwrap();
266        assert!(!json.contains("seq"));
267        assert!(json.contains("heartbeat_ack"));
268    }
269
270    #[test]
271    fn test_serialize_with_envelope() {
272        let msg = ServerMessage::HeartbeatAck { server_time: 12345 };
273        let json = compat::serialize_server_message(&msg, Some(1), Some(0)).unwrap();
274        assert!(json.contains(r#""seq":1"#));
275        assert!(json.contains(r#""ack":0"#));
276        assert!(json.contains("payload"));
277    }
278
279    #[test]
280    fn test_constants() {
281        assert_eq!(HEARTBEAT_INTERVAL_MS, 30_000);
282        const {
283            assert!(HEARTBEAT_TIMEOUT_MS > HEARTBEAT_INTERVAL_MS);
284        }
285        const {
286            assert!(RECONNECT_GRACE_MS > HEARTBEAT_TIMEOUT_MS);
287        }
288    }
289}