Skip to main content

launcher/
tournament_selection.rs

1//! The per-mod **tournament selection state**, NO extra file.
2//!
3//! The selection lives in the mod's own active configuration (the
4//! `mods.json` entries the launcher already writes and the hook resolver
5//! already merges):
6//!
7//! - `tournament_room_id`, non-empty = a selection exists; the
8//!   enable_if-gated managed rules interpolate it into their URLs;
9//! - `tournament_env` / `tournament_difficulty` / `tournament_player`,
10//!   the launcher-set values the rules' `remote`/`push_url` interpolate;
11//! - `tournament_room_name` / `tournament_starts_at` /
12//!   `tournament_ends_at`, display + play-gating values (rooms
13//!   have a name and a planning window; a room that has not started yet or
14//!   has expired must not start the game).
15//!
16//! Everything here reads that map. Writing it is the tournament page's
17//! job (same mechanism as any parameter edit,
18//! `mods_tasks::persist_mod_configurations`).
19
20use serde_json::Value;
21use std::collections::HashMap;
22
23/// The configuration key carrying the selected room id (the mod's own
24/// parameter id, declared, but hidden from the UI since ).
25pub const TOURNAMENT_ROOM_ID_CFG: &str = "tournament_room_id";
26/// display name of the selected room (launcher-written; no rule
27/// interpolates it, it exists so the UI never has to show the room id).
28pub const TOURNAMENT_ROOM_NAME_CFG: &str = "tournament_room_name";
29/// when the selected room opens for play (epoch seconds, UTC).
30pub const TOURNAMENT_STARTS_AT_CFG: &str = "tournament_starts_at";
31/// when the selected room closes (epoch seconds, UTC).
32pub const TOURNAMENT_ENDS_AT_CFG: &str = "tournament_ends_at";
33const TOURNAMENT_ENV_CFG: &str = "tournament_env";
34const TOURNAMENT_DIFFICULTY_CFG: &str = "tournament_difficulty";
35const TOURNAMENT_PLAYER_CFG: &str = "tournament_player";
36/// the mod's own play-online toggle (declared, user-facing): when off,
37/// the whole tournament flow goes dormant and the mod's offline rules
38/// take over (vanilla main menu with the room list).
39pub const TOURNAMENT_ONLINE_CFG: &str = "tournament_online";
40/// the launcher-computed room list the offline main-menu patch
41/// interpolates: `"Name (records), ..."`, soonest-expiring room first.
42pub const TOURNAMENT_ROOMS_CFG: &str = "tournament_rooms";
43
44/// Whether the mod's active configuration plays online (the
45/// `tournament_online` toggle). `true` when the key is absent: the mod
46/// defaults to online and older configurations predate the toggle.
47pub fn is_online(config: &HashMap<String, Value>) -> bool {
48    config
49        .get(TOURNAMENT_ONLINE_CFG)
50        .and_then(Value::as_bool)
51        .unwrap_or(true)
52}
53
54/// The player-name field's contract: 1 to 9 ASCII letters. The name
55/// travels inside every replay `.dat` and lands in the game's high-times
56/// table, so it stays as narrow as the game's own name handling needs.
57/// Returns the trimmed name, or the reason an input was refused.
58pub fn validate_player_name(raw: &str) -> Result<String, String> {
59    let name = raw.trim();
60    if name.is_empty() {
61        return Err("Enter a player name: 1 to 9 letters (a-z, A-Z).".to_owned());
62    }
63    if !(1..=9).contains(&name.len()) {
64        return Err("The player name is limited to 9 letters (a-z, A-Z).".to_owned());
65    }
66    if !name.chars().all(|c| c.is_ascii_alphabetic()) {
67        return Err("The player name takes letters only (a-z, A-Z).".to_owned());
68    }
69    Ok(name.to_owned())
70}
71
72/// One mod's current tournament selection, read from its active
73/// configuration. `None` when no room is selected (`tournament_room_id`
74/// empty or absent).
75#[derive(Debug, Clone, PartialEq)]
76pub struct TournamentSelection {
77    /// Server-generated room id (`r_` + 12 hex). Never user-facing
78    /// : the UI shows [`Self::name`] instead.
79    pub room_id: String,
80    /// Room display name (server-side, snowboarding words when the creator
81    /// left it empty). Empty for rooms made by older servers.
82    pub name: String,
83    /// Selected environment, one of `alpine`, `forest`, `village`.
84    pub env: String,
85    /// Selected difficulty, one of `easy`, `medium`, `hard`.
86    pub difficulty: String,
87    /// When the room opens for play, epoch seconds (UTC). `0` = unknown
88    /// (an older launcher's selection), treated as already started.
89    pub starts_at: i64,
90    /// When the room closes, epoch seconds (UTC).
91    pub ends_at: i64,
92    /// Player name as the game writes it (matches the in-`.dat` name and
93    /// `Saved_Data/Arcade_Player.txt`).
94    pub player: String,
95}
96
97impl TournamentSelection {
98    /// Reads a selection out of one mod's configuration map (the values
99    /// the launcher persists, overrides merged over the mod's defaults,
100    /// exactly what the hook resolver interpolates). `None` when no room
101    /// is selected.
102    #[must_use]
103    pub fn from_configuration(config: &HashMap<String, Value>) -> Option<Self> {
104        let room_id = config
105            .get(TOURNAMENT_ROOM_ID_CFG)
106            .and_then(Value::as_str)
107            .unwrap_or_default()
108            .to_owned();
109        if room_id.is_empty() {
110            return None;
111        }
112        let string_of = |key: &str| {
113            config
114                .get(key)
115                .and_then(Value::as_str)
116                .unwrap_or_default()
117                .to_owned()
118        };
119        let epoch_of = |key: &str| config.get(key).and_then(Value::as_i64).unwrap_or(0);
120        Some(Self {
121            room_id,
122            name: string_of(TOURNAMENT_ROOM_NAME_CFG),
123            env: string_of(TOURNAMENT_ENV_CFG),
124            difficulty: string_of(TOURNAMENT_DIFFICULTY_CFG),
125            starts_at: epoch_of(TOURNAMENT_STARTS_AT_CFG),
126            ends_at: epoch_of(TOURNAMENT_ENDS_AT_CFG),
127            player: string_of(TOURNAMENT_PLAYER_CFG),
128        })
129    }
130
131    /// play gate: the game may start into this room only while it is
132    /// live, started (or of unknown start, an old selection) AND not
133    /// expired. A previously selected but expired room, and a scheduled
134    /// room that has not started yet, both keep the Play (Online) button
135    /// disabled.
136    #[must_use]
137    pub fn is_playable(&self, now: i64) -> bool {
138        (self.starts_at == 0 || self.starts_at <= now) && self.ends_at > now
139    }
140
141    /// WHY the selection is not playable right now, the play button's
142    /// tooltip. `None` when it is playable.
143    #[must_use]
144    pub fn play_blocker(&self, now: i64) -> Option<String> {
145        if self.is_playable(now) {
146            return None;
147        }
148        if self.starts_at > now {
149            Some(format!(
150                "the room starts {}",
151                format_tournament_when(self.starts_at)
152            ))
153        } else {
154            Some(format!(
155                "the room expired {}",
156                format_tournament_when(self.ends_at)
157            ))
158        }
159    }
160}
161/// Formats an epoch value as local time for UI display (`2026-10-23 18:00`).
162#[must_use]
163pub fn format_tournament_when(ends_at: i64) -> String {
164    chrono::DateTime::from_timestamp(ends_at, 0)
165        .map(|utc| {
166            utc.with_timezone(&chrono::Local)
167                .format("%Y-%m-%d %H:%M")
168                .to_string()
169        })
170        .unwrap_or_else(|| ends_at.to_string())
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use serde_json::json;
177
178    fn sample_map() -> HashMap<String, Value> {
179        HashMap::from([
180            (TOURNAMENT_ROOM_ID_CFG.to_owned(), json!("r_0123456789ab")),
181            (TOURNAMENT_ROOM_NAME_CFG.to_owned(), json!("Fresh Powder")),
182            (TOURNAMENT_ENV_CFG.to_owned(), json!("village")),
183            (TOURNAMENT_DIFFICULTY_CFG.to_owned(), json!("medium")),
184            (TOURNAMENT_STARTS_AT_CFG.to_owned(), json!(1_760_000_000i64)),
185            (TOURNAMENT_ENDS_AT_CFG.to_owned(), json!(1_760_086_400i64)),
186            (TOURNAMENT_PLAYER_CFG.to_owned(), json!("psycho sam")),
187        ])
188    }
189
190    #[test]
191    fn selection_reads_from_configuration() {
192        let selection = TournamentSelection::from_configuration(&sample_map()).unwrap();
193        assert_eq!(selection.room_id, "r_0123456789ab");
194        assert_eq!(selection.name, "Fresh Powder");
195        assert_eq!(selection.env, "village");
196        assert_eq!(selection.difficulty, "medium");
197        assert_eq!(selection.starts_at, 1_760_000_000);
198        assert_eq!(selection.ends_at, 1_760_086_400);
199        assert_eq!(selection.player, "psycho sam");
200    }
201
202    #[test]
203    fn empty_room_id_means_no_selection() {
204        let mut map = sample_map();
205        map.insert(TOURNAMENT_ROOM_ID_CFG.to_owned(), json!(""));
206        assert_eq!(
207            TournamentSelection::from_configuration(&map),
208            None,
209            "empty room id = no selection (the mod's shipped default)"
210        );
211        assert_eq!(
212            TournamentSelection::from_configuration(&HashMap::new()),
213            None
214        );
215    }
216
217    #[test]
218    fn play_gate_requires_started_and_not_expired() {
219        let selection = TournamentSelection::from_configuration(&sample_map()).unwrap();
220        let starts = 1_760_000_000;
221        let ends = 1_760_086_400;
222        assert!(!selection.is_playable(starts - 1), "not started yet");
223        assert!(selection.is_playable(starts), "started");
224        assert!(selection.is_playable(ends - 1), "last live second");
225        assert!(!selection.is_playable(ends), "expired");
226    }
227
228    #[test]
229    fn legacy_selection_without_starts_at_counts_as_started() {
230        let mut map = sample_map();
231        map.remove(TOURNAMENT_STARTS_AT_CFG);
232        let selection = TournamentSelection::from_configuration(&map).unwrap();
233        assert!(selection.is_playable(0), "starts_at 0 = already started");
234    }
235
236    #[test]
237    fn play_blocker_names_the_reason() {
238        let selection = TournamentSelection::from_configuration(&sample_map()).unwrap();
239        assert_eq!(selection.play_blocker(1_760_005_000), None);
240        assert!(
241            selection
242                .play_blocker(1_760_000_000 - 60)
243                .unwrap()
244                .contains("starts"),
245            "before the window: 'starts'"
246        );
247        assert!(
248            selection
249                .play_blocker(1_760_086_400 + 60)
250                .unwrap()
251                .contains("expired"),
252            "after the window: 'expired'"
253        );
254    }
255}