Skip to main content

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::{AdventureEventKind, 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    /// Host-only: update the live sandbox config for the current lobby.
118    /// Rejected by the server if the caller is not the host or lacks the
119    /// `sandbox_enabled` capability.
120    SetSandboxConfig { config: GameConfig },
121
122    /// Host-only: fire a random event immediately in the current custom game.
123    TriggerAdventureEvent { kind: AdventureEventKind },
124
125    /// Start a new game in the current lobby.
126    ///
127    /// By default, any player can start. Can be restricted to host only
128    /// via server configuration.
129    ///
130    /// Requirements:
131    /// - Must be in a lobby
132    /// - 1-6 connected players
133    /// - No game already in progress
134    StartGame {
135        /// Optional game configuration
136        #[serde(default, skip_serializing_if = "Option::is_none")]
137        config: Option<GameConfig>,
138    },
139
140    // ========================================================================
141    // Game Action Messages (only valid when Playing)
142    // ========================================================================
143    /// Submit a word during your turn.
144    ///
145    /// The word is derived from the positions on the grid.
146    /// Positions must form a valid path (adjacent cells, no repeats).
147    SubmitWord {
148        game_id: String,
149        /// The word being submitted (for validation)
150        word: String,
151        /// Grid positions forming the word path
152        positions: Vec<Position>,
153    },
154
155    /// Pass your turn without submitting a word.
156    ///
157    /// Awards 0 points and advances to the next player.
158    PassTurn { game_id: String },
159
160    /// Shuffle the board (costs 1 gem).
161    ///
162    /// Randomizes tile positions while keeping their properties
163    /// (letters, multipliers, gems stay on tiles, just positions change).
164    ShuffleBoard { game_id: String },
165
166    /// Enter swap mode (for UI feedback).
167    ///
168    /// Broadcasts to other players that you're considering a swap.
169    /// Triggers wobble animation on their screens.
170    EnterSwapMode { game_id: String },
171
172    /// Exit swap mode without swapping.
173    ExitSwapMode { game_id: String },
174
175    /// Swap a tile's letter (costs 3 gems).
176    ///
177    /// Changes the letter on a specific tile. The multiplier and gem
178    /// status of the tile are preserved.
179    SwapTile {
180        game_id: String,
181        row: usize,
182        col: usize,
183        /// New letter (A-Z)
184        new_letter: char,
185    },
186
187    // ========================================================================
188    // Spectator Messages
189    // ========================================================================
190    /// Join a game as a spectator.
191    ///
192    /// Spectators can view the game but cannot interact with it.
193    SpectateGame { game_id: String },
194
195    /// Join an active game.
196    ///
197    /// The player is added at the end of the turn order.
198    /// Previous rounds count as 0 points.
199    JoinGame { game_id: String },
200
201    /// Leave spectator mode and return to lobby view.
202    LeaveSpectator { game_id: String },
203
204    /// Legacy leave game message.
205    LeaveGame { game_id: String },
206
207    // ========================================================================
208    // Live Update Messages
209    // ========================================================================
210    /// Broadcast current tile selection to other players.
211    ///
212    /// Sent as the player selects tiles, allowing spectators and
213    /// other players to see the selection in real-time.
214    SelectionUpdate {
215        game_id: String,
216        positions: Vec<Position>,
217    },
218
219    // ========================================================================
220    // Timer Vote Messages
221    // ========================================================================
222    /// Initiate a vote to start a turn timer on the current player.
223    ///
224    /// Requirements:
225    /// - At least 3 players in game
226    /// - Not your turn
227    /// - No vote already in progress
228    /// - Not in cooldown
229    InitiateTimerVote { game_id: String },
230
231    /// Vote yes on an active timer vote.
232    ///
233    /// Requirements:
234    /// - Vote must be in progress
235    /// - You haven't already voted
236    /// - You didn't initiate the vote
237    /// - Not your turn
238    VoteForTimer { game_id: String },
239
240    // ========================================================================
241    // Rematch Messages
242    // ========================================================================
243    /// Trigger early rematch start for all players.
244    ///
245    /// Cancels the countdown and starts the game immediately for everyone
246    /// still in the rematch pool.
247    TriggerRematch {
248        /// The game that just ended (for validation)
249        previous_game_id: String,
250    },
251
252    /// Leave the rematch pool and return to lobby.
253    ///
254    /// The player will be removed from the rematch player list and
255    /// other players will be notified.
256    LeaveRematch {
257        /// The game to leave rematch for
258        previous_game_id: String,
259    },
260
261    // ========================================================================
262    // Admin Messages
263    // ========================================================================
264    /// Request list of games (admin only).
265    AdminGetGames,
266
267    /// Delete a specific game (admin only).
268    AdminDeleteGame { game_id: String },
269
270    // ========================================================================
271    // System Messages (server-generated, not sent by clients)
272    // ========================================================================
273    /// Player disconnected from WebSocket.
274    ///
275    /// This message is generated by the backend when a WebSocket connection
276    /// closes unexpectedly. It is dispatched to handlers to trigger grace
277    /// period logic and schedule cleanup timers.
278    ///
279    /// **Not sent by clients** - synthesized by the server.
280    PlayerDisconnected {
281        /// The lobby the player was in (if any)
282        #[serde(skip_serializing_if = "Option::is_none")]
283        lobby_id: Option<String>,
284        /// The game the player was in (if any)
285        #[serde(skip_serializing_if = "Option::is_none")]
286        game_id: Option<String>,
287    },
288}
289
290impl ClientMessage {
291    /// Get the message type as a string (for logging/debugging).
292    #[must_use]
293    pub fn message_type(&self) -> &'static str {
294        match self {
295            Self::Identify { .. } => "identify",
296            Self::Heartbeat => "heartbeat",
297            Self::Ack { .. } => "ack",
298            Self::RequestSync => "request_sync",
299            Self::DebugState => "debug_state",
300            Self::JoinChannelLobby { .. } => "join_channel_lobby",
301            Self::CreateCustomLobby => "create_custom_lobby",
302            Self::JoinCustomLobby { .. } => "join_custom_lobby",
303            Self::LeaveLobby => "leave_lobby",
304            Self::JoinGamePool { .. } => "join_game_pool",
305            Self::LeaveGamePool => "leave_game_pool",
306            Self::CreateGame { .. } => "create_game",
307            Self::SetSandboxConfig { .. } => "set_sandbox_config",
308            Self::TriggerAdventureEvent { .. } => "trigger_adventure_event",
309            Self::StartGame { .. } => "start_game",
310            Self::SubmitWord { .. } => "submit_word",
311            Self::PassTurn { .. } => "pass_turn",
312            Self::ShuffleBoard { .. } => "shuffle_board",
313            Self::EnterSwapMode { .. } => "enter_swap_mode",
314            Self::ExitSwapMode { .. } => "exit_swap_mode",
315            Self::SwapTile { .. } => "swap_tile",
316            Self::JoinGame { .. } => "join_game",
317            Self::SpectateGame { .. } => "spectate_game",
318            Self::LeaveSpectator { .. } => "leave_spectator",
319            Self::LeaveGame { .. } => "leave_game",
320            Self::SelectionUpdate { .. } => "selection_update",
321            Self::InitiateTimerVote { .. } => "initiate_timer_vote",
322            Self::VoteForTimer { .. } => "vote_for_timer",
323            Self::TriggerRematch { .. } => "trigger_rematch",
324            Self::LeaveRematch { .. } => "leave_rematch",
325            Self::AdminGetGames => "admin_get_games",
326            Self::AdminDeleteGame { .. } => "admin_delete_game",
327            Self::PlayerDisconnected { .. } => "player_disconnected",
328        }
329    }
330
331    /// Check if this message requires the sender to be in a lobby.
332    #[must_use]
333    pub fn requires_lobby(&self) -> bool {
334        matches!(
335            self,
336            Self::LeaveLobby
337                | Self::JoinGamePool { .. }
338                | Self::LeaveGamePool
339                | Self::StartGame { .. }
340                | Self::SubmitWord { .. }
341                | Self::PassTurn { .. }
342                | Self::ShuffleBoard { .. }
343                | Self::EnterSwapMode { .. }
344                | Self::ExitSwapMode { .. }
345                | Self::SwapTile { .. }
346                | Self::SpectateGame { .. }
347                | Self::JoinGame { .. }
348                | Self::LeaveSpectator { .. }
349                | Self::SelectionUpdate { .. }
350                | Self::InitiateTimerVote { .. }
351                | Self::VoteForTimer { .. }
352                | Self::TriggerRematch { .. }
353                | Self::LeaveRematch { .. }
354                | Self::AdminGetGames
355                | Self::AdminDeleteGame { .. }
356        )
357    }
358
359    /// Check if this message requires an active game.
360    #[must_use]
361    pub fn requires_active_game(&self) -> bool {
362        matches!(
363            self,
364            Self::SubmitWord { .. }
365                | Self::PassTurn { .. }
366                | Self::ShuffleBoard { .. }
367                | Self::EnterSwapMode { .. }
368                | Self::ExitSwapMode { .. }
369                | Self::SwapTile { .. }
370                | Self::SelectionUpdate { .. }
371                | Self::InitiateTimerVote { .. }
372                | Self::VoteForTimer { .. }
373        )
374    }
375
376    /// Check if this message requires it to be the sender's turn.
377    #[must_use]
378    pub fn requires_turn(&self) -> bool {
379        matches!(
380            self,
381            Self::SubmitWord { .. }
382                | Self::PassTurn { .. }
383                | Self::ShuffleBoard { .. }
384                | Self::SwapTile { .. }
385        )
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_heartbeat_serialization() {
395        let msg = ClientMessage::Heartbeat;
396        let json = serde_json::to_string(&msg).unwrap();
397        assert_eq!(json, r#"{"type":"heartbeat"}"#);
398    }
399
400    #[test]
401    fn test_join_channel_lobby_serialization() {
402        let msg = ClientMessage::JoinChannelLobby {
403            channel_id: "123456".to_string(),
404            guild_id: Some("789".to_string()),
405        };
406        let json = serde_json::to_string(&msg).unwrap();
407        assert!(json.contains(r#""type":"join_channel_lobby""#));
408        assert!(json.contains(r#""channel_id":"123456""#));
409        assert!(json.contains(r#""guild_id":"789""#));
410    }
411
412    #[test]
413    fn test_submit_word_serialization() {
414        let msg = ClientMessage::SubmitWord {
415            game_id: "game_1".to_string(),
416            word: "HELLO".to_string(),
417            positions: vec![
418                Position { row: 0, col: 0 },
419                Position { row: 0, col: 1 },
420                Position { row: 1, col: 1 },
421            ],
422        };
423        let json = serde_json::to_string(&msg).unwrap();
424        assert!(json.contains(r#""type":"submit_word""#));
425        assert!(json.contains(r#""word":"HELLO""#));
426    }
427
428    #[test]
429    fn test_deserialize_heartbeat() {
430        let json = r#"{"type":"heartbeat"}"#;
431        let msg: ClientMessage = serde_json::from_str(json).unwrap();
432        assert!(matches!(msg, ClientMessage::Heartbeat));
433    }
434
435    #[test]
436    fn test_deserialize_join_channel_lobby() {
437        let json = r#"{"type":"join_channel_lobby","channel_id":"123","guild_id":null}"#;
438        let msg: ClientMessage = serde_json::from_str(json).unwrap();
439        match msg {
440            ClientMessage::JoinChannelLobby {
441                channel_id,
442                guild_id,
443            } => {
444                assert_eq!(channel_id, "123");
445                assert!(guild_id.is_none());
446            }
447            _ => panic!("Wrong message type"),
448        }
449    }
450
451    #[test]
452    fn test_message_type() {
453        assert_eq!(ClientMessage::Heartbeat.message_type(), "heartbeat");
454        assert_eq!(
455            ClientMessage::StartGame { config: None }.message_type(),
456            "start_game"
457        );
458    }
459
460    #[test]
461    fn test_requires_lobby() {
462        assert!(!ClientMessage::Heartbeat.requires_lobby());
463        assert!(!ClientMessage::CreateCustomLobby.requires_lobby());
464        assert!(ClientMessage::StartGame { config: None }.requires_lobby());
465    }
466
467    #[test]
468    fn test_requires_turn() {
469        assert!(!ClientMessage::Heartbeat.requires_turn());
470        assert!(!ClientMessage::InitiateTimerVote {
471            game_id: "game_1".to_string()
472        }
473        .requires_turn());
474        assert!(ClientMessage::PassTurn {
475            game_id: "game_1".to_string()
476        }
477        .requires_turn());
478        assert!(ClientMessage::SubmitWord {
479            game_id: "game_1".to_string(),
480            word: "TEST".to_string(),
481            positions: vec![]
482        }
483        .requires_turn());
484    }
485
486    #[test]
487    fn test_start_game_serialization_without_config() {
488        // Test with config: None - should skip serializing the config field
489        let msg = ClientMessage::StartGame { config: None };
490        let json = serde_json::to_string(&msg).unwrap();
491        assert_eq!(json, r#"{"type":"start_game"}"#);
492    }
493
494    #[test]
495    fn test_start_game_serialization_with_default_config() {
496        // Test with default config (regenerate_board_each_round: false)
497        let msg = ClientMessage::StartGame {
498            config: Some(GameConfig::default()),
499        };
500        let json = serde_json::to_string(&msg).unwrap();
501        assert!(json.contains(r#""type":"start_game""#));
502        assert!(json.contains(r#""regenerate_board_each_round":false"#));
503        assert!(json.contains(r#""grid_size":5"#));
504    }
505
506    #[test]
507    fn test_start_game_serialization_with_custom_config() {
508        // Test with custom config (regenerate_board_each_round: true)
509        let msg = ClientMessage::StartGame {
510            config: Some(GameConfig {
511                regenerate_board_each_round: true,
512                grid_size: 5,
513                adventure_level: None,
514                level_targets: None,
515                num_rounds: None,
516                bots: Vec::new(),
517                custom: None,
518                events: None,
519            }),
520        };
521        let json = serde_json::to_string(&msg).unwrap();
522        assert!(json.contains(r#""type":"start_game""#));
523        assert!(json.contains(r#""regenerate_board_each_round":true"#));
524    }
525
526    #[test]
527    fn test_start_game_deserialization_without_config() {
528        // Test deserializing without config field - should default to None
529        let json = r#"{"type":"start_game"}"#;
530        let msg: ClientMessage = serde_json::from_str(json).unwrap();
531        match msg {
532            ClientMessage::StartGame { config } => {
533                assert!(config.is_none());
534            }
535            _ => panic!("Wrong message type"),
536        }
537    }
538
539    #[test]
540    fn test_start_game_deserialization_with_config() {
541        // Test deserializing with config field
542        let json = r#"{"type":"start_game","config":{"regenerate_board_each_round":true}}"#;
543        let msg: ClientMessage = serde_json::from_str(json).unwrap();
544        match msg {
545            ClientMessage::StartGame { config } => {
546                assert!(config.is_some());
547                assert!(config.unwrap().regenerate_board_each_round);
548            }
549            _ => panic!("Wrong message type"),
550        }
551    }
552
553    #[test]
554    fn set_sandbox_config_round_trips() {
555        let msg = ClientMessage::SetSandboxConfig {
556            config: GameConfig::default(),
557        };
558        let json = serde_json::to_string(&msg).unwrap();
559        assert!(json.contains(r#""type":"set_sandbox_config""#));
560        let back: ClientMessage = serde_json::from_str(&json).unwrap();
561        assert!(matches!(back, ClientMessage::SetSandboxConfig { .. }));
562    }
563
564    #[test]
565    fn trigger_adventure_event_round_trips() {
566        let msg = ClientMessage::TriggerAdventureEvent {
567            kind: AdventureEventKind::Snake,
568        };
569        let json = serde_json::to_string(&msg).unwrap();
570        assert!(json.contains(r#""type":"trigger_adventure_event""#));
571        assert!(json.contains(r#""kind":"snake""#));
572        let back: ClientMessage = serde_json::from_str(&json).unwrap();
573        assert!(matches!(
574            back,
575            ClientMessage::TriggerAdventureEvent {
576                kind: AdventureEventKind::Snake
577            }
578        ));
579    }
580}