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}