1use serde::{Deserialize, Serialize};
15
16use crate::protocol::GameType;
17
18use super::types::{
19 AdminGameInfo, AdventureEventKind, CustomMeta, DebugBackendGameState, DebugHandlerGameState,
20 DebugLobbyState, DebugPlayerInfo, DebugWebsocketContext, ErrorCode, GameChange, GameConfig,
21 GamePlayerInfo, GameSnapshot, Grid, LobbyChange, LobbyGameInfo, LobbyPlayerInfo, LobbyType,
22 NewsItemPayload, PlayerInfo, PlayerName, Position, RematchCountdownState, RuneBoxStanding,
23 ScoreInfo, SpectatorInfo, TimerVoteState,
24};
25
26#[allow(clippy::large_enum_variant)]
29#[serde_with::serde_as]
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(tag = "type", rename_all = "snake_case")]
32pub enum ServerMessage {
33 Hello {
40 heartbeat_interval_ms: u32,
42 #[serde(skip_serializing_if = "Option::is_none")]
44 server_version: Option<String>,
45 },
46
47 Ready {
51 session_id: String,
53 #[serde_as(as = "serde_with::DisplayFromStr")]
55 player_id: i64,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 lobby: Option<LobbySnapshot>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 game: Option<GameSnapshot>,
62 },
63
64 Resumed {
68 missed_events: Vec<ServerMessage>,
70 },
71
72 HeartbeatAck {
76 server_time: u64,
78 },
79
80 InvalidSession { reason: String },
84
85 LobbyJoined {
90 lobby_id: String,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 lobby_code: Option<String>,
94 lobby: LobbySnapshot,
96 },
97
98 LobbySnapshot { lobby: LobbySnapshot },
102
103 LobbyDelta { changes: Vec<LobbyChange> },
107
108 LobbyLeft,
110
111 CustomLobbyCreated {
113 lobby_id: String,
114 lobby_code: String,
115 },
116 GameStarted {
123 game_id: String,
124 #[serde(default)]
133 game_type: GameType,
134 grid: Grid,
135 players: Vec<GamePlayerInfo>,
136 your_turn_order: u8,
138 #[serde_as(as = "serde_with::DisplayFromStr")]
140 current_turn: i64,
141 round: u8,
142 max_rounds: u8,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 turn_time_limit: Option<u32>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
150 custom: Option<CustomMeta>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
154 time_remaining_ms: Option<u64>,
155 },
156
157 GameSnapshot { game_id: String, game: GameSnapshot },
161
162 GameDelta {
164 game_id: String,
165 changes: Vec<GameChange>,
166 },
167
168 GameOver {
170 game_id: String,
171 final_scores: Vec<ScoreInfo>,
173 #[serde_as(as = "serde_with::DisplayFromStr")]
175 winner_id: i64,
176 #[serde(default)]
178 is_draw: bool,
179 },
180
181 GameCancelled { game_id: String, reason: String },
183
184 AdventureLevelResult {
189 level: u32,
191 score: i32,
193 stars: u8,
195 personal_best: bool,
197 #[serde(skip_serializing_if = "Option::is_none")]
201 unlocked_level: Option<u32>,
202 #[serde(skip_serializing_if = "Option::is_none")]
209 duration_ms: Option<u64>,
210 },
211
212 DailyResult {
215 seed: i64,
217 score: i32,
219 personal_best: i32,
221 rank: u32,
223 attempts_used: u8,
225 attempts_max: u8,
227 },
228
229 AdventureEvent {
235 game_id: String,
236 kind: AdventureEventKind,
237 affected_positions: Vec<Position>,
238 new_grid: Grid,
241 label: String,
244 },
245
246 PlayerJoined { player: LobbyPlayerInfo },
251
252 PlayerLeft {
254 #[serde_as(as = "serde_with::DisplayFromStr")]
255 player_id: i64,
256 #[serde(skip_serializing_if = "Option::is_none")]
257 reason: Option<String>,
258 },
259
260 PlayerReconnected {
262 #[serde_as(as = "serde_with::DisplayFromStr")]
263 player_id: i64,
264 },
265
266 PlayerDisconnected {
268 #[serde(skip_serializing_if = "Option::is_none")]
269 game_id: Option<String>,
270 #[serde_as(as = "serde_with::DisplayFromStr")]
271 player_id: i64,
272 grace_period_seconds: u32,
274 },
275
276 WordScored {
278 #[serde_as(as = "serde_with::DisplayFromStr")]
279 player_id: i64,
280 game_id: String,
281 word: String,
282 score: i32,
283 path: Vec<super::types::Position>,
285 total_score: i32,
287 gems_earned: i32,
289 total_gems: i32,
291 new_grid: Grid,
293 },
294
295 RuneBoxWordAccepted {
302 game_id: String,
304 word: String,
306 letters: i32,
309 total_score: i32,
311 },
312
313 RuneBoxScoreChanged {
320 #[serde_as(as = "serde_with::DisplayFromStr")]
322 player_id: i64,
323 game_id: String,
325 total_score: i32,
327 },
328
329 RuneBoxRoundEnded {
344 game_id: String,
346 round: u8,
348 max_rounds: u8,
352 round_scores: Vec<ScoreInfo>,
355 #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
362 eliminated: Vec<i64>,
363 standings: Vec<RuneBoxStanding>,
368 #[serde(skip_serializing_if = "Option::is_none")]
373 next_round_starts_at: Option<chrono::DateTime<chrono::Utc>>,
374 },
375
376 TurnChanged {
378 #[serde_as(as = "serde_with::DisplayFromStr")]
379 player_id: i64,
380 game_id: String,
381 round: u8,
382 #[serde(skip_serializing_if = "Option::is_none")]
384 time_remaining: Option<u32>,
385 },
386
387 TurnPassed {
389 #[serde_as(as = "serde_with::DisplayFromStr")]
390 player_id: i64,
391 game_id: String,
392 },
393
394 RoundChanged {
396 game_id: String,
397 round: u8,
398 max_rounds: u8,
399 #[serde(skip_serializing_if = "Option::is_none")]
402 new_grid: Option<Grid>,
403 },
404
405 BoardShuffled {
407 #[serde_as(as = "serde_with::DisplayFromStr")]
408 player_id: i64,
409 game_id: String,
410 new_grid: Grid,
411 gems_spent: i32,
412 total_gems: i32,
414 },
415
416 TileSwapped {
418 #[serde_as(as = "serde_with::DisplayFromStr")]
419 player_id: i64,
420 game_id: String,
421 row: usize,
422 col: usize,
423 old_letter: char,
424 new_letter: char,
425 gems_spent: i32,
426 total_gems: i32,
428 },
429
430 SwapModeEntered {
432 #[serde_as(as = "serde_with::DisplayFromStr")]
433 player_id: i64,
434 game_id: String,
435 },
436
437 SwapModeExited {
439 #[serde_as(as = "serde_with::DisplayFromStr")]
440 player_id: i64,
441 game_id: String,
442 },
443
444 SpectatorJoined {
449 game_id: String,
450 game: GameSnapshot,
452 },
453
454 SpectatorAdded {
456 spectator: SpectatorInfo,
457 game_id: String,
458 },
459
460 SpectatorRemoved {
462 #[serde_as(as = "serde_with::DisplayFromStr")]
463 spectator_id: i64,
464 game_id: String,
465 },
466
467 SpectatorBecamePlayer {
469 #[serde_as(as = "serde_with::DisplayFromStr")]
470 player_id: i64,
471 username: String,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
477 name: Option<PlayerName>,
478 game_id: String,
479 },
480
481 SpectatorLeft,
483
484 SelectionUpdate {
489 #[serde_as(as = "serde_with::DisplayFromStr")]
490 player_id: i64,
491 game_id: String,
492 positions: Vec<super::types::Position>,
493 },
494
495 TimerVoteUpdate {
500 state: TimerVoteState,
501 game_id: String,
502 },
503
504 TurnTimerStarted {
506 #[serde_as(as = "serde_with::DisplayFromStr")]
507 target_player_id: i64,
508 game_id: String,
509 seconds: u32,
510 },
511
512 TurnTimerExpired {
514 #[serde_as(as = "serde_with::DisplayFromStr")]
515 player_id: i64,
516 game_id: String,
517 },
518
519 RematchCountdownUpdate {
526 state: RematchCountdownState,
528 previous_game_id: String,
530 },
531
532 PlayerLeftRematch {
536 #[serde_as(as = "serde_with::DisplayFromStr")]
537 player_id: i64,
538 previous_game_id: String,
540 },
541
542 RematchStarting {
546 #[serde(skip_serializing_if = "Option::is_none")]
548 #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
549 triggered_by: Option<i64>,
550 previous_game_id: String,
552 },
553
554 PlayerPoolChanged {
559 #[serde_as(as = "serde_with::DisplayFromStr")]
560 player_id: i64,
561 old_pool: Option<GameType>,
562 new_pool: Option<GameType>,
563 },
564
565 PoolJoined {
570 position: i32,
571 total_in_pool: i32,
572 game_id: String,
573 },
574
575 PoolUpdate {
577 position: i32,
578 total_in_pool: i32,
579 game_id: String,
580 },
581
582 PoolLeft,
584
585 AdminGamesList { games: Vec<AdminGameInfo> },
590
591 AdminGameDeleted { game_id: String },
593
594 #[serde(rename = "game_state")]
601 GameStateUpdate {
602 game_id: String,
603 state: String,
604 grid: Grid,
605 players: Vec<PlayerInfo>,
606 current_turn: i64,
607 round: i32,
608 max_rounds: i32,
609 used_words: Vec<String>,
610 spectators: Vec<SpectatorInfo>,
611 timer_vote_state: TimerVoteState,
612 },
613
614 #[serde(rename = "lobby_state")]
616 LobbyStateUpdate {
617 lobby_id: String,
618 players: Vec<LobbyPlayerInfo>,
619 games: Vec<LobbyGameInfo>,
620 },
621
622 DebugStateResponse {
627 timestamp: String,
628 player: DebugPlayerInfo,
629 websocket_context: DebugWebsocketContext,
630 lobby_state: Option<DebugLobbyState>,
631 backend_game_state: Option<DebugBackendGameState>,
632 handler_game_state: Option<DebugHandlerGameState>,
633 },
634
635 NewsAnnounced { item: NewsItemPayload },
649
650 Error {
655 code: ErrorCode,
656 message: String,
657 #[serde(skip_serializing_if = "Option::is_none")]
659 details: Option<serde_json::Value>,
660 },
661}
662
663impl ServerMessage {
664 #[must_use]
666 pub fn error(code: ErrorCode) -> Self {
667 Self::Error {
668 message: code.message().to_string(),
669 code,
670 details: None,
671 }
672 }
673
674 pub fn error_with_message(code: ErrorCode, message: impl Into<String>) -> Self {
676 Self::Error {
677 code,
678 message: message.into(),
679 details: None,
680 }
681 }
682
683 pub fn error_with_details(
685 code: ErrorCode,
686 message: impl Into<String>,
687 details: serde_json::Value,
688 ) -> Self {
689 Self::Error {
690 code,
691 message: message.into(),
692 details: Some(details),
693 }
694 }
695
696 #[must_use]
698 pub fn message_type(&self) -> &'static str {
699 match self {
700 Self::Hello { .. } => "hello",
701 Self::Ready { .. } => "ready",
702 Self::Resumed { .. } => "resumed",
703 Self::HeartbeatAck { .. } => "heartbeat_ack",
704 Self::InvalidSession { .. } => "invalid_session",
705 Self::LobbyJoined { .. } => "lobby_joined",
706 Self::LobbySnapshot { .. } => "lobby_snapshot",
707 Self::LobbyDelta { .. } => "lobby_delta",
708 Self::LobbyLeft => "lobby_left",
709 Self::CustomLobbyCreated { .. } => "custom_lobby_created",
710 Self::GameStarted { .. } => "game_started",
711 Self::GameSnapshot { .. } => "game_snapshot",
712 Self::GameDelta { .. } => "game_delta",
713 Self::GameOver { .. } => "game_over",
714 Self::GameCancelled { .. } => "game_cancelled",
715 Self::AdventureLevelResult { .. } => "adventure_level_result",
716 Self::DailyResult { .. } => "daily_result",
717 Self::AdventureEvent { .. } => "adventure_event",
718 Self::PlayerJoined { .. } => "player_joined",
719 Self::PlayerLeft { .. } => "player_left",
720 Self::PlayerReconnected { .. } => "player_reconnected",
721 Self::PlayerDisconnected { .. } => "player_disconnected",
722 Self::PlayerPoolChanged { .. } => "player_pool_changed",
723 Self::WordScored { .. } => "word_scored",
724 Self::RuneBoxWordAccepted { .. } => "rune_box_word_accepted",
725 Self::RuneBoxScoreChanged { .. } => "rune_box_score_changed",
726 Self::RuneBoxRoundEnded { .. } => "rune_box_round_ended",
727 Self::TurnChanged { .. } => "turn_changed",
728 Self::TurnPassed { .. } => "turn_passed",
729 Self::RoundChanged { .. } => "round_changed",
730 Self::BoardShuffled { .. } => "board_shuffled",
731 Self::TileSwapped { .. } => "tile_swapped",
732 Self::SwapModeEntered { .. } => "swap_mode_entered",
733 Self::SwapModeExited { .. } => "swap_mode_exited",
734 Self::SpectatorJoined { .. } => "spectator_joined",
735 Self::SpectatorAdded { .. } => "spectator_added",
736 Self::SpectatorRemoved { .. } => "spectator_removed",
737 Self::SpectatorLeft => "spectator_left",
738 Self::SpectatorBecamePlayer { .. } => "spectator_became_player",
739 Self::SelectionUpdate { .. } => "selection_update",
740 Self::TimerVoteUpdate { .. } => "timer_vote_update",
741 Self::TurnTimerStarted { .. } => "turn_timer_started",
742 Self::TurnTimerExpired { .. } => "turn_timer_expired",
743 Self::RematchCountdownUpdate { .. } => "rematch_countdown_update",
744 Self::PlayerLeftRematch { .. } => "player_left_rematch",
745 Self::RematchStarting { .. } => "rematch_starting",
746 Self::PoolJoined { .. } => "pool_joined",
747 Self::PoolUpdate { .. } => "pool_update",
748 Self::PoolLeft => "pool_left",
749 Self::AdminGamesList { .. } => "admin_games_list",
750 Self::AdminGameDeleted { .. } => "admin_game_deleted",
751 Self::GameStateUpdate { .. } => "game_state",
752 Self::LobbyStateUpdate { .. } => "lobby_state",
753 Self::DebugStateResponse { .. } => "debug_state_response",
754
755 Self::NewsAnnounced { .. } => "news_announced",
756
757 Self::Error { .. } => "error",
758 }
759 }
760
761 #[must_use]
763 pub fn is_error(&self) -> bool {
764 matches!(self, Self::Error { .. })
765 }
766
767 #[must_use]
771 pub fn should_store_for_replay(&self) -> bool {
772 !matches!(
773 self,
774 Self::Hello { .. }
775 | Self::HeartbeatAck { .. }
776 | Self::SelectionUpdate { .. }
777 | Self::TimerVoteUpdate {
778 state: TimerVoteState::Idle,
779 ..
780 }
781 | Self::PlayerPoolChanged { .. }
782 | Self::TurnTimerStarted { .. }
783 | Self::TurnTimerExpired { .. }
784 | Self::DebugStateResponse { .. }
785 | Self::NewsAnnounced { .. }
791 )
792 }
793}
794
795impl From<ServerMessage> for serde_json::Value {
797 fn from(msg: ServerMessage) -> Self {
798 serde_json::to_value(msg).unwrap()
799 }
800}
801
802impl TryFrom<serde_json::Value> for ServerMessage {
804 type Error = serde_json::Error;
805
806 fn try_from(
807 value: serde_json::Value,
808 ) -> Result<Self, <ServerMessage as TryFrom<serde_json::Value>>::Error> {
809 serde_json::from_value(value)
810 }
811}
812#[serde_with::serde_as]
818#[derive(Debug, Clone, Serialize, Deserialize)]
819pub struct LobbySnapshot {
820 pub lobby_id: String,
821 pub lobby_type: LobbyType,
822 #[serde(skip_serializing_if = "Option::is_none")]
823 pub lobby_code: Option<String>,
824 pub players: Vec<LobbyPlayerInfo>,
825 pub games: Vec<LobbyGameInfo>,
826 #[serde(default = "default_max_players")]
828 pub max_players: u8,
829 #[serde(default, skip_serializing_if = "Option::is_none")]
832 pub sandbox_config: Option<GameConfig>,
833 #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
839 #[serde(default, skip_serializing_if = "Option::is_none")]
840 pub host_id: Option<i64>,
841}
842
843fn default_max_players() -> u8 {
844 6
845}
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850 use crate::protocol::types::NewsNotificationType;
853 use crate::protocol::{types, GameType};
854
855 #[test]
856 fn spectator_became_player_carries_the_name_on_the_wire() {
857 let msg = ServerMessage::SpectatorBecamePlayer {
858 player_id: 1_234_567_890_123_456_789,
859 username: "ada".to_string(),
860 name: Some(PlayerName::new("Ada", Some("Rune Queen".into()))),
861 game_id: "g1".to_string(),
862 };
863 let json = serde_json::to_string(&msg).unwrap();
864 assert!(json.contains(r#""name":{"discord":"Ada","runecast":"Rune Queen"}"#));
865 assert!(json.contains(r#""player_id":"1234567890123456789""#));
866 }
867
868 #[test]
869 fn spectator_became_player_without_a_name_parses_as_none() {
870 let raw = r#"{"type":"spectator_became_player","player_id":"42","username":"ada","game_id":"g1"}"#;
871 match serde_json::from_str::<ServerMessage>(raw).unwrap() {
872 ServerMessage::SpectatorBecamePlayer {
873 player_id, name, ..
874 } => {
875 assert_eq!(player_id, 42);
876 assert!(name.is_none());
877 }
878 other => panic!("wrong variant: {other:?}"),
879 }
880 }
881
882 #[test]
883 fn test_server_message_into_json() {
884 let msg = ServerMessage::BoardShuffled {
885 player_id: 1_234_567_890,
886 game_id: "1234567890".to_string(),
887 new_grid: Grid::new(),
888 gems_spent: 0,
889 total_gems: 0,
890 };
891 let json = serde_json::to_string(&msg).unwrap();
892 assert!(json.contains(r#""type":"board_shuffled""#));
893 assert!(json.contains(r#""player_id":"1234567890""#));
894 assert!(json.contains(r#""game_id":"1234567890""#));
895 assert!(json.contains(r#""new_grid":[]"#));
896 assert!(json.contains(r#""gems_spent":0"#));
897 assert!(json.contains(r#""total_gems":0"#));
898 }
899
900 #[test]
901 fn test_heartbeat_ack_serialization() {
902 let msg = ServerMessage::HeartbeatAck {
903 server_time: 1_701_234_567_890,
904 };
905 let json = serde_json::to_string(&msg).unwrap();
906 assert!(json.contains(r#""type":"heartbeat_ack""#));
907 assert!(json.contains(r#""server_time":1701234567890"#));
908 }
909
910 #[test]
911 fn test_error_message_creation() {
912 let msg = ServerMessage::error(ErrorCode::NotYourTurn);
913 match msg {
914 ServerMessage::Error { code, message, .. } => {
915 assert_eq!(code, ErrorCode::NotYourTurn);
916 assert_eq!(message, "It's not your turn");
917 }
918 _ => panic!("Expected error message"),
919 }
920 }
921
922 #[test]
923 fn test_error_with_details() {
924 let msg = ServerMessage::error_with_details(
925 ErrorCode::InvalidPath,
926 "Tiles must be adjacent",
927 serde_json::json!({"positions": [[0,0], [2,2]]}),
928 );
929 match msg {
930 ServerMessage::Error { details, .. } => {
931 assert!(details.is_some());
932 }
933 _ => panic!("Expected error message"),
934 }
935 }
936
937 #[test]
938 fn test_player_joined_serialization() {
939 let msg = ServerMessage::PlayerJoined {
940 player: LobbyPlayerInfo {
941 user_id: 123,
942 username: "TestPlayer".to_string(),
943 name: None,
944 avatar_url: None,
945 banner_url: None,
946 accent_color: None,
947 current_game_pool: None,
948 active_game_id: None,
949 spectate_game_id: None,
950 },
951 };
952 let json = serde_json::to_string(&msg).unwrap();
953 assert!(json.contains(r#""type":"player_joined""#));
954 assert!(json.contains(r#""user_id":"123""#));
955 }
956
957 #[test]
958 fn test_game_state_update_legacy() {
959 let json = r#"{"type":"game_state","game_id":"abc","state":"in_progress","grid":[],"players":[],"current_turn":123,"round":1,"max_rounds":5,"used_words":[],"spectators":[],"timer_vote_state":{"status":"idle"}}"#;
961 let msg: ServerMessage = serde_json::from_str(json).unwrap();
962 assert!(matches!(msg, ServerMessage::GameStateUpdate { .. }));
963 }
964
965 #[test]
966 fn test_should_store_for_replay() {
967 assert!(!ServerMessage::HeartbeatAck { server_time: 0 }.should_store_for_replay());
968 assert!(ServerMessage::PlayerJoined {
969 player: LobbyPlayerInfo {
970 user_id: 1,
971 username: "x".into(),
972 name: None,
973 avatar_url: None,
974 banner_url: None,
975 accent_color: None,
976 current_game_pool: None,
977 active_game_id: None,
978 spectate_game_id: None,
979 }
980 }
981 .should_store_for_replay());
982 }
983
984 #[test]
985 fn test_message_type() {
986 assert_eq!(
987 ServerMessage::HeartbeatAck { server_time: 0 }.message_type(),
988 "heartbeat_ack"
989 );
990 assert_eq!(
991 ServerMessage::error(ErrorCode::NotYourTurn).message_type(),
992 "error"
993 );
994
995 assert_eq!(
999 ServerMessage::AdventureLevelResult {
1000 level: 1,
1001 score: 10,
1002 stars: 1,
1003 personal_best: true,
1004 unlocked_level: None,
1005 duration_ms: None,
1006 }
1007 .message_type(),
1008 "adventure_level_result",
1009 );
1010 assert_eq!(
1011 ServerMessage::AdventureEvent {
1012 game_id: "g".to_string(),
1013 kind: types::AdventureEventKind::Bomb,
1014 affected_positions: vec![],
1015 new_grid: vec![],
1016 label: String::new(),
1017 }
1018 .message_type(),
1019 "adventure_event",
1020 );
1021 }
1022
1023 #[test]
1024 fn test_adventure_level_result_serialization() {
1025 let msg = ServerMessage::AdventureLevelResult {
1029 level: 3,
1030 score: 120,
1031 stars: 2,
1032 personal_best: false,
1033 unlocked_level: None,
1034 duration_ms: None,
1035 };
1036 let json = serde_json::to_string(&msg).unwrap();
1037 assert!(json.contains(r#""type":"adventure_level_result""#));
1038 assert!(json.contains(r#""level":3"#));
1039 assert!(json.contains(r#""score":120"#));
1040 assert!(json.contains(r#""stars":2"#));
1041 assert!(json.contains(r#""personal_best":false"#));
1042 assert!(
1043 !json.contains("unlocked_level"),
1044 "unlocked_level should be omitted when None (got: {json})"
1045 );
1046 assert!(
1047 !json.contains("duration_ms"),
1048 "duration_ms should be omitted when None (got: {json})"
1049 );
1050
1051 let msg = ServerMessage::AdventureLevelResult {
1057 level: 3,
1058 score: 120,
1059 stars: 3,
1060 personal_best: true,
1061 unlocked_level: Some(4),
1062 duration_ms: Some(123_456),
1063 };
1064 let json = serde_json::to_string(&msg).unwrap();
1065 assert!(json.contains(r#""unlocked_level":4"#));
1066 assert!(json.contains(r#""duration_ms":123456"#));
1067
1068 let parsed: ServerMessage = serde_json::from_str(&json).unwrap();
1071 match parsed {
1072 ServerMessage::AdventureLevelResult {
1073 level,
1074 score,
1075 stars,
1076 personal_best,
1077 unlocked_level,
1078 duration_ms,
1079 } => {
1080 assert_eq!(level, 3);
1081 assert_eq!(score, 120);
1082 assert_eq!(stars, 3);
1083 assert!(personal_best);
1084 assert_eq!(unlocked_level, Some(4));
1085 assert_eq!(duration_ms, Some(123_456));
1086 }
1087 other => panic!("expected AdventureLevelResult, got {other:?}"),
1088 }
1089 }
1090
1091 #[test]
1092 fn daily_result_serializes() {
1093 let msg = ServerMessage::DailyResult {
1094 seed: 1_592_668_382,
1095 score: 240,
1096 personal_best: 240,
1097 rank: 3,
1098 attempts_used: 1,
1099 attempts_max: 1,
1100 };
1101 assert_eq!(msg.message_type(), "daily_result");
1102 let s = serde_json::to_string(&msg).unwrap();
1103 assert!(s.contains("\"type\":\"daily_result\""));
1104 assert!(s.contains("\"rank\":3"));
1105 let back: ServerMessage = serde_json::from_str(&s).unwrap();
1106 assert!(matches!(back, ServerMessage::DailyResult { rank: 3, .. }));
1107 }
1108
1109 #[test]
1110 fn game_started_time_remaining_optional() {
1111 let msg = ServerMessage::GameStarted {
1112 game_type: GameType::Open,
1113 game_id: "g1".into(),
1114 grid: vec![],
1115 players: vec![],
1116 your_turn_order: 0,
1117 current_turn: 42,
1118 round: 1,
1119 max_rounds: 1,
1120 turn_time_limit: None,
1121 custom: None,
1122 time_remaining_ms: None,
1123 };
1124 let s = serde_json::to_string(&msg).unwrap();
1125 assert!(!s.contains("time_remaining_ms"));
1126 }
1127
1128 #[test]
1129 fn test_adventure_event_serialization() {
1130 let msg = ServerMessage::AdventureEvent {
1131 game_id: "abc".to_string(),
1132 kind: types::AdventureEventKind::Bomb,
1133 affected_positions: vec![
1134 types::Position { row: 1, col: 2 },
1135 types::Position { row: 2, col: 2 },
1136 ],
1137 new_grid: vec![vec![
1138 types::GridCell {
1142 letter: 'A',
1143 value: 1,
1144 multiplier: None,
1145 has_gem: false,
1146 is_hole: false,
1147 is_poisoned: false,
1148 is_abducted: false,
1149 },
1150 types::GridCell {
1151 letter: ' ',
1152 value: 0,
1153 multiplier: None,
1154 has_gem: false,
1155 is_hole: true,
1156 is_poisoned: false,
1157 is_abducted: false,
1158 },
1159 ]],
1160 label: "💣 Bomb!".to_string(),
1161 };
1162
1163 let json = serde_json::to_string(&msg).unwrap();
1164 assert!(json.contains(r#""type":"adventure_event""#));
1165 assert!(json.contains(r#""game_id":"abc""#));
1166 assert!(json.contains(r#""kind":"bomb""#));
1168 assert!(json.contains(r#""affected_positions":[{"row":1,"col":2}"#));
1170 assert!(json.contains(r#""letter":"A""#));
1172 assert!(json.contains(r#""is_hole":true"#));
1173
1174 let parsed: ServerMessage = serde_json::from_str(&json).unwrap();
1176 match parsed {
1177 ServerMessage::AdventureEvent {
1178 game_id,
1179 kind,
1180 affected_positions,
1181 new_grid,
1182 label,
1183 } => {
1184 assert_eq!(game_id, "abc");
1185 assert_eq!(kind, types::AdventureEventKind::Bomb);
1186 assert_eq!(affected_positions.len(), 2);
1187 assert_eq!(new_grid[0].len(), 2);
1188 assert!(new_grid[0][1].is_hole);
1189 assert_eq!(label, "💣 Bomb!");
1190 }
1191 other => panic!("expected AdventureEvent, got {other:?}"),
1192 }
1193 }
1194
1195 #[test]
1196 fn test_selection_update_serialization() {
1197 let msg = ServerMessage::SelectionUpdate {
1198 player_id: 11,
1199 game_id: "game1".to_string(),
1200 positions: vec![
1201 types::Position { row: 0, col: 0 },
1202 types::Position { row: 0, col: 1 },
1203 ],
1204 };
1205 let json = serde_json::to_string(&msg).unwrap();
1206 assert!(json.contains(r#""type":"selection_update""#));
1207 assert!(json.contains(r#""player_id":"11""#));
1208 assert!(json.contains(r#""game_id":"game1""#));
1209 }
1210
1211 #[test]
1212 fn test_player_queue_changed_serialization() {
1213 let msg = ServerMessage::PlayerPoolChanged {
1214 player_id: 123,
1215 old_pool: Some(GameType::Open),
1216 new_pool: Some(GameType::Adventure),
1217 };
1218 let json = serde_json::to_string(&msg).unwrap();
1219 assert!(json.contains(r#""type":"player_pool_changed""#));
1220 assert!(json.contains(r#""player_id":"123""#));
1221 assert!(json.contains(r#""old_pool":"open""#));
1222 assert!(json.contains(r#""new_pool":"adventure""#));
1223 }
1224
1225 #[test]
1226 fn test_debug_state_response_serialization() {
1227 let msg = ServerMessage::DebugStateResponse {
1228 timestamp: "2024-01-01T12:00:00Z".to_string(),
1229 player: DebugPlayerInfo {
1230 user_id: 987654321,
1231 username: "debug_user".to_string(),
1232 },
1233 websocket_context: DebugWebsocketContext {
1234 lobby_id: Some("lobby123".to_string()),
1235 game_id: Some("game456".to_string()),
1236 is_spectating: false,
1237 },
1238 lobby_state: Some(DebugLobbyState::Found {
1239 lobby_id: "lobby123".to_string(),
1240 player_in_lobby: true,
1241 lobby_player_ids: vec![111, 222, 333],
1242 active_game_id: Some("game456".to_string()),
1243 }),
1244 backend_game_state: Some(DebugBackendGameState::Found {
1245 game_id: "game456".to_string(),
1246 player_in_session_players: true,
1247 spectator_in_session: false,
1248 session_player_ids: vec![111, 222],
1249 session_spectator_ids: vec![],
1250 lobby_id: "lobby123".to_string(),
1251 }),
1252 handler_game_state: Some(DebugHandlerGameState::Found {
1253 game_id: "game456".to_string(),
1254 player_in_handler_game: true,
1255 handler_player_ids: vec![111, 222],
1256 current_turn_index: 0,
1257 round: 1,
1258 state: "in_progress".to_string(),
1259 }),
1260 };
1261
1262 let json = serde_json::to_string(&msg).unwrap();
1264
1265 assert!(json.contains(r#""type":"debug_state_response""#));
1267
1268 assert!(json.contains(r#""user_id":987654321"#));
1270 assert!(json.contains(r#""lobby_player_ids":[111,222,333]"#));
1271 assert!(json.contains(r#""session_player_ids":[111,222]"#));
1272 assert!(json.contains(r#""handler_player_ids":[111,222]"#));
1273
1274 assert!(json.contains(r#""timestamp":"2024-01-01T12:00:00Z""#));
1276 assert!(json.contains(r#""username":"debug_user""#));
1277 assert!(json.contains(r#""lobby_id":"lobby123""#));
1278 assert!(json.contains(r#""game_id":"game456""#));
1279 assert!(json.contains(r#""is_spectating":false"#));
1280 assert!(json.contains(r#""player_in_lobby":true"#));
1281
1282 let deserialized: ServerMessage = serde_json::from_str(&json).unwrap();
1284 match deserialized {
1285 ServerMessage::DebugStateResponse {
1286 timestamp,
1287 player,
1288 websocket_context,
1289 lobby_state,
1290 backend_game_state,
1291 handler_game_state,
1292 } => {
1293 assert_eq!(timestamp, "2024-01-01T12:00:00Z");
1294 assert_eq!(player.user_id, 987654321);
1295 assert_eq!(player.username, "debug_user");
1296 assert_eq!(websocket_context.lobby_id, Some("lobby123".to_string()));
1297 assert_eq!(websocket_context.game_id, Some("game456".to_string()));
1298 assert!(!websocket_context.is_spectating);
1299 assert!(lobby_state.is_some());
1300 assert!(backend_game_state.is_some());
1301 assert!(handler_game_state.is_some());
1302 }
1303 _ => panic!("Expected DebugStateResponse message"),
1304 }
1305 }
1306
1307 #[test]
1308 fn test_debug_state_response_with_errors() {
1309 let msg = ServerMessage::DebugStateResponse {
1311 timestamp: "2024-01-01T12:00:00Z".to_string(),
1312 player: DebugPlayerInfo {
1313 user_id: 123,
1314 username: "test".to_string(),
1315 },
1316 websocket_context: DebugWebsocketContext {
1317 lobby_id: None,
1318 game_id: None,
1319 is_spectating: false,
1320 },
1321 lobby_state: Some(DebugLobbyState::Error {
1322 error: "Lobby not found".to_string(),
1323 }),
1324 backend_game_state: Some(DebugBackendGameState::Error {
1325 error: "Game not found".to_string(),
1326 }),
1327 handler_game_state: Some(DebugHandlerGameState::Error {
1328 error: "Handler not found".to_string(),
1329 }),
1330 };
1331
1332 let json = serde_json::to_string(&msg).unwrap();
1333
1334 assert!(json.contains(r#""error":"Lobby not found""#));
1336 assert!(json.contains(r#""error":"Game not found""#));
1337 assert!(json.contains(r#""error":"Handler not found""#));
1338
1339 let deserialized: ServerMessage = serde_json::from_str(&json).unwrap();
1341 assert!(matches!(
1342 deserialized,
1343 ServerMessage::DebugStateResponse { .. }
1344 ));
1345 }
1346
1347 #[test]
1348 fn test_debug_state_response_minimal() {
1349 let msg = ServerMessage::DebugStateResponse {
1351 timestamp: "2024-01-01T12:00:00Z".to_string(),
1352 player: DebugPlayerInfo {
1353 user_id: 456,
1354 username: "minimal_user".to_string(),
1355 },
1356 websocket_context: DebugWebsocketContext {
1357 lobby_id: None,
1358 game_id: None,
1359 is_spectating: true,
1360 },
1361 lobby_state: None,
1362 backend_game_state: None,
1363 handler_game_state: None,
1364 };
1365
1366 let json = serde_json::to_string(&msg).unwrap();
1367
1368 assert!(json.contains(r#""type":"debug_state_response""#));
1370
1371 assert!(json.contains(r#""user_id":456"#));
1373 assert!(json.contains(r#""username":"minimal_user""#));
1374 assert!(json.contains(r#""is_spectating":true"#));
1375
1376 let deserialized: ServerMessage = serde_json::from_str(&json).unwrap();
1378 match deserialized {
1379 ServerMessage::DebugStateResponse {
1380 player,
1381 lobby_state,
1382 backend_game_state,
1383 handler_game_state,
1384 ..
1385 } => {
1386 assert_eq!(player.user_id, 456);
1387 assert!(lobby_state.is_none());
1388 assert!(backend_game_state.is_none());
1389 assert!(handler_game_state.is_none());
1390 }
1391 _ => panic!("Expected DebugStateResponse message"),
1392 }
1393 }
1394
1395 #[test]
1396 fn test_news_announced_round_trip() {
1397 let now = chrono::Utc::now();
1398 let expires = now + chrono::Duration::hours(1);
1399 let original = ServerMessage::NewsAnnounced {
1400 item: NewsItemPayload {
1401 id: "abc-123".to_string(),
1402 title: "Scheduled Maintenance".to_string(),
1403 message: "Servers will restart for ~5 minutes.".to_string(),
1404 notification_type: NewsNotificationType::Maintenance,
1405 created_at: now,
1406 expires_at: Some(expires),
1407 auto_hide_seconds: 0,
1408 refresh_on_every_login: false,
1409 priority: 100,
1410 },
1411 };
1412
1413 let json = serde_json::to_string(&original).unwrap();
1414 assert!(json.contains(r#""type":"news_announced""#));
1415 assert!(json.contains(r#""notification_type":"maintenance""#));
1416
1417 let parsed: ServerMessage = serde_json::from_str(&json).unwrap();
1418 match parsed {
1419 ServerMessage::NewsAnnounced { item } => {
1420 assert_eq!(item.id, "abc-123");
1421 assert_eq!(item.notification_type, NewsNotificationType::Maintenance);
1422 assert_eq!(item.priority, 100);
1423 assert_eq!(item.expires_at, Some(expires));
1424 }
1425 _ => panic!("Expected NewsAnnounced"),
1426 }
1427 }
1428
1429 #[test]
1430 fn test_news_announced_excluded_from_replay() {
1431 let msg = ServerMessage::NewsAnnounced {
1434 item: NewsItemPayload {
1435 id: "x".to_string(),
1436 title: "t".to_string(),
1437 message: "m".to_string(),
1438 notification_type: NewsNotificationType::Announcement,
1439 created_at: chrono::Utc::now(),
1440 expires_at: None,
1441 auto_hide_seconds: 0,
1442 refresh_on_every_login: false,
1443 priority: 0,
1444 },
1445 };
1446 assert!(!msg.should_store_for_replay());
1447 }
1448
1449 #[test]
1450 fn game_started_custom_marker_roundtrips_and_omits_when_none() {
1451 use crate::protocol::types::CustomMeta;
1452
1453 let msg = ServerMessage::GameStarted {
1455 game_type: GameType::Open,
1456 game_id: "game-sandbox-1".to_string(),
1457 grid: vec![],
1458 players: vec![types::GamePlayerInfo {
1459 user_id: 111_222_333_444_555_666,
1460 username: "Player1".to_string(),
1461 name: None,
1462 avatar_url: None,
1463 turn_order: 0,
1464 score: 0,
1465 gems: 0,
1466 is_connected: true,
1467 team: None,
1468 }],
1469 your_turn_order: 0,
1470 current_turn: 111_222_333_444_555_666,
1471 round: 1,
1472 max_rounds: 5,
1473 turn_time_limit: None,
1474 custom: Some(CustomMeta {}),
1475 time_remaining_ms: None,
1476 };
1477 let json = serde_json::to_string(&msg).unwrap();
1478 assert!(
1479 json.contains("\"custom\""),
1480 "custom should serialize when Some (got: {json})"
1481 );
1482 let back: ServerMessage = serde_json::from_str(&json).unwrap();
1483 assert!(
1484 matches!(
1485 back,
1486 ServerMessage::GameStarted {
1487 custom: Some(_),
1488 ..
1489 }
1490 ),
1491 "custom should deserialize as Some"
1492 );
1493
1494 let ffa = ServerMessage::GameStarted {
1496 game_type: GameType::Open,
1497 game_id: "game-ffa-1".to_string(),
1498 grid: vec![],
1499 players: vec![],
1500 your_turn_order: 0,
1501 current_turn: 111_222_333_444_555_666,
1502 round: 1,
1503 max_rounds: 5,
1504 turn_time_limit: None,
1505 custom: None,
1506 time_remaining_ms: None,
1507 };
1508 let json2 = serde_json::to_string(&ffa).unwrap();
1509 assert!(
1510 !json2.contains("\"custom\""),
1511 "custom should be omitted when None (got: {json2})"
1512 );
1513 }
1514
1515 #[test]
1516 fn lobby_snapshot_carries_sandbox_config() {
1517 let mut snap = LobbySnapshot {
1519 lobby_id: "test-lobby".to_string(),
1520 lobby_type: LobbyType::Custom,
1521 lobby_code: None,
1522 players: vec![],
1523 games: vec![],
1524 max_players: 6,
1525 sandbox_config: None,
1526 host_id: None,
1527 };
1528 snap.sandbox_config = Some(GameConfig::default());
1529
1530 let json = serde_json::to_string(&snap).unwrap();
1531 assert!(
1532 json.contains("sandbox_config"),
1533 "sandbox_config should be present when Some (got: {json})"
1534 );
1535 let back: LobbySnapshot = serde_json::from_str(&json).unwrap();
1536 assert!(back.sandbox_config.is_some());
1537
1538 let plain = LobbySnapshot {
1540 lobby_id: "plain-lobby".to_string(),
1541 lobby_type: LobbyType::Custom,
1542 lobby_code: None,
1543 players: vec![],
1544 games: vec![],
1545 max_players: 6,
1546 sandbox_config: None,
1547 host_id: None,
1548 };
1549 assert!(
1550 !serde_json::to_string(&plain)
1551 .unwrap()
1552 .contains("sandbox_config"),
1553 "sandbox_config should be omitted when None"
1554 );
1555 assert!(
1556 !serde_json::to_string(&plain).unwrap().contains("host_id"),
1557 "host_id should be omitted when None"
1558 );
1559 }
1560
1561 #[test]
1562 fn lobby_snapshot_host_id_serializes_as_string_snowflake_safe() {
1563 let snowflake: i64 = 123_456_789_012_345_678; let snap = LobbySnapshot {
1568 lobby_id: "lobby".to_string(),
1569 lobby_type: LobbyType::Custom,
1570 lobby_code: None,
1571 players: vec![],
1572 games: vec![],
1573 max_players: 6,
1574 sandbox_config: None,
1575 host_id: Some(snowflake),
1576 };
1577 let json = serde_json::to_string(&snap).unwrap();
1578 assert!(
1580 json.contains(r#""host_id":"123456789012345678""#),
1581 "host_id must serialize as a quoted string, got: {json}"
1582 );
1583 let back: LobbySnapshot = serde_json::from_str(&json).unwrap();
1585 assert_eq!(back.host_id, Some(snowflake));
1586 }
1587
1588 #[test]
1589 fn runebox_word_accepted_round_trips() {
1590 let msg = ServerMessage::RuneBoxWordAccepted {
1591 game_id: "g1".to_string(),
1592 word: "RUNES".to_string(),
1593 letters: 5,
1594 total_score: 17,
1595 };
1596 let json = serde_json::to_string(&msg).unwrap();
1597 assert!(json.contains(r#""type":"rune_box_word_accepted""#));
1598 let back: ServerMessage = serde_json::from_str(&json).unwrap();
1599 match back {
1600 ServerMessage::RuneBoxWordAccepted {
1601 word,
1602 letters,
1603 total_score,
1604 ..
1605 } => {
1606 assert_eq!(word, "RUNES");
1607 assert_eq!(letters, 5);
1608 assert_eq!(total_score, 17);
1609 }
1610 other => panic!("wrong variant: {other:?}"),
1611 }
1612 }
1613
1614 #[test]
1615 fn runebox_score_changed_carries_no_word() {
1616 let msg = ServerMessage::RuneBoxScoreChanged {
1622 player_id: 1_234_567_890_123_456_789,
1623 game_id: "g1".to_string(),
1624 total_score: 12,
1625 };
1626 let json = serde_json::to_string(&msg).unwrap();
1627 assert!(
1628 !json.contains("word"),
1629 "opponent broadcast leaked a word: {json}"
1630 );
1631 assert!(
1633 json.contains(r#""player_id":"1234567890123456789""#),
1634 "player_id must serialize as a quoted string, got: {json}"
1635 );
1636 let back: ServerMessage = serde_json::from_str(&json).unwrap();
1637 match back {
1638 ServerMessage::RuneBoxScoreChanged {
1639 player_id,
1640 total_score,
1641 ..
1642 } => {
1643 assert_eq!(player_id, 1_234_567_890_123_456_789);
1644 assert_eq!(total_score, 12);
1645 }
1646 other => panic!("wrong variant: {other:?}"),
1647 }
1648 }
1649
1650 #[test]
1651 fn runebox_messages_have_distinct_wire_types() {
1652 let accepted = ServerMessage::RuneBoxWordAccepted {
1653 game_id: "g1".to_string(),
1654 word: "RUNES".to_string(),
1655 letters: 5,
1656 total_score: 5,
1657 };
1658 let changed = ServerMessage::RuneBoxScoreChanged {
1659 player_id: 1,
1660 game_id: "g1".to_string(),
1661 total_score: 5,
1662 };
1663 assert_eq!(accepted.message_type(), "rune_box_word_accepted");
1664 assert_eq!(changed.message_type(), "rune_box_score_changed");
1665 }
1666
1667 #[test]
1673 fn game_started_carries_its_game_type() {
1674 let msg = ServerMessage::GameStarted {
1675 game_type: GameType::RuneBox,
1676 game_id: "g1".to_string(),
1677 grid: vec![],
1678 players: vec![],
1679 your_turn_order: 0,
1680 current_turn: 1,
1681 round: 1,
1682 max_rounds: 3,
1683 turn_time_limit: None,
1684 custom: None,
1685 time_remaining_ms: Some(90_000),
1686 };
1687 let json = serde_json::to_string(&msg).unwrap();
1688 assert!(
1689 json.contains(r#""game_type":"rune_box""#),
1690 "game_started must name the mode, got: {json}"
1691 );
1692 let back: ServerMessage = serde_json::from_str(&json).unwrap();
1693 match back {
1694 ServerMessage::GameStarted { game_type, .. } => {
1695 assert_eq!(game_type, GameType::RuneBox);
1696 }
1697 other => panic!("wrong variant: {other:?}"),
1698 }
1699 }
1700
1701 fn round_ended_sample(
1703 next_round_starts_at: Option<chrono::DateTime<chrono::Utc>>,
1704 ) -> ServerMessage {
1705 ServerMessage::RuneBoxRoundEnded {
1706 game_id: "g1".to_string(),
1707 round: 1,
1708 max_rounds: 2,
1709 round_scores: vec![
1710 ScoreInfo {
1711 user_id: 1_234_567_890_123_456_789,
1712 username: "Ada".to_string(),
1713 name: None,
1714 score: 41,
1715 },
1716 ScoreInfo {
1717 user_id: 8_876_543_210_987_654_321_i64,
1718 username: "Bo".to_string(),
1719 name: None,
1720 score: 12,
1721 },
1722 ],
1723 eliminated: vec![8_876_543_210_987_654_321_i64],
1724 standings: vec![RuneBoxStanding {
1725 player_id: 1_234_567_890_123_456_789,
1726 username: "Ada".to_string(),
1727 name: None,
1728 round_wins: 1,
1729 total_score: 41,
1730 eliminated: false,
1731 }],
1732 next_round_starts_at,
1733 }
1734 }
1735
1736 #[test]
1740 fn round_end_is_a_distinct_message_type_from_game_over() {
1741 let round_end = round_ended_sample(None);
1742 let match_end = ServerMessage::GameOver {
1743 game_id: "g1".to_string(),
1744 final_scores: vec![],
1745 winner_id: 1,
1746 is_draw: false,
1747 };
1748 assert_eq!(round_end.message_type(), "rune_box_round_ended");
1749 assert_eq!(match_end.message_type(), "game_over");
1750 assert_ne!(round_end.message_type(), match_end.message_type());
1751 }
1752
1753 #[test]
1754 fn round_ended_round_trips_with_snowflakes_as_strings() {
1755 let json = serde_json::to_string(&round_ended_sample(None)).unwrap();
1756 assert!(json.contains(r#""type":"rune_box_round_ended""#));
1757 assert!(
1761 json.contains(r#""eliminated":["8876543210987654321"]"#),
1762 "eliminated ids must be quoted strings, got: {json}"
1763 );
1764 assert!(
1765 json.contains(r#""player_id":"1234567890123456789""#),
1766 "standings player_id must be a quoted string, got: {json}"
1767 );
1768 assert!(
1769 json.contains(r#""user_id":"1234567890123456789""#),
1770 "round score user_id must be a quoted string, got: {json}"
1771 );
1772
1773 let back: ServerMessage = serde_json::from_str(&json).unwrap();
1774 match back {
1775 ServerMessage::RuneBoxRoundEnded {
1776 round,
1777 max_rounds,
1778 round_scores,
1779 eliminated,
1780 standings,
1781 next_round_starts_at,
1782 ..
1783 } => {
1784 assert_eq!(round, 1);
1785 assert_eq!(max_rounds, 2);
1786 assert_eq!(round_scores.len(), 2);
1787 assert_eq!(eliminated, vec![8_876_543_210_987_654_321_i64]);
1788 assert_eq!(standings[0].round_wins, 1);
1789 assert_eq!(standings[0].total_score, 41);
1790 assert!(!standings[0].eliminated);
1791 assert!(next_round_starts_at.is_none());
1792 }
1793 other => panic!("wrong variant: {other:?}"),
1794 }
1795 }
1796
1797 #[test]
1801 fn round_ended_leaks_no_words() {
1802 let json = serde_json::to_string(&round_ended_sample(None)).unwrap();
1803 assert!(
1804 !json.contains("word"),
1805 "round-end summary leaked a word field: {json}"
1806 );
1807 }
1808
1809 #[test]
1813 fn round_ended_omits_unscheduled_next_round() {
1814 let json = serde_json::to_string(&round_ended_sample(None)).unwrap();
1815 assert!(!json.contains("next_round_starts_at"), "got: {json}");
1816
1817 let at = chrono::DateTime::parse_from_rfc3339("2026-07-29T12:00:00Z")
1818 .unwrap()
1819 .with_timezone(&chrono::Utc);
1820 let json = serde_json::to_string(&round_ended_sample(Some(at))).unwrap();
1821 assert!(json.contains("next_round_starts_at"), "got: {json}");
1822 let back: ServerMessage = serde_json::from_str(&json).unwrap();
1823 match back {
1824 ServerMessage::RuneBoxRoundEnded {
1825 next_round_starts_at,
1826 ..
1827 } => assert_eq!(next_round_starts_at, Some(at)),
1828 other => panic!("wrong variant: {other:?}"),
1829 }
1830 }
1831}