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}