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}