1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct Position {
15 pub row: usize,
16 pub col: usize,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum Multiplier {
23 DoubleLetter,
24 TripleLetter,
25 DoubleWord,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct GridCell {
31 pub letter: char,
32 pub value: u8,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub multiplier: Option<Multiplier>,
35 #[serde(default)]
36 pub has_gem: bool,
37}
38
39pub type Grid = Vec<Vec<GridCell>>;
41
42#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum GameMode {
46 Solo,
47 #[default]
48 Multiplayer,
49 Adventure,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum LobbyType {
60 Channel,
62 Custom,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum GameType {
73 Open,
75 TwoVTwo,
77 Adventure,
79}
80
81impl std::fmt::Display for GameType {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 GameType::Open => write!(f, "open"),
85 GameType::TwoVTwo => write!(f, "two_v_two"),
86 GameType::Adventure => write!(f, "adventure"),
87 }
88 }
89}
90
91#[serde_with::serde_as]
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct LobbyPlayerInfo {
95 #[serde_as(as = "serde_with::DisplayFromStr")]
97 pub user_id: i64,
98 pub username: String,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub avatar_url: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub banner_url: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub accent_color: Option<i32>,
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub current_game_pool: Option<GameType>,
110 pub active_game_id: Option<String>,
112 pub spectate_game_id: Option<String>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct GameSummary {
119 pub game_id: String,
120 pub state: GameState,
121 pub current_round: u8,
122 pub max_rounds: u8,
123 pub player_count: u8,
124 pub spectator_count: u8,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum GameState {
131 Idle,
133 Queueing,
135 Starting,
137 InProgress,
139 Finished,
141 Cancelled,
143}
144
145#[serde_with::serde_as]
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct PlayerInfo {
153 #[serde_as(as = "serde_with::DisplayFromStr")]
155 pub user_id: i64,
156 pub username: String,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub avatar_url: Option<String>,
159 pub score: i32,
160 #[serde(default)]
162 pub gems: i32,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub team: Option<i32>,
166 #[serde(default = "default_true")]
168 pub is_connected: bool,
169}
170
171fn default_true() -> bool {
172 true
173}
174
175#[serde_with::serde_as]
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct GamePlayerInfo {
179 #[serde_as(as = "serde_with::DisplayFromStr")]
180 pub user_id: i64,
181 pub username: String,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub avatar_url: Option<String>,
184 pub turn_order: u8,
185 pub score: i32,
186 pub gems: i32,
187 pub is_connected: bool,
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub team: Option<i32>,
190}
191
192#[serde_with::serde_as]
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct SpectatorInfo {
196 #[serde_as(as = "serde_with::DisplayFromStr")]
197 pub user_id: i64,
198 pub username: String,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub avatar_url: Option<String>,
201}
202
203#[serde_with::serde_as]
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct ScoreInfo {
207 #[serde_as(as = "serde_with::DisplayFromStr")]
208 pub user_id: i64,
209 pub username: String,
210 pub score: i32,
211}
212
213#[serde_with::serde_as]
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct LobbyGamePlayerInfo {
217 #[serde_as(as = "serde_with::DisplayFromStr")]
218 pub user_id: i64,
219 pub username: String,
220 pub score: i32,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct LobbyGameInfo {
226 pub game_id: String,
227 pub current_round: i32,
228 pub max_rounds: i32,
229 pub players: Vec<LobbyGamePlayerInfo>,
230}
231
232#[serde_with::serde_as]
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct GameSnapshot {
240 pub game_id: String,
241 pub state: GameState,
242 pub grid: Grid,
243 pub players: Vec<PlayerInfo>,
244 pub spectators: Vec<SpectatorInfo>,
245 #[serde_as(as = "serde_with::DisplayFromStr")]
246 pub current_turn: i64,
247 pub round: u8,
248 pub max_rounds: u8,
249 pub used_words: Vec<String>,
250 pub timer_vote_state: TimerVoteState,
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub your_player: Option<PlayerInfo>,
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub timer_expiration_time: Option<chrono::DateTime<chrono::Utc>>,
257}
258
259#[serde_with::serde_as]
268#[derive(Debug, Clone, Serialize, Deserialize, Default)]
269#[serde(tag = "status", rename_all = "snake_case")]
270pub enum TimerVoteState {
271 #[default]
273 Idle,
274
275 VoteInProgress {
277 #[serde_as(as = "serde_with::DisplayFromStr")]
279 initiator_id: i64,
280 #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
282 voters: Vec<i64>,
283 votes_needed: u32,
285 expires_at: chrono::DateTime<chrono::Utc>,
287 },
288
289 TimerActive {
291 expires_at: chrono::DateTime<chrono::Utc>,
293 #[serde_as(as = "serde_with::DisplayFromStr")]
295 target_player_id: i64,
296 },
297
298 Cooldown {
300 expires_at: chrono::DateTime<chrono::Utc>,
302 },
303
304 Disabled,
306}
307
308#[serde_with::serde_as]
317#[derive(Debug, Clone, Serialize, Deserialize, Default)]
318#[serde(tag = "status", rename_all = "snake_case")]
319pub enum RematchCountdownState {
320 #[default]
322 Idle,
323
324 Active {
326 expires_at: chrono::DateTime<chrono::Utc>,
328 seconds_remaining: u32,
330 #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
332 player_ids: Vec<i64>,
333 game_type: GameType,
335 },
336
337 Starting {
339 #[serde(skip_serializing_if = "Option::is_none")]
341 #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
342 triggered_by: Option<i64>,
343 },
344}
345
346#[serde_with::serde_as]
352#[derive(Debug, Clone, Serialize, Deserialize)]
353#[serde(tag = "change_type", rename_all = "snake_case")]
354pub enum LobbyChange {
355 PlayerJoined { player: LobbyPlayerInfo },
357
358 PlayerLeft {
360 #[serde_as(as = "serde_with::DisplayFromStr")]
361 player_id: i64,
362 #[serde(skip_serializing_if = "Option::is_none")]
363 reason: Option<String>,
364 },
365
366 PlayerConnectionChanged {
368 #[serde_as(as = "serde_with::DisplayFromStr")]
369 player_id: i64,
370 is_connected: bool,
371 },
372
373 GameStateChanged { game_id: String, state: GameState },
375
376 PoolUpdated { game_id: String, pool_count: u32 },
378
379 HostChanged { new_host_id: String },
381}
382
383#[serde_with::serde_as]
385#[derive(Debug, Clone, Serialize, Deserialize)]
386#[serde(tag = "change_type", rename_all = "snake_case")]
387pub enum GameChange {
388 GridUpdated {
390 grid: Grid,
391 #[serde(skip_serializing_if = "Option::is_none")]
393 replaced_positions: Option<Vec<Position>>,
394 },
395
396 ScoreUpdated {
398 #[serde_as(as = "serde_with::DisplayFromStr")]
399 player_id: i64,
400 score: i32,
401 gems: i32,
402 },
403
404 TurnChanged {
406 #[serde_as(as = "serde_with::DisplayFromStr")]
407 player_id: i64,
408 },
409
410 RoundChanged { round: u8 },
412
413 WordUsed { word: String },
415
416 SpectatorJoined { spectator: SpectatorInfo },
418
419 SpectatorLeft {
421 #[serde_as(as = "serde_with::DisplayFromStr")]
422 spectator_id: i64,
423 },
424
425 PlayerConnectionChanged {
427 #[serde_as(as = "serde_with::DisplayFromStr")]
428 player_id: i64,
429 is_connected: bool,
430 },
431}
432
433#[serde_with::serde_as]
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct AdminGameInfo {
441 pub game_id: String,
442 pub state: GameState,
443 pub created_at: chrono::DateTime<chrono::Utc>,
444 #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
445 pub players: Vec<i64>,
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(rename_all = "snake_case")]
455pub enum ErrorCode {
456 NotAuthenticated,
458 SessionExpired,
459 InvalidSession,
460
461 LobbyNotFound,
463 LobbyFull,
464 NotInLobby,
465 AlreadyInLobby,
466
467 GameNotFound,
469 GameInProgress,
470 GameNotActive,
471 NotInGame,
472 AlreadyInGame,
473
474 NotYourTurn,
476 InvalidAction,
477 ActionTimeout,
478
479 InvalidPath,
481 PathTooShort,
482 WordNotInDictionary,
483 WordAlreadyUsed,
484
485 NotHost,
487 NotEnoughPlayers,
488 TooManyPlayers,
489
490 InsufficientGems,
492
493 TooManyRequests,
495 MessageTooLarge,
496
497 InvalidRequest,
499 InternalError,
500}
501
502impl ErrorCode {
503 #[must_use]
505 pub fn message(&self) -> &'static str {
506 match self {
507 Self::NotAuthenticated => "Not authenticated",
508 Self::SessionExpired => "Session expired",
509 Self::InvalidSession => "Invalid session",
510 Self::LobbyNotFound => "Lobby not found",
511 Self::LobbyFull => "Lobby is full",
512 Self::NotInLobby => "You must be in a lobby",
513 Self::AlreadyInLobby => "Already in a lobby",
514 Self::GameNotFound => "Game not found",
515 Self::GameInProgress => "A game is already in progress",
516 Self::GameNotActive => "Game is not active",
517 Self::NotInGame => "You are not in this game",
518 Self::AlreadyInGame => "You are already in this game",
519 Self::NotYourTurn => "It's not your turn",
520 Self::InvalidAction => "Invalid action",
521 Self::ActionTimeout => "Action timed out",
522 Self::InvalidPath => "Invalid path - letters must be adjacent",
523 Self::PathTooShort => "Word must be at least 3 letters",
524 Self::WordNotInDictionary => "Word not found in dictionary",
525 Self::WordAlreadyUsed => "Word has already been used",
526 Self::NotHost => "Only the host can do this",
527 Self::NotEnoughPlayers => "Not enough players",
528 Self::TooManyPlayers => "Too many players",
529 Self::InsufficientGems => "Not enough gems",
530 Self::TooManyRequests => "Too many requests",
531 Self::MessageTooLarge => "Message too large",
532 Self::InvalidRequest => "Invalid request",
533 Self::InternalError => "Internal server error",
534 }
535 }
536}
537
538impl std::fmt::Display for ErrorCode {
539 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
540 write!(f, "{}", self.message())
541 }
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct GameConfig {
553 #[serde(default)]
556 pub regenerate_board_each_round: bool,
557
558 #[serde(default = "default_grid_size")]
561 pub grid_size: u8,
562}
563
564fn default_grid_size() -> u8 {
565 5
566}
567
568impl Default for GameConfig {
569 fn default() -> Self {
570 Self {
571 regenerate_board_each_round: false,
572 grid_size: default_grid_size(),
573 }
574 }
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct DebugPlayerInfo {
584 pub user_id: i64,
585 pub username: String,
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct DebugWebsocketContext {
591 pub lobby_id: Option<String>,
592 pub game_id: Option<String>,
593 pub is_spectating: bool,
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
598#[serde(untagged)]
599pub enum DebugLobbyState {
600 Found {
601 lobby_id: String,
602 player_in_lobby: bool,
603 lobby_player_ids: Vec<i64>,
604 active_game_id: Option<String>,
605 },
606 Error {
607 error: String,
608 },
609}
610
611#[derive(Debug, Clone, Serialize, Deserialize)]
613#[serde(untagged)]
614pub enum DebugBackendGameState {
615 Found {
616 game_id: String,
617 player_in_session_players: bool,
618 spectator_in_session: bool,
619 session_player_ids: Vec<i64>,
620 session_spectator_ids: Vec<i64>,
621 lobby_id: String,
622 },
623 Error {
624 error: String,
625 },
626}
627
628#[derive(Debug, Clone, Serialize, Deserialize)]
630#[serde(untagged)]
631pub enum DebugHandlerGameState {
632 Found {
633 game_id: String,
634 player_in_handler_game: bool,
635 handler_player_ids: Vec<i64>,
636 current_turn_index: usize,
637 round: u8,
638 state: String,
639 },
640 Error {
641 error: String,
642 },
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648
649 #[test]
650 fn test_position_serialization() {
651 let pos = Position { row: 2, col: 3 };
652 let json = serde_json::to_string(&pos).unwrap();
653 assert_eq!(json, r#"{"row":2,"col":3}"#);
654 }
655
656 #[test]
657 fn test_multiplier_serialization() {
658 assert_eq!(
659 serde_json::to_string(&Multiplier::DoubleLetter).unwrap(),
660 r#""double_letter""#
661 );
662 assert_eq!(
663 serde_json::to_string(&Multiplier::DoubleWord).unwrap(),
664 r#""double_word""#
665 );
666 }
667
668 #[test]
669 fn test_timer_vote_state_serialization() {
670 let idle = TimerVoteState::Idle;
671 let json = serde_json::to_string(&idle).unwrap();
672 assert!(json.contains(r#""status":"idle""#));
673
674 let now = chrono::Utc::now();
675 let vote = TimerVoteState::VoteInProgress {
676 initiator_id: 123,
677 voters: vec![456],
678 votes_needed: 2,
679 expires_at: now,
680 };
681 let json = serde_json::to_string(&vote).unwrap();
682 assert!(json.contains(r#""status":"vote_in_progress""#));
683 assert!(json.contains(r#""initiator_id":"123""#));
684 assert!(json.contains(r#""expires_at""#));
685
686 let active = TimerVoteState::TimerActive {
687 expires_at: now,
688 target_player_id: 789,
689 };
690 let json = serde_json::to_string(&active).unwrap();
691 assert!(json.contains(r#""status":"timer_active""#));
692 assert!(json.contains(r#""target_player_id":"789""#));
693
694 let cooldown = TimerVoteState::Cooldown { expires_at: now };
695 let json = serde_json::to_string(&cooldown).unwrap();
696 assert!(json.contains(r#""status":"cooldown""#));
697 assert!(json.contains(r#""expires_at""#));
698 }
699
700 #[test]
701 fn test_lobby_change_serialization() {
702 let change = LobbyChange::PlayerJoined {
703 player: LobbyPlayerInfo {
704 user_id: 123,
705 username: "TestUser".to_string(),
706 avatar_url: None,
707 banner_url: None,
708 accent_color: None,
709 current_game_pool: None,
710 active_game_id: None,
711 spectate_game_id: None,
712 },
713 };
714 let json = serde_json::to_string(&change).unwrap();
715 assert!(json.contains(r#""change_type":"player_joined""#));
716 }
717
718 #[test]
719 fn test_error_code_message() {
720 assert_eq!(ErrorCode::NotYourTurn.message(), "It's not your turn");
721 assert_eq!(
722 ErrorCode::WordNotInDictionary.message(),
723 "Word not found in dictionary"
724 );
725 }
726
727 #[test]
728 fn test_game_snapshot_serialization() {
729 let player = PlayerInfo {
730 user_id: 1,
731 username: "Player1".to_string(),
732 avatar_url: None,
733 score: 10,
734 gems: 5,
735 team: None,
736 is_connected: true,
737 };
738
739 let spectator = SpectatorInfo {
740 user_id: 2,
741 username: "Spec1".to_string(),
742 avatar_url: Some("http://avatar.url".to_string()),
743 };
744
745 let snapshot = GameSnapshot {
746 game_id: "game1".to_string(),
747 state: GameState::InProgress,
748 grid: vec![vec![GridCell {
749 letter: 'A',
750 value: 1,
751 multiplier: None,
752 has_gem: false,
753 }]],
754 players: vec![player],
755 spectators: vec![spectator],
756 current_turn: 1,
757 round: 1,
758 max_rounds: 3,
759 used_words: vec!["WORD".to_string()],
760 timer_vote_state: TimerVoteState::default(),
761 your_player: None,
762 timer_expiration_time: None,
763 };
764
765 let json = serde_json::to_string(&snapshot).unwrap();
766 assert!(json.contains(r#""game_id":"game1""#));
767 assert!(json.contains(r#""players":[{"user_id":"1""#));
768 assert!(json.contains(r#""spectators":[{"user_id":"2""#));
769 assert!(json.contains(r#""current_turn":"1""#));
770 }
771
772 #[test]
773 fn test_game_config_default() {
774 let config = GameConfig::default();
775 assert!(!config.regenerate_board_each_round);
776 }
777
778 #[test]
779 fn test_game_config_serialization() {
780 let config = GameConfig {
782 regenerate_board_each_round: false,
783 grid_size: 5,
784 };
785 let json = serde_json::to_string(&config).unwrap();
786 assert!(json.contains(r#""regenerate_board_each_round":false"#));
787 assert!(json.contains(r#""grid_size":5"#));
788
789 let config = GameConfig {
791 regenerate_board_each_round: true,
792 grid_size: 4,
793 };
794 let json = serde_json::to_string(&config).unwrap();
795 assert!(json.contains(r#""regenerate_board_each_round":true"#));
796 assert!(json.contains(r#""grid_size":4"#));
797 }
798
799 #[test]
800 fn test_game_config_deserialization() {
801 let json = r#"{"regenerate_board_each_round":false}"#;
803 let config: GameConfig = serde_json::from_str(json).unwrap();
804 assert!(!config.regenerate_board_each_round);
805 assert_eq!(config.grid_size, 5, "grid_size should default to 5");
806
807 let json = r#"{"regenerate_board_each_round":true}"#;
809 let config: GameConfig = serde_json::from_str(json).unwrap();
810 assert!(config.regenerate_board_each_round);
811
812 let json = r"{}";
814 let config: GameConfig = serde_json::from_str(json).unwrap();
815 assert!(!config.regenerate_board_each_round);
816 assert_eq!(config.grid_size, 5);
817
818 let json = r#"{"grid_size":4}"#;
820 let config: GameConfig = serde_json::from_str(json).unwrap();
821 assert_eq!(config.grid_size, 4);
822 assert!(!config.regenerate_board_each_round);
823 }
824}