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 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
43 pub is_hole: bool,
44 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
51 pub is_poisoned: bool,
52 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
58 pub is_abducted: bool,
59}
60
61pub type Grid = Vec<Vec<GridCell>>;
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum AdventureEventKind {
73 Bomb,
75 Snake,
77 Ufo,
79 #[serde(other)]
82 Unknown,
83}
84
85#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum GameMode {
89 Solo,
90 #[default]
91 Multiplayer,
92 Adventure,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum LobbyType {
103 Channel,
105 Custom,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum GameType {
116 Open,
118 TwoVTwo,
120 Adventure,
122 Sandbox,
124 Daily,
126 RuneBox,
130 #[serde(other)]
132 Unknown,
133}
134
135impl Default for GameType {
140 fn default() -> Self {
141 Self::Unknown
142 }
143}
144
145impl std::fmt::Display for GameType {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 match self {
148 GameType::Open => write!(f, "open"),
149 GameType::TwoVTwo => write!(f, "two_v_two"),
150 GameType::Adventure => write!(f, "adventure"),
151 GameType::Sandbox => write!(f, "sandbox"),
152 GameType::Daily => write!(f, "daily"),
153 GameType::RuneBox => write!(f, "rune_box"),
154 GameType::Unknown => write!(f, "unknown"),
155 }
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct PlayerName {
178 pub discord: String,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub runecast: Option<String>,
185}
186
187impl PlayerName {
188 #[must_use]
190 pub fn new(discord: impl Into<String>, runecast: Option<String>) -> Self {
191 Self {
192 discord: discord.into(),
193 runecast,
194 }
195 }
196
197 #[must_use]
199 pub fn shown(&self, austere: bool) -> &str {
200 match &self.runecast {
201 Some(chosen) if !austere && !chosen.is_empty() => chosen,
202 _ => &self.discord,
203 }
204 }
205}
206
207#[serde_with::serde_as]
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct LobbyPlayerInfo {
211 #[serde_as(as = "serde_with::DisplayFromStr")]
213 pub user_id: i64,
214 pub username: String,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub name: Option<PlayerName>,
221 #[serde(skip_serializing_if = "Option::is_none")]
222 pub avatar_url: Option<String>,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub banner_url: Option<String>,
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub accent_color: Option<i32>,
229 #[serde(skip_serializing_if = "Option::is_none")]
231 pub current_game_pool: Option<GameType>,
232 pub active_game_id: Option<String>,
234 pub spectate_game_id: Option<String>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct GameSummary {
241 pub game_id: String,
242 pub state: GameState,
243 pub current_round: u8,
244 pub max_rounds: u8,
245 pub player_count: u8,
246 pub spectator_count: u8,
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum GameState {
253 Idle,
255 Queueing,
257 Starting,
259 InProgress,
261 Finished,
263 Cancelled,
265}
266
267#[serde_with::serde_as]
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct PlayerInfo {
275 #[serde_as(as = "serde_with::DisplayFromStr")]
277 pub user_id: i64,
278 pub username: String,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub name: Option<PlayerName>,
285 #[serde(skip_serializing_if = "Option::is_none")]
286 pub avatar_url: Option<String>,
287 pub score: i32,
288 #[serde(default)]
290 pub gems: i32,
291 #[serde(skip_serializing_if = "Option::is_none")]
293 pub team: Option<i32>,
294 #[serde(default = "default_true")]
296 pub is_connected: bool,
297}
298
299fn default_true() -> bool {
300 true
301}
302
303#[serde_with::serde_as]
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct GamePlayerInfo {
307 #[serde_as(as = "serde_with::DisplayFromStr")]
308 pub user_id: i64,
309 pub username: String,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub name: Option<PlayerName>,
316 #[serde(skip_serializing_if = "Option::is_none")]
317 pub avatar_url: Option<String>,
318 pub turn_order: u8,
319 pub score: i32,
320 pub gems: i32,
321 pub is_connected: bool,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub team: Option<i32>,
324}
325
326#[serde_with::serde_as]
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct SpectatorInfo {
330 #[serde_as(as = "serde_with::DisplayFromStr")]
331 pub user_id: i64,
332 pub username: String,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub name: Option<PlayerName>,
339 #[serde(skip_serializing_if = "Option::is_none")]
340 pub avatar_url: Option<String>,
341}
342
343#[serde_with::serde_as]
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct ScoreInfo {
347 #[serde_as(as = "serde_with::DisplayFromStr")]
348 pub user_id: i64,
349 pub username: String,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub name: Option<PlayerName>,
356 pub score: i32,
357}
358
359#[serde_with::serde_as]
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371pub struct RuneBoxStanding {
372 #[serde_as(as = "serde_with::DisplayFromStr")]
375 pub player_id: i64,
376 pub username: String,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub name: Option<PlayerName>,
383 pub round_wins: u8,
390 pub total_score: i32,
392 pub eliminated: bool,
394}
395
396#[serde_with::serde_as]
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct LobbyGamePlayerInfo {
400 #[serde_as(as = "serde_with::DisplayFromStr")]
401 pub user_id: i64,
402 pub username: String,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub name: Option<PlayerName>,
409 pub score: i32,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct LobbyGameInfo {
415 pub game_id: String,
416 pub game_type: GameType,
419 pub current_round: i32,
420 pub max_rounds: i32,
421 pub players: Vec<LobbyGamePlayerInfo>,
422}
423
424#[serde_with::serde_as]
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct GameSnapshot {
432 pub game_id: String,
433 #[serde(default)]
445 pub game_type: GameType,
446 pub state: GameState,
447 pub grid: Grid,
448 pub players: Vec<PlayerInfo>,
449 pub spectators: Vec<SpectatorInfo>,
450 #[serde_as(as = "serde_with::DisplayFromStr")]
451 pub current_turn: i64,
452 pub round: u8,
453 pub max_rounds: u8,
454 pub used_words: Vec<String>,
455 pub timer_vote_state: TimerVoteState,
456 #[serde(skip_serializing_if = "Option::is_none")]
458 pub your_player: Option<PlayerInfo>,
459 #[serde(skip_serializing_if = "Option::is_none")]
461 pub timer_expiration_time: Option<chrono::DateTime<chrono::Utc>>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub custom: Option<CustomMeta>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub time_remaining_ms: Option<u64>,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub your_found_words: Option<Vec<String>>,
480}
481
482#[serde_with::serde_as]
491#[derive(Debug, Clone, Serialize, Deserialize, Default)]
492#[serde(tag = "status", rename_all = "snake_case")]
493pub enum TimerVoteState {
494 #[default]
496 Idle,
497
498 VoteInProgress {
500 #[serde_as(as = "serde_with::DisplayFromStr")]
502 initiator_id: i64,
503 #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
505 voters: Vec<i64>,
506 votes_needed: u32,
508 expires_at: chrono::DateTime<chrono::Utc>,
510 },
511
512 TimerActive {
514 expires_at: chrono::DateTime<chrono::Utc>,
516 #[serde_as(as = "serde_with::DisplayFromStr")]
518 target_player_id: i64,
519 },
520
521 Cooldown {
523 expires_at: chrono::DateTime<chrono::Utc>,
525 },
526
527 Disabled,
529}
530
531#[serde_with::serde_as]
540#[derive(Debug, Clone, Serialize, Deserialize, Default)]
541#[serde(tag = "status", rename_all = "snake_case")]
542pub enum RematchCountdownState {
543 #[default]
545 Idle,
546
547 Active {
549 expires_at: chrono::DateTime<chrono::Utc>,
551 seconds_remaining: u32,
553 #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
555 player_ids: Vec<i64>,
556 game_type: GameType,
558 },
559
560 Starting {
562 #[serde(skip_serializing_if = "Option::is_none")]
564 #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
565 triggered_by: Option<i64>,
566 },
567}
568
569#[serde_with::serde_as]
575#[derive(Debug, Clone, Serialize, Deserialize)]
576#[serde(tag = "change_type", rename_all = "snake_case")]
577pub enum LobbyChange {
578 PlayerJoined { player: LobbyPlayerInfo },
580
581 PlayerLeft {
583 #[serde_as(as = "serde_with::DisplayFromStr")]
584 player_id: i64,
585 #[serde(skip_serializing_if = "Option::is_none")]
586 reason: Option<String>,
587 },
588
589 PlayerConnectionChanged {
591 #[serde_as(as = "serde_with::DisplayFromStr")]
592 player_id: i64,
593 is_connected: bool,
594 },
595
596 GameStateChanged { game_id: String, state: GameState },
598
599 PoolUpdated { game_id: String, pool_count: u32 },
601
602 HostChanged { new_host_id: String },
604}
605
606#[serde_with::serde_as]
608#[derive(Debug, Clone, Serialize, Deserialize)]
609#[serde(tag = "change_type", rename_all = "snake_case")]
610pub enum GameChange {
611 GridUpdated {
613 grid: Grid,
614 #[serde(skip_serializing_if = "Option::is_none")]
616 replaced_positions: Option<Vec<Position>>,
617 },
618
619 ScoreUpdated {
621 #[serde_as(as = "serde_with::DisplayFromStr")]
622 player_id: i64,
623 score: i32,
624 gems: i32,
625 },
626
627 TurnChanged {
629 #[serde_as(as = "serde_with::DisplayFromStr")]
630 player_id: i64,
631 },
632
633 RoundChanged { round: u8 },
635
636 WordUsed { word: String },
638
639 SpectatorJoined { spectator: SpectatorInfo },
641
642 SpectatorLeft {
644 #[serde_as(as = "serde_with::DisplayFromStr")]
645 spectator_id: i64,
646 },
647
648 PlayerConnectionChanged {
650 #[serde_as(as = "serde_with::DisplayFromStr")]
651 player_id: i64,
652 is_connected: bool,
653 },
654}
655
656#[serde_with::serde_as]
662#[derive(Debug, Clone, Serialize, Deserialize)]
663pub struct AdminGameInfo {
664 pub game_id: String,
665 pub state: GameState,
666 pub created_at: chrono::DateTime<chrono::Utc>,
667 #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
668 pub players: Vec<i64>,
669}
670
671#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
677#[serde(rename_all = "snake_case")]
678pub enum ErrorCode {
679 NotAuthenticated,
681 SessionExpired,
682 InvalidSession,
683
684 LobbyNotFound,
686 LobbyFull,
687 NotInLobby,
688 AlreadyInLobby,
689
690 GameNotFound,
692 GameInProgress,
693 GameNotActive,
694 NotInGame,
695 AlreadyInGame,
696
697 NotYourTurn,
699 InvalidAction,
700 ActionTimeout,
701
702 InvalidPath,
704 PathTooShort,
705 WordNotInDictionary,
706 WordAlreadyUsed,
707
708 NotHost,
710 NotEnoughPlayers,
711 TooManyPlayers,
712 NotAuthorized,
716
717 InsufficientGems,
719
720 TooManyRequests,
722 MessageTooLarge,
723
724 InvalidRequest,
726 InternalError,
727}
728
729impl ErrorCode {
730 #[must_use]
732 pub fn message(&self) -> &'static str {
733 match self {
734 Self::NotAuthenticated => "Not authenticated",
735 Self::SessionExpired => "Session expired",
736 Self::InvalidSession => "Invalid session",
737 Self::LobbyNotFound => "Lobby not found",
738 Self::LobbyFull => "Lobby is full",
739 Self::NotInLobby => "You must be in a lobby",
740 Self::AlreadyInLobby => "Already in a lobby",
741 Self::GameNotFound => "Game not found",
742 Self::GameInProgress => "A game is already in progress",
743 Self::GameNotActive => "Game is not active",
744 Self::NotInGame => "You are not in this game",
745 Self::AlreadyInGame => "You are already in this game",
746 Self::NotYourTurn => "It's not your turn",
747 Self::InvalidAction => "Invalid action",
748 Self::ActionTimeout => "Action timed out",
749 Self::InvalidPath => "Invalid path - letters must be adjacent",
750 Self::PathTooShort => "Word must be at least 3 letters",
751 Self::WordNotInDictionary => "Word not found in dictionary",
752 Self::WordAlreadyUsed => "Word has already been used",
753 Self::NotHost => "Only the host can do this",
754 Self::NotEnoughPlayers => "Not enough players",
755 Self::TooManyPlayers => "Too many players",
756 Self::NotAuthorized => "You are not authorized to do this",
757 Self::InsufficientGems => "Not enough gems",
758 Self::TooManyRequests => "Too many requests",
759 Self::MessageTooLarge => "Message too large",
760 Self::InvalidRequest => "Invalid request",
761 Self::InternalError => "Internal server error",
762 }
763 }
764}
765
766impl std::fmt::Display for ErrorCode {
767 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
768 write!(f, "{}", self.message())
769 }
770}
771
772#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
779#[serde(rename_all = "lowercase")]
780pub enum BotDifficulty {
781 Easy,
782 Medium,
783 Hard,
784}
785
786#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
788pub struct BotSpec {
789 pub difficulty: BotDifficulty,
790}
791
792#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
798pub struct EventConfig {
799 #[serde(default)]
801 pub enabled_kinds: Vec<AdventureEventKind>,
802 #[serde(default)]
804 pub frequency: f64,
805}
806
807#[derive(Debug, Clone, Serialize, Deserialize)]
811pub struct GameConfig {
812 #[serde(default)]
815 pub regenerate_board_each_round: bool,
816
817 #[serde(default = "default_grid_size")]
820 pub grid_size: u8,
821
822 #[serde(default, skip_serializing_if = "Option::is_none")]
826 pub adventure_level: Option<u32>,
827
828 #[serde(default, skip_serializing_if = "Option::is_none")]
832 pub level_targets: Option<LevelTargets>,
833
834 #[serde(default, skip_serializing_if = "Option::is_none")]
837 pub num_rounds: Option<u8>,
838
839 #[serde(default, skip_serializing_if = "Vec::is_empty")]
841 pub bots: Vec<BotSpec>,
842
843 #[serde(default, skip_serializing_if = "Option::is_none")]
846 pub custom: Option<CustomMeta>,
847
848 #[serde(default, skip_serializing_if = "Option::is_none")]
850 pub events: Option<EventConfig>,
851}
852
853fn default_grid_size() -> u8 {
854 5
855}
856
857impl Default for GameConfig {
858 fn default() -> Self {
859 Self {
860 regenerate_board_each_round: false,
861 grid_size: default_grid_size(),
862 adventure_level: None,
863 level_targets: None,
864 num_rounds: None,
865 bots: Vec::new(),
866 custom: None,
867 events: None,
868 }
869 }
870}
871
872#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
876pub struct CustomMeta {}
877
878#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
883pub struct LevelTargets {
884 pub one_star: i32,
885 pub two_star: i32,
886 pub three_star: i32,
887}
888
889#[derive(Debug, Clone, Serialize, Deserialize)]
895pub struct DebugPlayerInfo {
896 pub user_id: i64,
897 pub username: String,
898}
899
900#[derive(Debug, Clone, Serialize, Deserialize)]
902pub struct DebugWebsocketContext {
903 pub lobby_id: Option<String>,
904 pub game_id: Option<String>,
905 pub is_spectating: bool,
906}
907
908#[derive(Debug, Clone, Serialize, Deserialize)]
910#[serde(untagged)]
911pub enum DebugLobbyState {
912 Found {
913 lobby_id: String,
914 player_in_lobby: bool,
915 lobby_player_ids: Vec<i64>,
916 active_game_id: Option<String>,
917 },
918 Error {
919 error: String,
920 },
921}
922
923#[derive(Debug, Clone, Serialize, Deserialize)]
925#[serde(untagged)]
926pub enum DebugBackendGameState {
927 Found {
928 game_id: String,
929 player_in_session_players: bool,
930 spectator_in_session: bool,
931 session_player_ids: Vec<i64>,
932 session_spectator_ids: Vec<i64>,
933 lobby_id: String,
934 },
935 Error {
936 error: String,
937 },
938}
939
940#[derive(Debug, Clone, Serialize, Deserialize)]
942#[serde(untagged)]
943pub enum DebugHandlerGameState {
944 Found {
945 game_id: String,
946 player_in_handler_game: bool,
947 handler_player_ids: Vec<i64>,
948 current_turn_index: usize,
949 round: u8,
950 state: String,
951 },
952 Error {
953 error: String,
954 },
955}
956
957#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
966#[serde(rename_all = "snake_case")]
967pub enum NewsNotificationType {
968 Maintenance,
969 Announcement,
970 Update,
971 #[serde(other)]
973 Unknown,
974}
975
976impl NewsNotificationType {
977 #[must_use]
983 pub fn from_wire_str(s: &str) -> Self {
984 match s {
985 "maintenance" => Self::Maintenance,
986 "announcement" => Self::Announcement,
987 "update" => Self::Update,
988 _ => Self::Unknown,
989 }
990 }
991}
992
993impl From<&str> for NewsNotificationType {
994 fn from(s: &str) -> Self {
995 Self::from_wire_str(s)
996 }
997}
998
999impl From<&String> for NewsNotificationType {
1000 fn from(s: &String) -> Self {
1001 Self::from_wire_str(s.as_str())
1002 }
1003}
1004
1005#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
1014pub struct NewsItemPayload {
1015 pub id: String,
1016 pub title: String,
1017 pub message: String,
1018 pub notification_type: NewsNotificationType,
1019 pub created_at: chrono::DateTime<chrono::Utc>,
1020 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
1021 pub auto_hide_seconds: u32,
1023 pub refresh_on_every_login: bool,
1026 pub priority: i32,
1028}
1029
1030#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1036pub struct BuildInfo {
1037 pub version: String,
1039 pub git_sha: String,
1041 pub git_describe: String,
1044 pub git_branch: String,
1046 pub dirty: bool,
1048 pub built_at: String,
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054 use super::*;
1055
1056 #[test]
1057 fn build_info_round_trips_snake_case() {
1058 let info = BuildInfo {
1059 version: "0.16.0".to_string(),
1060 git_sha: "a1b2c3d".to_string(),
1061 git_describe: "v0.16.0-2-ga1b2c3d".to_string(),
1062 git_branch: "master".to_string(),
1063 dirty: true,
1064 built_at: "2026-07-14T11:20:05Z".to_string(),
1065 };
1066 let json = serde_json::to_string(&info).unwrap();
1067 assert!(json.contains(r#""git_sha":"a1b2c3d""#));
1069 assert!(json.contains(r#""git_describe":"#));
1070 assert!(json.contains(r#""git_branch":"master""#));
1071 assert!(json.contains(r#""dirty":true"#));
1072 assert!(json.contains(r#""built_at":"#));
1073 let back: BuildInfo = serde_json::from_str(&json).unwrap();
1075 assert_eq!(back, info);
1076 }
1077
1078 #[test]
1079 fn test_position_serialization() {
1080 let pos = Position { row: 2, col: 3 };
1081 let json = serde_json::to_string(&pos).unwrap();
1082 assert_eq!(json, r#"{"row":2,"col":3}"#);
1083 }
1084
1085 #[test]
1086 fn test_multiplier_serialization() {
1087 assert_eq!(
1088 serde_json::to_string(&Multiplier::DoubleLetter).unwrap(),
1089 r#""double_letter""#
1090 );
1091 assert_eq!(
1092 serde_json::to_string(&Multiplier::DoubleWord).unwrap(),
1093 r#""double_word""#
1094 );
1095 }
1096
1097 #[test]
1098 fn test_timer_vote_state_serialization() {
1099 let idle = TimerVoteState::Idle;
1100 let json = serde_json::to_string(&idle).unwrap();
1101 assert!(json.contains(r#""status":"idle""#));
1102
1103 let now = chrono::Utc::now();
1104 let vote = TimerVoteState::VoteInProgress {
1105 initiator_id: 123,
1106 voters: vec![456],
1107 votes_needed: 2,
1108 expires_at: now,
1109 };
1110 let json = serde_json::to_string(&vote).unwrap();
1111 assert!(json.contains(r#""status":"vote_in_progress""#));
1112 assert!(json.contains(r#""initiator_id":"123""#));
1113 assert!(json.contains(r#""expires_at""#));
1114
1115 let active = TimerVoteState::TimerActive {
1116 expires_at: now,
1117 target_player_id: 789,
1118 };
1119 let json = serde_json::to_string(&active).unwrap();
1120 assert!(json.contains(r#""status":"timer_active""#));
1121 assert!(json.contains(r#""target_player_id":"789""#));
1122
1123 let cooldown = TimerVoteState::Cooldown { expires_at: now };
1124 let json = serde_json::to_string(&cooldown).unwrap();
1125 assert!(json.contains(r#""status":"cooldown""#));
1126 assert!(json.contains(r#""expires_at""#));
1127 }
1128
1129 #[test]
1130 fn test_lobby_change_serialization() {
1131 let change = LobbyChange::PlayerJoined {
1132 player: LobbyPlayerInfo {
1133 user_id: 123,
1134 username: "TestUser".to_string(),
1135 name: None,
1136 avatar_url: None,
1137 banner_url: None,
1138 accent_color: None,
1139 current_game_pool: None,
1140 active_game_id: None,
1141 spectate_game_id: None,
1142 },
1143 };
1144 let json = serde_json::to_string(&change).unwrap();
1145 assert!(json.contains(r#""change_type":"player_joined""#));
1146 }
1147
1148 #[test]
1149 fn test_error_code_message() {
1150 assert_eq!(ErrorCode::NotYourTurn.message(), "It's not your turn");
1151 assert_eq!(
1152 ErrorCode::WordNotInDictionary.message(),
1153 "Word not found in dictionary"
1154 );
1155 }
1156
1157 #[test]
1158 fn test_game_snapshot_serialization() {
1159 let player = PlayerInfo {
1160 user_id: 1,
1161 username: "Player1".to_string(),
1162 name: None,
1163 avatar_url: None,
1164 score: 10,
1165 gems: 5,
1166 team: None,
1167 is_connected: true,
1168 };
1169
1170 let spectator = SpectatorInfo {
1171 user_id: 2,
1172 username: "Spec1".to_string(),
1173 name: None,
1174 avatar_url: Some("http://avatar.url".to_string()),
1175 };
1176
1177 let snapshot = GameSnapshot {
1178 game_id: "game1".to_string(),
1179 game_type: GameType::Open,
1180 state: GameState::InProgress,
1181 grid: vec![vec![GridCell {
1182 is_hole: false,
1183 letter: 'A',
1184 value: 1,
1185 multiplier: None,
1186 has_gem: false,
1187 is_poisoned: false,
1188 is_abducted: false,
1189 }]],
1190 players: vec![player],
1191 spectators: vec![spectator],
1192 current_turn: 1,
1193 round: 1,
1194 max_rounds: 3,
1195 used_words: vec!["WORD".to_string()],
1196 timer_vote_state: TimerVoteState::default(),
1197 your_player: None,
1198 timer_expiration_time: None,
1199 custom: None,
1200 time_remaining_ms: None,
1201 your_found_words: None,
1202 };
1203
1204 let json = serde_json::to_string(&snapshot).unwrap();
1205 assert!(json.contains(r#""game_id":"game1""#));
1206 assert!(json.contains(r#""players":[{"user_id":"1""#));
1207 assert!(json.contains(r#""spectators":[{"user_id":"2""#));
1208 assert!(json.contains(r#""current_turn":"1""#));
1209 }
1210
1211 #[test]
1212 fn test_game_config_default() {
1213 let config = GameConfig::default();
1214 assert!(!config.regenerate_board_each_round);
1215 }
1216
1217 #[test]
1218 fn test_game_config_serialization() {
1219 let config = GameConfig {
1221 regenerate_board_each_round: false,
1222 grid_size: 5,
1223 adventure_level: None,
1224 level_targets: None,
1225 num_rounds: None,
1226 bots: Vec::new(),
1227 custom: None,
1228 events: None,
1229 };
1230 let json = serde_json::to_string(&config).unwrap();
1231 assert!(json.contains(r#""regenerate_board_each_round":false"#));
1232 assert!(json.contains(r#""grid_size":5"#));
1233 assert!(
1235 !json.contains("adventure_level"),
1236 "adventure_level should be omitted when None"
1237 );
1238 assert!(
1239 !json.contains("level_targets"),
1240 "level_targets should be omitted when None"
1241 );
1242
1243 let config = GameConfig {
1245 regenerate_board_each_round: true,
1246 grid_size: 4,
1247 adventure_level: None,
1248 level_targets: None,
1249 num_rounds: None,
1250 bots: Vec::new(),
1251 custom: None,
1252 events: None,
1253 };
1254 let json = serde_json::to_string(&config).unwrap();
1255 assert!(json.contains(r#""regenerate_board_each_round":true"#));
1256 assert!(json.contains(r#""grid_size":4"#));
1257
1258 let config = GameConfig {
1260 regenerate_board_each_round: false,
1261 grid_size: 4,
1262 adventure_level: Some(7),
1263 level_targets: Some(LevelTargets {
1264 one_star: 30,
1265 two_star: 60,
1266 three_star: 90,
1267 }),
1268 num_rounds: None,
1269 bots: Vec::new(),
1270 custom: None,
1271 events: None,
1272 };
1273 let json = serde_json::to_string(&config).unwrap();
1274 assert!(json.contains(r#""adventure_level":7"#));
1275 assert!(json.contains(r#""level_targets""#));
1276 assert!(json.contains(r#""one_star":30"#));
1277 assert!(json.contains(r#""two_star":60"#));
1278 assert!(json.contains(r#""three_star":90"#));
1279 }
1280
1281 #[test]
1282 fn test_game_config_deserialization() {
1283 let json = r#"{"regenerate_board_each_round":false}"#;
1285 let config: GameConfig = serde_json::from_str(json).unwrap();
1286 assert!(!config.regenerate_board_each_round);
1287 assert_eq!(config.grid_size, 5, "grid_size should default to 5");
1288 assert!(
1289 config.adventure_level.is_none(),
1290 "adventure_level defaults to None"
1291 );
1292 assert!(
1293 config.level_targets.is_none(),
1294 "level_targets defaults to None"
1295 );
1296
1297 let json = r#"{"regenerate_board_each_round":true}"#;
1299 let config: GameConfig = serde_json::from_str(json).unwrap();
1300 assert!(config.regenerate_board_each_round);
1301
1302 let json = r"{}";
1304 let config: GameConfig = serde_json::from_str(json).unwrap();
1305 assert!(!config.regenerate_board_each_round);
1306 assert_eq!(config.grid_size, 5);
1307 assert!(config.adventure_level.is_none());
1308 assert!(config.level_targets.is_none());
1309
1310 let json = r#"{"grid_size":4}"#;
1312 let config: GameConfig = serde_json::from_str(json).unwrap();
1313 assert_eq!(config.grid_size, 4);
1314 assert!(!config.regenerate_board_each_round);
1315
1316 let json = r#"{
1318 "grid_size": 4,
1319 "adventure_level": 12,
1320 "level_targets": { "one_star": 50, "two_star": 75, "three_star": 100 }
1321 }"#;
1322 let config: GameConfig = serde_json::from_str(json).unwrap();
1323 assert_eq!(config.adventure_level, Some(12));
1324 assert_eq!(
1325 config.level_targets,
1326 Some(LevelTargets {
1327 one_star: 50,
1328 two_star: 75,
1329 three_star: 100
1330 })
1331 );
1332 }
1333
1334 #[test]
1335 fn test_level_targets_serde_round_trip() {
1336 let targets = LevelTargets {
1340 one_star: 100,
1341 two_star: 200,
1342 three_star: 300,
1343 };
1344 let json = serde_json::to_value(targets).expect("LevelTargets should serialize");
1345 let expected = serde_json::json!({
1346 "one_star": 100,
1347 "two_star": 200,
1348 "three_star": 300,
1349 });
1350 assert_eq!(json, expected);
1351
1352 let round_tripped: LevelTargets =
1353 serde_json::from_value(json).expect("LevelTargets should deserialize");
1354 assert_eq!(round_tripped, targets);
1355 }
1356
1357 #[test]
1358 fn test_grid_cell_is_hole_wire_compat() {
1359 let plain = GridCell {
1364 letter: 'A',
1365 value: 1,
1366 multiplier: None,
1367 has_gem: false,
1368 is_hole: false,
1369 is_poisoned: false,
1370 is_abducted: false,
1371 };
1372 let json = serde_json::to_string(&plain).unwrap();
1373 assert!(
1374 !json.contains("is_hole"),
1375 "is_hole should be omitted when false (got: {json})"
1376 );
1377
1378 let hole = GridCell {
1379 letter: ' ',
1380 value: 0,
1381 multiplier: None,
1382 has_gem: false,
1383 is_hole: true,
1384 is_poisoned: false,
1385 is_abducted: false,
1386 };
1387 let json = serde_json::to_string(&hole).unwrap();
1388 assert!(json.contains(r#""is_hole":true"#));
1389
1390 let incoming = r#"{"letter":"B","value":3,"has_gem":false}"#;
1392 let cell: GridCell = serde_json::from_str(incoming).unwrap();
1393 assert!(!cell.is_hole, "is_hole should default to false when absent");
1394 }
1395
1396 #[test]
1397 fn test_grid_cell_is_poisoned_wire_compat() {
1398 let plain = GridCell {
1402 letter: 'A',
1403 value: 1,
1404 multiplier: None,
1405 has_gem: false,
1406 is_hole: false,
1407 is_poisoned: false,
1408 is_abducted: false,
1409 };
1410 let json = serde_json::to_string(&plain).unwrap();
1411 assert!(
1412 !json.contains("is_poisoned"),
1413 "is_poisoned should be omitted when false (got: {json})"
1414 );
1415
1416 let poisoned = GridCell {
1417 letter: 'E',
1418 value: 1,
1419 multiplier: None,
1420 has_gem: false,
1421 is_hole: false,
1422 is_poisoned: true,
1423 is_abducted: false,
1424 };
1425 let json = serde_json::to_string(&poisoned).unwrap();
1426 assert!(json.contains(r#""is_poisoned":true"#));
1427
1428 let incoming = r#"{"letter":"B","value":3,"has_gem":false}"#;
1431 let cell: GridCell = serde_json::from_str(incoming).unwrap();
1432 assert!(
1433 !cell.is_poisoned,
1434 "is_poisoned should default to false when absent"
1435 );
1436 }
1437
1438 #[test]
1439 fn test_grid_cell_is_abducted_wire_compat() {
1440 let plain = GridCell {
1443 letter: 'A',
1444 value: 1,
1445 multiplier: None,
1446 has_gem: false,
1447 is_hole: false,
1448 is_poisoned: false,
1449 is_abducted: false,
1450 };
1451 let json = serde_json::to_string(&plain).unwrap();
1452 assert!(
1453 !json.contains("is_abducted"),
1454 "is_abducted should be omitted when false (got: {json})"
1455 );
1456
1457 let abducted = GridCell {
1458 letter: 'T',
1459 value: 1,
1460 multiplier: None,
1461 has_gem: false,
1462 is_hole: false,
1463 is_poisoned: false,
1464 is_abducted: true,
1465 };
1466 let json = serde_json::to_string(&abducted).unwrap();
1467 assert!(json.contains(r#""is_abducted":true"#));
1468
1469 let incoming = r#"{"letter":"B","value":3,"has_gem":false}"#;
1471 let cell: GridCell = serde_json::from_str(incoming).unwrap();
1472 assert!(
1473 !cell.is_abducted,
1474 "is_abducted should default to false when absent"
1475 );
1476 }
1477
1478 #[test]
1479 fn test_news_notification_type_serde() {
1480 let json = serde_json::to_string(&NewsNotificationType::Maintenance).unwrap();
1482 assert_eq!(json, r#""maintenance""#);
1483 assert_eq!(
1484 serde_json::from_str::<NewsNotificationType>(r#""announcement""#).unwrap(),
1485 NewsNotificationType::Announcement
1486 );
1487
1488 assert_eq!(
1491 serde_json::from_str::<NewsNotificationType>(r#""patch_notes""#).unwrap(),
1492 NewsNotificationType::Unknown
1493 );
1494
1495 assert_eq!(
1497 NewsNotificationType::from_wire_str("maintenance"),
1498 NewsNotificationType::Maintenance
1499 );
1500 assert_eq!(
1501 NewsNotificationType::from("update"),
1502 NewsNotificationType::Update
1503 );
1504 assert_eq!(
1505 NewsNotificationType::from_wire_str("unknown_future_type"),
1506 NewsNotificationType::Unknown
1507 );
1508 }
1509
1510 #[test]
1511 fn bot_spec_round_trips_lowercase() {
1512 let spec = BotSpec {
1513 difficulty: BotDifficulty::Hard,
1514 };
1515 let json = serde_json::to_string(&spec).unwrap();
1516 assert_eq!(json, r#"{"difficulty":"hard"}"#);
1517 let back: BotSpec = serde_json::from_str(&json).unwrap();
1518 assert_eq!(back.difficulty, BotDifficulty::Hard);
1519 }
1520
1521 #[test]
1522 fn test_adventure_event_kind_serde() {
1523 let json = serde_json::to_string(&AdventureEventKind::Bomb).unwrap();
1525 assert_eq!(json, r#""bomb""#);
1526 assert_eq!(
1527 serde_json::from_str::<AdventureEventKind>(r#""snake""#).unwrap(),
1528 AdventureEventKind::Snake
1529 );
1530 assert_eq!(
1531 serde_json::from_str::<AdventureEventKind>(r#""ufo""#).unwrap(),
1532 AdventureEventKind::Ufo
1533 );
1534
1535 assert_eq!(
1538 serde_json::from_str::<AdventureEventKind>(r#""earthquake""#).unwrap(),
1539 AdventureEventKind::Unknown
1540 );
1541 }
1542
1543 #[test]
1544 fn default_gameconfig_wire_unchanged() {
1545 let json = serde_json::to_string(&GameConfig::default()).unwrap();
1546 assert!(json.contains(r#""grid_size":5"#));
1547 assert!(!json.contains("num_rounds")); assert!(!json.contains(r#""bots""#) || json.contains(r#""bots":[]"#));
1549 assert!(!json.contains("custom")); }
1551
1552 #[test]
1553 fn sandbox_gameconfig_round_trips() {
1554 let cfg = GameConfig {
1555 num_rounds: Some(7),
1556 bots: vec![BotSpec {
1557 difficulty: BotDifficulty::Easy,
1558 }],
1559 custom: Some(CustomMeta {}),
1560 grid_size: 6,
1561 ..GameConfig::default()
1562 };
1563 let json = serde_json::to_string(&cfg).unwrap();
1564 let back: GameConfig = serde_json::from_str(&json).unwrap();
1565 assert_eq!(back.num_rounds, Some(7));
1566 assert_eq!(back.bots.len(), 1);
1567 assert!(back.custom.is_some());
1568 assert_eq!(back.grid_size, 6);
1569 }
1570
1571 #[test]
1572 fn legacy_gameconfig_json_still_parses() {
1573 let cfg: GameConfig =
1574 serde_json::from_str(r#"{"regenerate_board_each_round":true,"grid_size":4}"#).unwrap();
1575 assert!(cfg.regenerate_board_each_round);
1576 assert!(cfg.bots.is_empty());
1577 assert!(cfg.num_rounds.is_none());
1578 assert!(cfg.custom.is_none());
1579 }
1580
1581 #[test]
1582 fn event_config_round_trips() {
1583 let cfg = EventConfig {
1584 enabled_kinds: vec![AdventureEventKind::Bomb, AdventureEventKind::Ufo],
1585 frequency: 0.5,
1586 };
1587 let json = serde_json::to_string(&cfg).unwrap();
1588 let back: EventConfig = serde_json::from_str(&json).unwrap();
1589 assert_eq!(back.enabled_kinds.len(), 2);
1590 assert!((back.frequency - 0.5).abs() < 1e-9);
1591 }
1592
1593 #[test]
1594 fn gameconfig_without_events_is_back_compat() {
1595 let json = serde_json::to_string(&GameConfig::default()).unwrap();
1596 assert!(!json.contains("events"));
1597 let back: GameConfig = serde_json::from_str(&json).unwrap();
1598 assert!(back.events.is_none());
1599 }
1600
1601 #[test]
1602 fn lobby_game_info_game_type_round_trips() {
1603 let info = LobbyGameInfo {
1606 game_id: "game-42".to_string(),
1607 game_type: GameType::Sandbox,
1608 current_round: 2,
1609 max_rounds: 5,
1610 players: vec![],
1611 };
1612 let json = serde_json::to_string(&info).unwrap();
1613 assert!(
1614 json.contains(r#""game_type":"sandbox""#),
1615 r#"expected "game_type":"sandbox" in JSON, got: {json}"#
1616 );
1617 let back: LobbyGameInfo = serde_json::from_str(&json).unwrap();
1618 assert_eq!(back.game_type, GameType::Sandbox);
1619 assert_eq!(back.game_id, "game-42");
1620 }
1621
1622 #[test]
1623 fn game_type_sandbox_serializes_snake_case() {
1624 let gt = GameType::Sandbox;
1625 let json = serde_json::to_string(>).unwrap();
1626 assert_eq!(json, "\"sandbox\"");
1627 let back: GameType = serde_json::from_str("\"sandbox\"").unwrap();
1628 assert_eq!(back, GameType::Sandbox);
1629 assert_eq!(gt.to_string(), "sandbox");
1630 }
1631
1632 #[test]
1633 fn game_type_daily_serde_roundtrip() {
1634 let j = serde_json::to_string(&GameType::Daily).unwrap();
1635 assert_eq!(j, "\"daily\"");
1636 let back: GameType = serde_json::from_str("\"daily\"").unwrap();
1637 assert!(matches!(back, GameType::Daily));
1638 assert_eq!(GameType::Daily.to_string(), "daily");
1639 }
1640
1641 #[test]
1642 fn game_type_unknown_is_deserialize_fallback() {
1643 assert_eq!(
1645 serde_json::from_str::<GameType>("\"open\"").unwrap(),
1646 GameType::Open
1647 );
1648 assert_eq!(
1649 serde_json::from_str::<GameType>("\"sandbox\"").unwrap(),
1650 GameType::Sandbox
1651 );
1652 assert_eq!(
1654 serde_json::from_str::<GameType>("\"some_future_mode\"").unwrap(),
1655 GameType::Unknown
1656 );
1657 }
1658
1659 #[test]
1660 fn game_snapshot_custom_field_round_trips() {
1661 let player = PlayerInfo {
1663 user_id: 1,
1664 username: "Player1".to_string(),
1665 name: None,
1666 avatar_url: None,
1667 score: 0,
1668 gems: 0,
1669 team: None,
1670 is_connected: true,
1671 };
1672 let base = GameSnapshot {
1673 game_id: "snap1".to_string(),
1674 game_type: GameType::Sandbox,
1675 state: GameState::InProgress,
1676 grid: vec![],
1677 players: vec![player],
1678 spectators: vec![],
1679 current_turn: 1,
1680 round: 1,
1681 max_rounds: 3,
1682 used_words: vec![],
1683 timer_vote_state: TimerVoteState::default(),
1684 your_player: None,
1685 timer_expiration_time: None,
1686 custom: Some(CustomMeta {}),
1687 time_remaining_ms: None,
1688 your_found_words: None,
1689 };
1690
1691 let json = serde_json::to_string(&base).unwrap();
1693 assert!(
1694 json.contains("\"custom\""),
1695 "expected \"custom\" in JSON, got: {json}"
1696 );
1697
1698 let back: GameSnapshot = serde_json::from_str(&json).unwrap();
1700 assert!(back.custom.is_some());
1701
1702 let no_custom = GameSnapshot {
1704 custom: None,
1705 ..base
1706 };
1707 let json_none = serde_json::to_string(&no_custom).unwrap();
1708 assert!(
1709 !json_none.contains("\"custom\""),
1710 "expected no \"custom\" in JSON with None, got: {json_none}"
1711 );
1712 let back_none: GameSnapshot = serde_json::from_str(&json_none).unwrap();
1713 assert!(back_none.custom.is_none());
1714 }
1715
1716 #[test]
1717 fn game_snapshot_time_remaining_omitted_when_none_roundtrips_when_some() {
1718 let base = GameSnapshot {
1719 game_id: "g".to_string(),
1720 game_type: GameType::Daily,
1721 state: GameState::InProgress,
1722 grid: vec![],
1723 players: vec![],
1724 spectators: vec![],
1725 current_turn: 1,
1726 round: 1,
1727 max_rounds: 1,
1728 used_words: vec![],
1729 timer_vote_state: TimerVoteState::default(),
1730 your_player: None,
1731 timer_expiration_time: None,
1732 custom: None,
1733 time_remaining_ms: None,
1734 your_found_words: None,
1735 };
1736 let json_none = serde_json::to_string(&base).unwrap();
1738 assert!(!json_none.contains("time_remaining_ms"));
1739 let with = GameSnapshot {
1741 time_remaining_ms: Some(45_000),
1742 ..base
1743 };
1744 let json = serde_json::to_string(&with).unwrap();
1745 assert!(json.contains("\"time_remaining_ms\":45000"));
1746 let back: GameSnapshot = serde_json::from_str(&json).unwrap();
1747 assert_eq!(back.time_remaining_ms, Some(45_000));
1748 }
1749
1750 #[test]
1751 fn game_type_runebox_round_trips_as_snake_case() {
1752 let json = serde_json::to_string(&GameType::RuneBox).unwrap();
1753 assert_eq!(json, "\"rune_box\"");
1754 let back: GameType = serde_json::from_str(&json).unwrap();
1755 assert_eq!(back, GameType::RuneBox);
1756 assert_eq!(GameType::RuneBox.to_string(), "rune_box");
1757 }
1758
1759 #[test]
1760 fn unknown_game_type_still_degrades_rather_than_failing() {
1761 let back: GameType = serde_json::from_str("\"some_future_mode\"").unwrap();
1764 assert_eq!(back, GameType::Unknown);
1765 }
1766
1767 #[test]
1768 fn game_snapshot_found_words_omitted_when_none_roundtrips_when_some() {
1769 let base = GameSnapshot {
1770 game_id: "g".to_string(),
1771 game_type: GameType::RuneBox,
1772 state: GameState::InProgress,
1773 grid: vec![],
1774 players: vec![],
1775 spectators: vec![],
1776 current_turn: 1,
1777 round: 1,
1778 max_rounds: 1,
1779 used_words: vec![],
1780 timer_vote_state: TimerVoteState::default(),
1781 your_player: None,
1782 timer_expiration_time: None,
1783 custom: None,
1784 time_remaining_ms: None,
1785 your_found_words: None,
1786 };
1787 let json_none = serde_json::to_string(&base).unwrap();
1789 assert!(!json_none.contains("your_found_words"));
1790
1791 let with = GameSnapshot {
1792 your_found_words: Some(vec!["RUNES".to_string()]),
1793 ..base
1794 };
1795 let json = serde_json::to_string(&with).unwrap();
1796 assert!(json.contains("\"your_found_words\":[\"RUNES\"]"));
1797 let back: GameSnapshot = serde_json::from_str(&json).unwrap();
1798 assert_eq!(back.your_found_words, Some(vec!["RUNES".to_string()]));
1799 }
1800
1801 fn snapshot_of(game_type: GameType) -> GameSnapshot {
1803 GameSnapshot {
1804 game_id: "g".to_string(),
1805 game_type,
1806 state: GameState::InProgress,
1807 grid: vec![],
1808 players: vec![],
1809 spectators: vec![],
1810 current_turn: 1,
1811 round: 1,
1812 max_rounds: 1,
1813 used_words: vec![],
1814 timer_vote_state: TimerVoteState::default(),
1815 your_player: None,
1816 timer_expiration_time: None,
1817 custom: None,
1818 time_remaining_ms: None,
1819 your_found_words: None,
1820 }
1821 }
1822
1823 #[test]
1826 fn game_snapshot_carries_its_game_type_on_the_wire() {
1827 let json = serde_json::to_string(&snapshot_of(GameType::RuneBox)).unwrap();
1828 assert!(
1829 json.contains("\"game_type\":\"rune_box\""),
1830 "expected game_type on the snapshot, got: {json}"
1831 );
1832 let back: GameSnapshot = serde_json::from_str(&json).unwrap();
1833 assert_eq!(back.game_type, GameType::RuneBox);
1834 }
1835
1836 #[test]
1841 fn game_type_is_emitted_for_every_mode_never_omitted() {
1842 for (game_type, tag) in [
1843 (GameType::Open, "open"),
1844 (GameType::Sandbox, "sandbox"),
1845 (GameType::Adventure, "adventure"),
1846 (GameType::Daily, "daily"),
1847 (GameType::RuneBox, "rune_box"),
1848 ] {
1849 let json = serde_json::to_string(&snapshot_of(game_type)).unwrap();
1850 assert!(
1851 json.contains(&format!("\"game_type\":\"{tag}\"")),
1852 "{game_type:?} must serialize its game_type, got: {json}"
1853 );
1854 }
1855 }
1856
1857 #[test]
1861 fn snapshot_without_game_type_decodes_as_unknown() {
1862 let votes = serde_json::to_string(&TimerVoteState::default()).unwrap();
1866 let legacy = format!(
1867 r#"{{
1868 "game_id": "g",
1869 "state": "in_progress",
1870 "grid": [],
1871 "players": [],
1872 "spectators": [],
1873 "current_turn": "1",
1874 "round": 1,
1875 "max_rounds": 1,
1876 "used_words": [],
1877 "timer_vote_state": {votes}
1878 }}"#
1879 );
1880 let back: GameSnapshot =
1881 serde_json::from_str(&legacy).expect("a pre-game_type snapshot must still decode");
1882 assert_eq!(back.game_type, GameType::Unknown);
1883 }
1884
1885 #[test]
1894 fn snapshot_ignores_fields_it_does_not_know() {
1895 let mut json = serde_json::to_string(&snapshot_of(GameType::Open)).unwrap();
1896 json = json.replace('{', r#"{"a_field_from_the_future":[1,2,3],"#);
1898
1899 let back: GameSnapshot =
1900 serde_json::from_str(&json).expect("an unknown field must be ignored, not rejected");
1901 assert_eq!(back.game_type, GameType::Open);
1902 }
1903
1904 #[test]
1905 fn player_name_shows_the_runecast_nickname_unless_austere() {
1906 let name = PlayerName::new("Ada", Some("Rune Queen".to_string()));
1907 assert_eq!(name.shown(false), "Rune Queen");
1908 assert_eq!(name.shown(true), "Ada");
1909 }
1910
1911 #[test]
1912 fn player_name_without_a_runecast_nickname_shows_discord_either_way() {
1913 let name = PlayerName::new("Ada", None);
1914 assert_eq!(name.shown(false), "Ada");
1915 assert_eq!(name.shown(true), "Ada");
1916 }
1917
1918 #[test]
1919 fn player_name_with_an_empty_runecast_nickname_shows_discord() {
1920 assert_eq!(
1921 PlayerName::new("Ada", Some(String::new())).shown(false),
1922 "Ada"
1923 );
1924 }
1925
1926 #[test]
1927 fn player_name_omits_an_absent_runecast_nickname_on_the_wire() {
1928 let json = serde_json::to_string(&PlayerName::new("Ada", None)).unwrap();
1929 assert_eq!(json, r#"{"discord":"Ada"}"#);
1930 let json = serde_json::to_string(&PlayerName::new("Ada", Some("Q".into()))).unwrap();
1931 assert_eq!(json, r#"{"discord":"Ada","runecast":"Q"}"#);
1932 let back: PlayerName = serde_json::from_str(r#"{"discord":"Ada"}"#).unwrap();
1933 assert_eq!(back, PlayerName::new("Ada", None));
1934 }
1935
1936 #[test]
1937 fn a_player_struct_carries_its_name_beside_username() {
1938 let p = SpectatorInfo {
1939 user_id: 1_098_406_821_124_911_175,
1940 username: "ada_l".into(),
1941 name: Some(PlayerName::new("Ada", Some("Rune Queen".into()))),
1942 avatar_url: None,
1943 };
1944 let json = serde_json::to_string(&p).unwrap();
1945 assert!(json.contains(r#""username":"ada_l""#), "{json}");
1946 assert!(
1947 json.contains(r#""name":{"discord":"Ada","runecast":"Rune Queen"}"#),
1948 "{json}"
1949 );
1950 }
1951
1952 #[test]
1953 fn a_player_without_a_resolved_name_has_no_name_key() {
1954 let p = ScoreInfo {
1955 user_id: 1,
1956 username: "ada_l".into(),
1957 name: None,
1958 score: 3,
1959 };
1960 let json = serde_json::to_string(&p).unwrap();
1961 assert!(!json.contains("\"name\""), "{json}");
1962 }
1963
1964 #[test]
1966 fn json_from_an_older_server_parses_with_no_name() {
1967 let old = r#"{"user_id":"1","username":"ada_l","score":3}"#;
1968 let p: ScoreInfo = serde_json::from_str(old).unwrap();
1969 assert_eq!(p.username, "ada_l");
1970 assert!(p.name.is_none());
1971 }
1972}