runecast_protocol/protocol/client_messages.rs
1//! Client-to-server messages.
2//!
3//! All messages that can be sent from the frontend client to the backend server.
4//! Messages are tagged with `type` field for JSON serialization.
5//!
6//! # Categories
7//!
8//! - **Connection**: Handshake, heartbeat, acknowledgments
9//! - **Lobby**: Join, leave, create lobbies
10//! - **Game Lifecycle**: Start, end games
11//! - **Game Actions**: Submit words, pass turn, use powers
12//! - **Spectator**: Watch games, join as player
13//! - **Timer Vote**: Vote to start turn timer
14//! - **Admin**: Administrative commands
15
16use serde::{Deserialize, Serialize};
17
18use super::types::{GameConfig, GameMode, GameType, Position};
19
20/// Messages sent from client to server.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "type", rename_all = "snake_case")]
23pub enum ClientMessage {
24 // ========================================================================
25 // Connection Messages
26 // ========================================================================
27 /// Initial identification after WebSocket connect.
28 ///
29 /// If `resume_seq` is provided, attempt to resume a previous session
30 /// and receive missed messages since that sequence number.
31 Identify {
32 /// Last seen sequence number (for session resumption)
33 #[serde(skip_serializing_if = "Option::is_none")]
34 resume_seq: Option<u64>,
35 },
36
37 /// Keep-alive ping. Server responds with `HeartbeatAck`.
38 ///
39 /// Should be sent every 20-30 seconds to survive proxy timeouts.
40 Heartbeat,
41
42 /// Explicit acknowledgment of received messages.
43 ///
44 /// Can be used when no other message is being sent to confirm receipt.
45 /// Usually, acks are piggybacked on other messages via the envelope.
46 Ack {
47 /// Sequence number being acknowledged
48 seq: u64,
49 },
50
51 /// Request a full state sync from the server.
52 ///
53 /// Used to recover from state desync between client and server.
54 /// Server responds with `LobbySnapshot` and optionally `GameSnapshot`.
55 RequestSync,
56
57 /// Request debug state information for diagnosing context issues.
58 ///
59 /// Returns detailed information about the player's current state
60 /// in the WebSocket handler, lobby, and game systems.
61 DebugState,
62
63 // ========================================================================
64 // Lobby Messages
65 // ========================================================================
66 /// Join a channel-based lobby (default Discord Activity behavior).
67 ///
68 /// The lobby is automatically created if it doesn't exist.
69 JoinChannelLobby {
70 /// Discord channel ID
71 channel_id: String,
72 /// Discord guild ID (optional for DM activities)
73 #[serde(skip_serializing_if = "Option::is_none")]
74 guild_id: Option<String>,
75 },
76
77 /// Create a new custom lobby with a shareable code.
78 ///
79 /// Returns a 6-character code that others can use to join.
80 CreateCustomLobby,
81
82 /// Join an existing custom lobby by its code.
83 JoinCustomLobby {
84 /// 6-character lobby code (case-insensitive)
85 lobby_code: String,
86 },
87
88 /// Leave the current lobby.
89 ///
90 /// If in a game, this also leaves the game.
91 LeaveLobby,
92
93 // ========================================================================
94 // Game Pool Messages (waiting room for game matching)
95 // ========================================================================
96 /// Join a game pool within the lobby.
97 ///
98 /// Players must join a game pool to be matched for that game type.
99 /// Only one game pool can be joined at a time.
100 JoinGamePool {
101 /// The game type pool to join
102 game_type: GameType,
103 },
104
105 /// Leave the current game pool.
106 ///
107 /// Returns the player to the main lobby view.
108 LeaveGamePool,
109
110 // ========================================================================
111 // Game Lifecycle Messages
112 // ========================================================================
113 /// Request to create a new game (legacy - prefer `StartGame`).
114 #[serde(rename = "create_game")]
115 CreateGame { mode: GameMode },
116
117 /// Start a new game in the current lobby.
118 ///
119 /// By default, any player can start. Can be restricted to host only
120 /// via server configuration.
121 ///
122 /// Requirements:
123 /// - Must be in a lobby
124 /// - 1-6 connected players
125 /// - No game already in progress
126 StartGame {
127 /// Optional game configuration
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 config: Option<GameConfig>,
130 },
131
132 // ========================================================================
133 // Game Action Messages (only valid when Playing)
134 // ========================================================================
135 /// Submit a word during your turn.
136 ///
137 /// The word is derived from the positions on the grid.
138 /// Positions must form a valid path (adjacent cells, no repeats).
139 SubmitWord {
140 game_id: String,
141 /// The word being submitted (for validation)
142 word: String,
143 /// Grid positions forming the word path
144 positions: Vec<Position>,
145 },
146
147 /// Pass your turn without submitting a word.
148 ///
149 /// Awards 0 points and advances to the next player.
150 PassTurn { game_id: String },
151
152 /// Shuffle the board (costs 1 gem).
153 ///
154 /// Randomizes tile positions while keeping their properties
155 /// (letters, multipliers, gems stay on tiles, just positions change).
156 ShuffleBoard { game_id: String },
157
158 /// Enter swap mode (for UI feedback).
159 ///
160 /// Broadcasts to other players that you're considering a swap.
161 /// Triggers wobble animation on their screens.
162 EnterSwapMode { game_id: String },
163
164 /// Exit swap mode without swapping.
165 ExitSwapMode { game_id: String },
166
167 /// Swap a tile's letter (costs 3 gems).
168 ///
169 /// Changes the letter on a specific tile. The multiplier and gem
170 /// status of the tile are preserved.
171 SwapTile {
172 game_id: String,
173 row: usize,
174 col: usize,
175 /// New letter (A-Z)
176 new_letter: char,
177 },
178
179 // ========================================================================
180 // Spectator Messages
181 // ========================================================================
182 /// Join a game as a spectator.
183 ///
184 /// Spectators can view the game but cannot interact with it.
185 SpectateGame { game_id: String },
186
187 /// Join an active game.
188 ///
189 /// The player is added at the end of the turn order.
190 /// Previous rounds count as 0 points.
191 JoinGame { game_id: String },
192
193 /// Leave spectator mode and return to lobby view.
194 LeaveSpectator { game_id: String },
195
196 /// Legacy leave game message.
197 LeaveGame { game_id: String },
198
199 // ========================================================================
200 // Live Update Messages
201 // ========================================================================
202 /// Broadcast current tile selection to other players.
203 ///
204 /// Sent as the player selects tiles, allowing spectators and
205 /// other players to see the selection in real-time.
206 SelectionUpdate {
207 game_id: String,
208 positions: Vec<Position>,
209 },
210
211 // ========================================================================
212 // Timer Vote Messages
213 // ========================================================================
214 /// Initiate a vote to start a turn timer on the current player.
215 ///
216 /// Requirements:
217 /// - At least 3 players in game
218 /// - Not your turn
219 /// - No vote already in progress
220 /// - Not in cooldown
221 InitiateTimerVote { game_id: String },
222
223 /// Vote yes on an active timer vote.
224 ///
225 /// Requirements:
226 /// - Vote must be in progress
227 /// - You haven't already voted
228 /// - You didn't initiate the vote
229 /// - Not your turn
230 VoteForTimer { game_id: String },
231
232 // ========================================================================
233 // Rematch Messages
234 // ========================================================================
235 /// Trigger early rematch start for all players.
236 ///
237 /// Cancels the countdown and starts the game immediately for everyone
238 /// still in the rematch pool.
239 TriggerRematch {
240 /// The game that just ended (for validation)
241 previous_game_id: String,
242 },
243
244 /// Leave the rematch pool and return to lobby.
245 ///
246 /// The player will be removed from the rematch player list and
247 /// other players will be notified.
248 LeaveRematch {
249 /// The game to leave rematch for
250 previous_game_id: String,
251 },
252
253 // ========================================================================
254 // Admin Messages
255 // ========================================================================
256 /// Request list of games (admin only).
257 AdminGetGames,
258
259 /// Delete a specific game (admin only).
260 AdminDeleteGame { game_id: String },
261
262 // ========================================================================
263 // System Messages (server-generated, not sent by clients)
264 // ========================================================================
265 /// Player disconnected from WebSocket.
266 ///
267 /// This message is generated by the backend when a WebSocket connection
268 /// closes unexpectedly. It is dispatched to handlers to trigger grace
269 /// period logic and schedule cleanup timers.
270 ///
271 /// **Not sent by clients** - synthesized by the server.
272 PlayerDisconnected {
273 /// The lobby the player was in (if any)
274 #[serde(skip_serializing_if = "Option::is_none")]
275 lobby_id: Option<String>,
276 /// The game the player was in (if any)
277 #[serde(skip_serializing_if = "Option::is_none")]
278 game_id: Option<String>,
279 },
280}
281
282impl ClientMessage {
283 /// Get the message type as a string (for logging/debugging).
284 #[must_use]
285 pub fn message_type(&self) -> &'static str {
286 match self {
287 Self::Identify { .. } => "identify",
288 Self::Heartbeat => "heartbeat",
289 Self::Ack { .. } => "ack",
290 Self::RequestSync => "request_sync",
291 Self::DebugState => "debug_state",
292 Self::JoinChannelLobby { .. } => "join_channel_lobby",
293 Self::CreateCustomLobby => "create_custom_lobby",
294 Self::JoinCustomLobby { .. } => "join_custom_lobby",
295 Self::LeaveLobby => "leave_lobby",
296 Self::JoinGamePool { .. } => "join_game_pool",
297 Self::LeaveGamePool => "leave_game_pool",
298 Self::CreateGame { .. } => "create_game",
299 Self::StartGame { .. } => "start_game",
300 Self::SubmitWord { .. } => "submit_word",
301 Self::PassTurn { .. } => "pass_turn",
302 Self::ShuffleBoard { .. } => "shuffle_board",
303 Self::EnterSwapMode { .. } => "enter_swap_mode",
304 Self::ExitSwapMode { .. } => "exit_swap_mode",
305 Self::SwapTile { .. } => "swap_tile",
306 Self::JoinGame { .. } => "join_game",
307 Self::SpectateGame { .. } => "spectate_game",
308 Self::LeaveSpectator { .. } => "leave_spectator",
309 Self::LeaveGame { .. } => "leave_game",
310 Self::SelectionUpdate { .. } => "selection_update",
311 Self::InitiateTimerVote { .. } => "initiate_timer_vote",
312 Self::VoteForTimer { .. } => "vote_for_timer",
313 Self::TriggerRematch { .. } => "trigger_rematch",
314 Self::LeaveRematch { .. } => "leave_rematch",
315 Self::AdminGetGames => "admin_get_games",
316 Self::AdminDeleteGame { .. } => "admin_delete_game",
317 Self::PlayerDisconnected { .. } => "player_disconnected",
318 }
319 }
320
321 /// Check if this message requires the sender to be in a lobby.
322 #[must_use]
323 pub fn requires_lobby(&self) -> bool {
324 matches!(
325 self,
326 Self::LeaveLobby
327 | Self::JoinGamePool { .. }
328 | Self::LeaveGamePool
329 | Self::StartGame { .. }
330 | Self::SubmitWord { .. }
331 | Self::PassTurn { .. }
332 | Self::ShuffleBoard { .. }
333 | Self::EnterSwapMode { .. }
334 | Self::ExitSwapMode { .. }
335 | Self::SwapTile { .. }
336 | Self::SpectateGame { .. }
337 | Self::JoinGame { .. }
338 | Self::LeaveSpectator { .. }
339 | Self::SelectionUpdate { .. }
340 | Self::InitiateTimerVote { .. }
341 | Self::VoteForTimer { .. }
342 | Self::TriggerRematch { .. }
343 | Self::LeaveRematch { .. }
344 | Self::AdminGetGames
345 | Self::AdminDeleteGame { .. }
346 )
347 }
348
349 /// Check if this message requires an active game.
350 #[must_use]
351 pub fn requires_active_game(&self) -> bool {
352 matches!(
353 self,
354 Self::SubmitWord { .. }
355 | Self::PassTurn { .. }
356 | Self::ShuffleBoard { .. }
357 | Self::EnterSwapMode { .. }
358 | Self::ExitSwapMode { .. }
359 | Self::SwapTile { .. }
360 | Self::SelectionUpdate { .. }
361 | Self::InitiateTimerVote { .. }
362 | Self::VoteForTimer { .. }
363 )
364 }
365
366 /// Check if this message requires it to be the sender's turn.
367 #[must_use]
368 pub fn requires_turn(&self) -> bool {
369 matches!(
370 self,
371 Self::SubmitWord { .. }
372 | Self::PassTurn { .. }
373 | Self::ShuffleBoard { .. }
374 | Self::SwapTile { .. }
375 )
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn test_heartbeat_serialization() {
385 let msg = ClientMessage::Heartbeat;
386 let json = serde_json::to_string(&msg).unwrap();
387 assert_eq!(json, r#"{"type":"heartbeat"}"#);
388 }
389
390 #[test]
391 fn test_join_channel_lobby_serialization() {
392 let msg = ClientMessage::JoinChannelLobby {
393 channel_id: "123456".to_string(),
394 guild_id: Some("789".to_string()),
395 };
396 let json = serde_json::to_string(&msg).unwrap();
397 assert!(json.contains(r#""type":"join_channel_lobby""#));
398 assert!(json.contains(r#""channel_id":"123456""#));
399 assert!(json.contains(r#""guild_id":"789""#));
400 }
401
402 #[test]
403 fn test_submit_word_serialization() {
404 let msg = ClientMessage::SubmitWord {
405 game_id: "game_1".to_string(),
406 word: "HELLO".to_string(),
407 positions: vec![
408 Position { row: 0, col: 0 },
409 Position { row: 0, col: 1 },
410 Position { row: 1, col: 1 },
411 ],
412 };
413 let json = serde_json::to_string(&msg).unwrap();
414 assert!(json.contains(r#""type":"submit_word""#));
415 assert!(json.contains(r#""word":"HELLO""#));
416 }
417
418 #[test]
419 fn test_deserialize_heartbeat() {
420 let json = r#"{"type":"heartbeat"}"#;
421 let msg: ClientMessage = serde_json::from_str(json).unwrap();
422 assert!(matches!(msg, ClientMessage::Heartbeat));
423 }
424
425 #[test]
426 fn test_deserialize_join_channel_lobby() {
427 let json = r#"{"type":"join_channel_lobby","channel_id":"123","guild_id":null}"#;
428 let msg: ClientMessage = serde_json::from_str(json).unwrap();
429 match msg {
430 ClientMessage::JoinChannelLobby {
431 channel_id,
432 guild_id,
433 } => {
434 assert_eq!(channel_id, "123");
435 assert!(guild_id.is_none());
436 }
437 _ => panic!("Wrong message type"),
438 }
439 }
440
441 #[test]
442 fn test_message_type() {
443 assert_eq!(ClientMessage::Heartbeat.message_type(), "heartbeat");
444 assert_eq!(
445 ClientMessage::StartGame { config: None }.message_type(),
446 "start_game"
447 );
448 }
449
450 #[test]
451 fn test_requires_lobby() {
452 assert!(!ClientMessage::Heartbeat.requires_lobby());
453 assert!(!ClientMessage::CreateCustomLobby.requires_lobby());
454 assert!(ClientMessage::StartGame { config: None }.requires_lobby());
455 }
456
457 #[test]
458 fn test_requires_turn() {
459 assert!(!ClientMessage::Heartbeat.requires_turn());
460 assert!(!ClientMessage::InitiateTimerVote {
461 game_id: "game_1".to_string()
462 }
463 .requires_turn());
464 assert!(ClientMessage::PassTurn {
465 game_id: "game_1".to_string()
466 }
467 .requires_turn());
468 assert!(ClientMessage::SubmitWord {
469 game_id: "game_1".to_string(),
470 word: "TEST".to_string(),
471 positions: vec![]
472 }
473 .requires_turn());
474 }
475
476 #[test]
477 fn test_start_game_serialization_without_config() {
478 // Test with config: None - should skip serializing the config field
479 let msg = ClientMessage::StartGame { config: None };
480 let json = serde_json::to_string(&msg).unwrap();
481 assert_eq!(json, r#"{"type":"start_game"}"#);
482 }
483
484 #[test]
485 fn test_start_game_serialization_with_default_config() {
486 // Test with default config (regenerate_board_each_round: false)
487 let msg = ClientMessage::StartGame {
488 config: Some(GameConfig::default()),
489 };
490 let json = serde_json::to_string(&msg).unwrap();
491 assert!(json.contains(r#""type":"start_game""#));
492 assert!(json.contains(r#""regenerate_board_each_round":false"#));
493 assert!(json.contains(r#""grid_size":5"#));
494 }
495
496 #[test]
497 fn test_start_game_serialization_with_custom_config() {
498 // Test with custom config (regenerate_board_each_round: true)
499 let msg = ClientMessage::StartGame {
500 config: Some(GameConfig {
501 regenerate_board_each_round: true,
502 grid_size: 5,
503 }),
504 };
505 let json = serde_json::to_string(&msg).unwrap();
506 assert!(json.contains(r#""type":"start_game""#));
507 assert!(json.contains(r#""regenerate_board_each_round":true"#));
508 }
509
510 #[test]
511 fn test_start_game_deserialization_without_config() {
512 // Test deserializing without config field - should default to None
513 let json = r#"{"type":"start_game"}"#;
514 let msg: ClientMessage = serde_json::from_str(json).unwrap();
515 match msg {
516 ClientMessage::StartGame { config } => {
517 assert!(config.is_none());
518 }
519 _ => panic!("Wrong message type"),
520 }
521 }
522
523 #[test]
524 fn test_start_game_deserialization_with_config() {
525 // Test deserializing with config field
526 let json = r#"{"type":"start_game","config":{"regenerate_board_each_round":true}}"#;
527 let msg: ClientMessage = serde_json::from_str(json).unwrap();
528 match msg {
529 ClientMessage::StartGame { config } => {
530 assert!(config.is_some());
531 assert!(config.unwrap().regenerate_board_each_round);
532 }
533 _ => panic!("Wrong message type"),
534 }
535 }
536}