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}