From 958866b37dd0541b6bb58d0d4aa4aa7805f6ed49 Mon Sep 17 00:00:00 2001 From: Cycle Five Date: Fri, 16 Oct 2026 01:53:07 -0400 Subject: [PATCH] feat: the opt-in rematch state, the lobby's auto-rematch flag and its switch (v0.21.0) Co-Authored-By: Claude & Lothrop (cycle.five@proton.me) --- Cargo.toml | 2 +- src/protocol/client_messages.rs | 19 ++++++++++ src/protocol/server_messages.rs | 30 +++++++++++++++ src/protocol/types.rs | 66 +++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 23a8d70..4d6e6e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "runecast-protocol" -version = "0.20.0" +version = "0.21.0" edition = "2021" description = "Protocol definitions for RuneCast WebSocket communication" diff --git a/src/protocol/client_messages.rs b/src/protocol/client_messages.rs index bb9ca38..1acd0aa 100644 --- a/src/protocol/client_messages.rs +++ b/src/protocol/client_messages.rs @@ -258,6 +258,11 @@ pub enum ClientMessage { previous_game_id: String, }, + /// Switch the current lobby's auto-rematch setting. Any lobby member may send + /// it; the server broadcasts `LobbyChange::AutoRematchChanged` in a + /// `LobbyDelta`, and sending the current value again changes nothing. + SetAutoRematch { enabled: bool }, + // ======================================================================== // Admin Messages // ======================================================================== @@ -322,6 +327,7 @@ impl ClientMessage { Self::VoteForTimer { .. } => "vote_for_timer", Self::TriggerRematch { .. } => "trigger_rematch", Self::LeaveRematch { .. } => "leave_rematch", + Self::SetAutoRematch { .. } => "set_auto_rematch", Self::AdminGetGames => "admin_get_games", Self::AdminDeleteGame { .. } => "admin_delete_game", Self::PlayerDisconnected { .. } => "player_disconnected", @@ -351,6 +357,7 @@ impl ClientMessage { | Self::VoteForTimer { .. } | Self::TriggerRematch { .. } | Self::LeaveRematch { .. } + | Self::SetAutoRematch { .. } | Self::AdminGetGames | Self::AdminDeleteGame { .. } ) @@ -577,4 +584,16 @@ mod tests { } )); } + + #[test] + fn set_auto_rematch_parses_and_needs_a_lobby() { + let msg: ClientMessage = + serde_json::from_str(r#"{"type":"set_auto_rematch","enabled":false}"#).unwrap(); + assert!(matches!( + msg, + ClientMessage::SetAutoRematch { enabled: false } + )); + assert_eq!(msg.message_type(), "set_auto_rematch"); + assert!(msg.requires_lobby()); + } } diff --git a/src/protocol/server_messages.rs b/src/protocol/server_messages.rs index 4465f8d..ee600c9 100644 --- a/src/protocol/server_messages.rs +++ b/src/protocol/server_messages.rs @@ -838,12 +838,20 @@ pub struct LobbySnapshot { #[serde_as(as = "Option")] #[serde(default, skip_serializing_if = "Option::is_none")] pub host_id: Option, + /// Whether a finished game arms a rematch countdown (`true`) or an opt-in + /// (`false`). Absent from backends before v0.48.0, so it reads as on. + #[serde(default = "default_true")] + pub auto_rematch: bool, } fn default_max_players() -> u8 { 6 } +fn default_true() -> bool { + true +} + #[cfg(test)] mod tests { use super::*; @@ -1524,6 +1532,7 @@ mod tests { max_players: 6, sandbox_config: None, host_id: None, + auto_rematch: true, }; snap.sandbox_config = Some(GameConfig::default()); @@ -1545,6 +1554,7 @@ mod tests { max_players: 6, sandbox_config: None, host_id: None, + auto_rematch: true, }; assert!( !serde_json::to_string(&plain) @@ -1573,6 +1583,7 @@ mod tests { max_players: 6, sandbox_config: None, host_id: Some(snowflake), + auto_rematch: true, }; let json = serde_json::to_string(&snap).unwrap(); // Quoted = string on the wire; an unquoted number would be the bug. @@ -1828,4 +1839,23 @@ mod tests { other => panic!("wrong variant: {other:?}"), } } + + #[test] + fn a_snapshot_without_auto_rematch_reads_as_on() { + let json = r#"{"lobby_id":"l","lobby_type":"channel","players":[],"games":[]}"#; + let snap: LobbySnapshot = serde_json::from_str(json).unwrap(); + assert!( + snap.auto_rematch, + "an older server's snapshot must read as auto-rematch on" + ); + } + + #[test] + fn a_snapshot_carries_auto_rematch_off() { + let json = r#"{"lobby_id":"l","lobby_type":"channel","players":[],"games":[],"auto_rematch":false}"#; + let snap: LobbySnapshot = serde_json::from_str(json).unwrap(); + assert!(!snap.auto_rematch); + let out = serde_json::to_string(&snap).unwrap(); + assert!(out.contains(r#""auto_rematch":false"#), "{out}"); + } } diff --git a/src/protocol/types.rs b/src/protocol/types.rs index 9546dbd..fced995 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -557,6 +557,23 @@ pub enum RematchCountdownState { game_type: GameType, }, + /// Auto-rematch is off for the lobby, or was switched off mid-countdown: no + /// timer. Each eligible player who presses Play Again (`TriggerRematch`) + /// joins `ready`; the rematch starts for `ready` only, once it reaches + /// `needed`. Exists only while at least `needed` players are eligible. + OptIn { + /// Players who pressed Play Again. + #[serde_as(as = "Vec")] + ready: Vec, + /// The previous game's players who may still opt in (a superset of `ready`). + #[serde_as(as = "Vec")] + eligible: Vec, + /// How many must be ready before the rematch starts (`REMATCH_MIN_PLAYERS`). + needed: u8, + /// The game pool/game type for the rematch + game_type: GameType, + }, + /// A player triggered an early start Starting { /// Who triggered the early start (None if countdown expired naturally) @@ -601,6 +618,13 @@ pub enum LobbyChange { /// Host changed HostChanged { new_host_id: String }, + + /// The lobby's auto-rematch setting changed. Any lobby member may change it. + AutoRematchChanged { + enabled: bool, + #[serde_as(as = "serde_with::DisplayFromStr")] + changed_by: i64, + }, } /// Changes to game state (for delta updates). @@ -1969,4 +1993,46 @@ mod tests { assert_eq!(p.username, "ada_l"); assert!(p.name.is_none()); } + + #[test] + fn opt_in_state_round_trips_with_string_ids() { + let s = RematchCountdownState::OptIn { + ready: vec![1], + eligible: vec![1, 2], + needed: 2, + game_type: GameType::Open, + }; + let json = serde_json::to_string(&s).unwrap(); + assert!(json.contains(r#""status":"opt_in""#), "{json}"); + assert!(json.contains(r#""ready":["1"]"#), "{json}"); + assert!(json.contains(r#""eligible":["1","2"]"#), "{json}"); + assert!(json.contains(r#""needed":2"#), "{json}"); + let back: RematchCountdownState = serde_json::from_str(&json).unwrap(); + assert!( + matches!(&back, RematchCountdownState::OptIn { ready, eligible, needed: 2, game_type: GameType::Open } + if ready == &[1] && eligible == &[1, 2]), + "{back:?}" + ); + } + + #[test] + fn auto_rematch_changed_round_trips() { + let c = LobbyChange::AutoRematchChanged { + enabled: false, + changed_by: 42, + }; + let json = serde_json::to_string(&c).unwrap(); + assert_eq!( + json, + r#"{"change_type":"auto_rematch_changed","enabled":false,"changed_by":"42"}"# + ); + let back: LobbyChange = serde_json::from_str(&json).unwrap(); + assert!(matches!( + back, + LobbyChange::AutoRematchChanged { + enabled: false, + changed_by: 42 + } + )); + } }