Skip to main content

launcher/pages/mods/
tournament.rs

1//! Tournament UI for the Mods page.
2//!
3//! Two halves live here:
4//!
5//! 1. **Generic `ui`-schema rendering**, every enabled mod whose `mod.json`
6//!    carries a `ui` section gets its buttons under its details panel
7//!    ([`render_mod_actions`]). A button opens its popup
8//!    ([`render_popups`]), which is either an input form built from the
9//!    authored fields or a live room list fetched from the server. Both are
10//!    driven entirely by the parsed [`ModUi`]; nothing about
11//!    `online-tournament` is hardcoded beyond the `tournament.*` action
12//!    handlers themselves (the action/config ids are internal wire names
13//!    kept stable across the "Championship → Tournament" rename,
14//!    only the player-facing strings changed).
15//!
16//! 2. **The `tournament.create` / `tournament.join` action handlers**
17//!    create or join a room over HTTP (background task,
18//!    the UI thread never blocks), then persist the selection into the
19//!    mod's active configuration via [`mods_tasks::persist_mod_configurations`]
20//!    (the same mechanism as a parameter edit). There is NO extra
21//!    selection file: `tournament_room.json` carried nothing the mod
22//!    configuration doesn't already have; the room id/name/window/player
23//!    now live where the hook resolver already interpolates from
24//!    (see `tournament_selection::TournamentSelection`).
25//!
26//! UX rules implemented here:
27//! - the player name is NEVER asked for: it is read from
28//!   `Saved_Data/Arcade_Player.txt` (the game writes it; the uploaded .dat
29//!   files carry it). Unreadable → the action is refused with that
30//!   guidance, no editable fallback field;
31//! - the create form's Starts/Ends REALLY default to now/tomorrow on every
32//!   open (the previous selection no longer prefills them);
33//! - the room list renders square card buttons with the track's
34//!   `Menu_Texture_Normal.tga` as the background (the room NAME on the
35//!   card, the rest in the tooltip) instead of full-width text rows;
36//! - hints render inside text fields (`UiField::hint`).
37//!
38//! The Play button's `Online` label is computed by
39//! [`tournament_play_selection`], called from the footer.
40
41use crate::launcher_config::AccountConfig;
42use crate::tasks::{TaskEvent, TaskHandle, spawn_task};
43use crate::tournament_selection::{
44    TOURNAMENT_ROOMS_CFG, TOURNAMENT_ENDS_AT_CFG, TOURNAMENT_ROOM_NAME_CFG,
45    TOURNAMENT_STARTS_AT_CFG, TournamentSelection, format_tournament_when, is_online,
46};
47// Trait method syntax below (`chrono::Local.from_local_datetime(..)`,
48// `.year()`/`.hour()`/…).
49use crate::utils::texture_loader::load_texture_robust;
50use chrono::{Datelike as _, TimeZone as _, Timelike as _};
51use eframe::egui;
52use egui::{Align, Color32, FontId, Layout, RichText, Sense};
53use serde_json::Value;
54use std::collections::HashMap;
55use std::io::ErrorKind;
56use supreme_fs::{GameStorage, RoomDto, RoomIdentity, RoomSummaryDto};
57use supreme_hook_config::{
58    HookConfig, ModUi, UiButton, UiField, UiFieldKind, UiPopup, UiPopupKind, load_mods_state,
59    managed_file_specs, validation,
60};
61
62use super::tasks as mods_tasks;
63use super::types::{LoadedMod, ModsPageState};
64
65/// Live placeholder in button labels, filled from the cached `room_list`
66/// (contract A).
67const ROOM_COUNT_PLACEHOLDER: &str = "{room_count}";
68
69/// Parameter ids the mod package declares for its enable_if-gated redirect
70/// rules (contract E, exactly these strings).
71const TOURNAMENT_ENV_PARAM: &str = "tournament_env";
72const TOURNAMENT_DIFFICULTY_PARAM: &str = "tournament_difficulty";
73/// the remaining selection values the mod's managed-file rules
74/// interpolate into their `remote` URLs (declarative remote-backed files,
75/// the DLL builds no URLs itself). the server URL is not among
76/// them, the mod's own `configuration` ships that default; injecting it
77/// from launcher code would make the mod non-self-contained.
78const TOURNAMENT_ROOM_ID_PARAM: &str = "tournament_room_id";
79pub(super) const TOURNAMENT_PLAYER_PARAM: &str = "tournament_player";
80
81/// the room-card background texture, the track's own menu tile,
82/// shipped with the game at
83/// `Data/Levels/<Environment>/Tracks/<Difficulty>/Menu_Texture_Normal.tga`.
84const MENU_TEXTURE_RELPATH: &str = "Data/Levels";
85const MENU_TEXTURE_FILE: &str = "Menu_Texture_Normal.tga";
86/// card size (square cards).
87const ROOM_CARD_SIZE: f32 = 148.0;
88
89/// the Create form popup's width. egui 0.35's `Window::auto_sized`
90/// opens at INFINITY clamped to the whole content rect (nearly the full
91/// app width, the "Create room popup takes all the horizontal space"
92/// bug) and never shrinks back. A fixed, form-sized width plus widgets
93/// that do NOT fill the available width (see `render_field`) gives the
94/// compact popup the form deserves.
95const CREATE_POPUP_WIDTH: f32 = 320.0;
96
97/// the Create form's widget-column width (the room-name box, the
98/// dropdowns). Deliberately NOT "fill available width", see the window
99/// sizing note in `render_form_popup`.
100const FORM_FIELD_WIDTH: f32 = 170.0;
101
102// ---------------------------------------------------------------------------
103// UI state types (stored on ModsPageState)
104// ---------------------------------------------------------------------------
105
106/// The live value of one popup field, keyed by
107/// `(mod dir, button id, field id)`, per button, per contract B.
108#[derive(Debug, Clone, PartialEq)]
109pub(super) enum PopupValue {
110    Text(String),
111    Number(f64),
112}
113
114/// The popup currently open. Keyed per (mod, button): two buttons may point
115/// at the same popup id, and each gets its own window and field values.
116#[derive(Debug, Clone)]
117pub(super) struct OpenPopup {
118    /// `dir_name` of the mod owning the button.
119    pub mod_dir: String,
120    /// `UiButton::id` that opened this popup.
121    pub button_id: String,
122    /// The popup's id in `ModUi::popups`.
123    pub popup_id: String,
124    /// The owning button's action string (e.g. `tournament.create`).
125    pub action: String,
126    /// monotonically increasing open counter, part of the egui
127    /// window id, a fresh id per open gives the window fresh geometry
128    /// state, so the popup re-opens at its authored size every time
129    /// instead of keeping the widest size it ever grew to.
130    pub open_serial: u64,
131}
132
133/// Cached `list_rooms` result, shared by `{room_count}` labels and the
134/// room_list popup (contract A: fetched on page activation and whenever a
135/// room_list popup opens).
136#[derive(Debug, Clone, Default)]
137pub(super) enum RoomCache {
138    #[default]
139    Idle,
140    Loading,
141    Ready(Vec<RoomSummaryDto>),
142    Failed,
143}
144
145impl RoomCache {
146    /// What `{room_count}` renders as right now. `-` on fetch failure per
147    /// contract A.
148    fn count_text(&self) -> String {
149        match self {
150            Self::Ready(rooms) => rooms.len().to_string(),
151            Self::Failed => "-".to_owned(),
152            Self::Idle | Self::Loading => "…".to_owned(),
153        }
154    }
155}
156
157/// What a finished rooms task produced, `list_rooms` for the cache slot,
158/// `create_room`/`join_room` for the action slot.
159#[derive(Debug)]
160pub(super) enum RoomTaskResult {
161    Rooms(Vec<RoomSummaryDto>),
162    Room(RoomDto),
163    /// A `room_remove` succeeded; the payload is the removed room's id
164    /// (the selection cleanup needs to know WHICH room went away).
165    Removed(String),
166}
167
168/// Everything needed to persist once a create/join task succeeds (the task
169/// thread must not touch UI state, so this rides along on the UI side).
170#[derive(Debug, Clone)]
171pub(super) struct PendingSelection {
172    pub mod_dir: String,
173    pub player: String,
174}
175
176/// A room removal awaiting its confirmation popup's verdict.
177#[derive(Debug, Clone)]
178pub(super) struct RemoveConfirm {
179    /// `dir_name` of the mod whose room list offered the removal (the
180    /// player identity lives in its configuration).
181    pub mod_dir: String,
182    pub room_id: String,
183    /// Display name (or `"env / difficulty"` for unnamed rooms), shown
184    /// in the confirmation copy.
185    pub name: String,
186}
187
188// ---------------------------------------------------------------------------
189// Page-level wiring (called from mods_page)
190// ---------------------------------------------------------------------------
191
192/// Drains both in-flight rooms tasks. Must run before rendering so the same
193/// frame reflects completions.
194///
195/// Returns whether a create/join **persisted**, the caller must refresh
196/// the hook resolver (same signal a parameter edit produces), so the
197/// freshly-written `tournament_env` / `tournament_difficulty` overrides
198/// are interpolated into the redirect rules on the next Play.
199pub(super) fn poll_tasks(
200    ctx: &egui::Context,
201    storage: &GameStorage,
202    state: &mut ModsPageState,
203) -> bool {
204    let mut config_changed = false;
205
206    // Room-list slot: feeds the cache only. The result is drained out
207    // from under the `list_task` borrow, the room-list persistence needs
208    // the whole `state`.
209    let mut list_result: Option<Result<RoomTaskResult, String>> = None;
210    if let Some(handle) = state.list_task.as_mut() {
211        let mut finished = false;
212        while let Ok(event) = handle.try_recv() {
213            let TaskEvent::Finished(res) = event else {
214                continue;
215            };
216            finished = true;
217            list_result = Some(res);
218        }
219        if finished {
220            state.list_task = None;
221        }
222    }
223    match list_result {
224        Some(Ok(RoomTaskResult::Rooms(rooms))) => {
225            config_changed |= persist_room_list(storage, state, &rooms);
226            state.room_cache = RoomCache::Ready(rooms);
227        }
228        Some(Ok(RoomTaskResult::Room(_))) => {} // never produced by the list slot
229        Some(Ok(RoomTaskResult::Removed(_))) => {} // never produced by the list slot
230        Some(Err(e)) => {
231            eprintln!("Tournament: failed to list rooms: {e}");
232            state.room_cache = RoomCache::Failed;
233        }
234        None => {}
235    }
236
237    // Create/join slot: on success persist the selection + overrides.
238    // The finished result is drained out from under the `action_task` borrow
239    // and applied after it ends, persistence needs the whole `state`.
240    let mut action_result: Option<Result<RoomTaskResult, String>> = None;
241    if let Some(handle) = state.action_task.as_mut() {
242        let mut finished = false;
243        while let Ok(event) = handle.try_recv() {
244            let TaskEvent::Finished(res) = event else {
245                continue;
246            };
247            finished = true;
248            action_result = Some(res);
249        }
250        if finished {
251            state.action_task = None;
252        }
253    }
254    if let Some(res) = action_result {
255        match res {
256            Ok(RoomTaskResult::Room(room)) => {
257                config_changed |= persist_selection(storage, state, &room);
258            }
259            Ok(RoomTaskResult::Removed(room_id)) => {
260                config_changed |= handle_room_removed(ctx, storage, state, &room_id);
261            }
262            Ok(RoomTaskResult::Rooms(_)) => {} // never produced by the action slot
263            Err(e) => {
264                // Server unreachable, room expired, …, surface it, never panic.
265                eprintln!("Tournament: room request failed: {e}");
266                state.tournament_error = Some(e);
267            }
268        }
269        // Consumed either way (success already used it; failure means
270        // nothing to persist).
271        state.pending_selection = None;
272    }
273
274    // Account-link + avatar slots (the logged-in player mode): a landed
275    // link writes the identity overrides, a logout drops them.
276    config_changed |= super::account::poll_tasks(ctx, storage, state);
277
278    config_changed
279}
280
281/// Detects the Mods page (re)becoming active and refreshes the room cache
282/// when any rendered content needs live room data (contract A).
283///
284/// `mods_page` runs exactly once per rendered pass while the page is
285/// visible, so a pass number that isn't exactly "last + 1" means at least
286/// one pass rendered with another page on top, i.e. we just came back.
287pub(super) fn on_page_activation(
288    ctx: &egui::Context,
289    storage: &GameStorage,
290    state: &mut ModsPageState,
291) {
292    let now = ctx.cumulative_pass_nr();
293    let activated = state.last_seen_pass.is_none_or(|last| now != last + 1);
294    state.last_seen_pass = Some(now);
295
296    if activated && needs_room_data(state) {
297        start_list_task(ctx, storage, &mut state.room_cache, &mut state.list_task);
298    }
299}
300
301/// Whether any enabled, loadable mod's `ui` references live room data: a
302/// `{room_count}` label or a `room_list` popup.
303fn needs_room_data(state: &ModsPageState) -> bool {
304    state.mods.iter().any(|m| {
305        m.enabled
306            && m.load_error.is_none()
307            && is_online(&m.configuration)
308            && m.config.ui.as_ref().is_some_and(|ui_schema| {
309                ui_schema
310                    .buttons
311                    .iter()
312                    .any(|b| b.label.contains(ROOM_COUNT_PLACEHOLDER))
313                    || ui_schema
314                        .popups
315                        .values()
316                        .any(|p| p.kind == UiPopupKind::RoomList)
317            })
318    })
319}
320
321/// Spawns `list_rooms` on a background thread (the established
322/// `spawn_task` mechanism; egui repaints on completion because
323/// `TaskSender::send` calls `ctx.request_repaint()`).
324fn start_list_task(
325    ctx: &egui::Context,
326    storage: &GameStorage,
327    room_cache: &mut RoomCache,
328    list_task: &mut Option<TaskHandle<RoomTaskResult>>,
329) {
330    if list_task.is_some() {
331        return; // one fetch at a time; the cache updates when it lands
332    }
333    *room_cache = RoomCache::Loading;
334    let storage = storage.clone();
335    *list_task = Some(spawn_task(ctx.clone(), move |_tx| {
336        storage
337            .list_rooms()
338            .map(RoomTaskResult::Rooms)
339            .map_err(|e| e.to_string())
340    }));
341}
342
343// ---------------------------------------------------------------------------
344// Buttons + selection summary (details panel)
345// ---------------------------------------------------------------------------
346
347/// Renders the `ui` buttons for one enabled mod, plus its current
348/// tournament selection summary when one exists (switching is
349/// allowed and overwrites the previous selection).
350pub(super) fn render_mod_actions(
351    ui: &mut egui::Ui,
352    storage: &GameStorage,
353    m: &LoadedMod,
354    action_in_flight: bool,
355    actions: RoomActions<'_>,
356) {
357    let RoomActions {
358        room_cache,
359        open_popup,
360        popup_values,
361        list_task,
362        popup_serial,
363        account,
364        avatar,
365        login_popup,
366    } = actions;
367    let Some(ui_schema) = m.config.ui.as_ref() else {
368        return;
369    };
370    if ui_schema.buttons.is_empty() {
371        return;
372    }
373    // The mod's own play-online toggle: when it is off the whole
374    // tournament flow hides, the game's rules go dormant and the vanilla
375    // main menu lists the rooms instead.
376    if !is_online(&m.configuration) {
377        return;
378    }
379
380    ui.add_space(6.0);
381    ui.horizontal(|ui| {
382        for button in &ui_schema.buttons {
383            let label = button
384                .label
385                .replace(ROOM_COUNT_PLACEHOLDER, &room_cache.count_text());
386            let mut response = ui.add_enabled(!action_in_flight, egui::Button::new(label));
387            if action_in_flight {
388                response = response.on_hover_text("Another tournament request is already running.");
389            }
390            if response.clicked() {
391                on_button_clicked(
392                    ui.ctx(),
393                    storage,
394                    m,
395                    button,
396                    RoomActions {
397                        room_cache: &mut *room_cache,
398                        open_popup: &mut *open_popup,
399                        popup_values: &mut *popup_values,
400                        list_task: &mut *list_task,
401                        popup_serial: &mut *popup_serial,
402                        account,
403                        avatar,
404                        login_popup: &mut *login_popup,
405                    },
406                );
407            }
408        }
409
410        // The launcher-owned account chip, after the mod's own buttons:
411        // a guest gets the Log in button (the browser link flow), a
412        // logged-in player gets the avatar + nickname, opening the
413        // account panel (Log out). It is deliberately NOT part of the
414        // mod's `ui` schema - the account belongs to the launcher, not
415        // to any single mod package.
416        render_account_chip(ui, m, account, avatar, login_popup);
417    });
418
419    // Current selection, a quiet status line read from
420    // the mod's active configuration; switching rooms simply overwrites
421    // the values. The room ID is never shown by design, the NAME
422    // is, with the planning window and a liveness note.
423    let selection = TournamentSelection::from_configuration(&m.configuration);
424    if let Some(selection) = selection {
425        ui.add_space(4.0);
426        let now = chrono::Utc::now().timestamp();
427        let label = if selection.name.is_empty() {
428            format!("{} / {}", selection.env, selection.difficulty)
429        } else {
430            selection.name.clone()
431        };
432        let window = if selection.starts_at > 0 {
433            format!(
434                "{} → {}",
435                format_tournament_when(selection.starts_at),
436                format_tournament_when(selection.ends_at),
437            )
438        } else {
439            format!("until {}", format_tournament_when(selection.ends_at))
440        };
441        let state_note = selection
442            .play_blocker(now)
443            .map(|blocker| format!(" ({blocker})"))
444            .unwrap_or_default();
445        ui.horizontal(|ui| {
446            ui.label(RichText::new("Current room:").weak());
447            ui.label(format!("{label} · {window}{state_note}"));
448        });
449    }
450}
451
452/// The account chip at the end of the action row. A guest sees **Log
453/// in** (starts the browser link flow - the popup opens in Waiting and
454/// the poll pass spawns the task); a logged-in player sees the avatar +
455/// nickname and the click opens the account panel (the identity, Log
456/// out). The avatar falls back to the 👤 glyph until it loaded.
457fn render_account_chip(
458    ui: &mut egui::Ui,
459    m: &LoadedMod,
460    account: &Option<AccountConfig>,
461    avatar: &super::account::AvatarState,
462    login_popup: &mut Option<super::account::LoginPopup>,
463) {
464    let Some(account) = account else {
465        let mut login = ui.button("\u{1F464} Log in");
466        login = login.on_hover_text(
467            "Log in with a tournament account (opens your browser). Rooms you create \n\
468             and join then carry your verified identity, and your nickname is yours alone.",
469        );
470        if login.clicked() {
471            *login_popup = Some(super::account::LoginPopup::Waiting {
472                mod_dir: m.dir_name.clone(),
473                url: None,
474            });
475        }
476        return;
477    };
478    let mut chip = match avatar {
479        super::account::AvatarState::Ready { texture, .. } => {
480            let image = egui::Image::new(texture).fit_to_exact_size(egui::vec2(18.0, 18.0));
481            ui.add(egui::Button::image_and_text(image, &account.nickname))
482        }
483        _ => ui.add(egui::Button::new(format!("\u{1F464} {}", account.nickname))),
484    };
485    chip = chip.on_hover_text(format!(
486        "Logged in as {} (account #{}). Click to open the account panel.",
487        account.account_name, account.user_id
488    ));
489    if chip.clicked() {
490        *login_popup = Some(super::account::LoginPopup::Linked);
491    }
492}
493
494/// A button was pressed: open its popup, seeding per-button default values.
495fn on_button_clicked(
496    ctx: &egui::Context,
497    storage: &GameStorage,
498    m: &LoadedMod,
499    button: &UiButton,
500    actions: RoomActions<'_>,
501) {
502    let RoomActions {
503        room_cache,
504        open_popup,
505        popup_values,
506        list_task,
507        popup_serial,
508        ..
509    } = actions;
510    let Some(ui_schema) = m.config.ui.as_ref() else {
511        return;
512    };
513    let Some(popup_id) = button.popup.as_deref() else {
514        // A popup-less tournament action has nothing to collect input
515        // from (tournament actions always open a popup), log and ignore.
516        eprintln!(
517            "Mods page: button '{}' has action '{}' but no popup to open",
518            button.id, button.action
519        );
520        return;
521    };
522    let Some(popup) = ui_schema.popups.get(popup_id) else {
523        return; // rejected by ModUi::validate, but never trust that here
524    };
525
526    // Reset this button's fields to their defaults (per button id).
527    popup_values.retain(|(dir, btn, _), _| !(dir == &m.dir_name && btn == &button.id));
528    for field in &popup.fields {
529        let default = default_field_value(m, field);
530        popup_values.insert(
531            (m.dir_name.clone(), button.id.clone(), field.id.clone()),
532            default,
533        );
534    }
535
536    // Live room data every time a room_list popup opens (contract A).
537    if popup.kind == UiPopupKind::RoomList {
538        start_list_task(ctx, storage, room_cache, list_task);
539    }
540
541    // a fresh window serial per open, the auto-sized popup
542    // re-hugs its contents instead of keeping a stale geometry.
543    *popup_serial += 1;
544    let open_serial = *popup_serial;
545
546    *open_popup = Some(OpenPopup {
547        mod_dir: m.dir_name.clone(),
548        button_id: button.id.clone(),
549        popup_id: popup_id.to_owned(),
550        action: button.action.clone(),
551        open_serial,
552    });
553}
554
555/// The default value for one authored field when a popup opens. Selects
556/// prefill from the mod's current tournament overrides (so "switching"
557/// starts from the live selection); datetimes REALLY default to the
558/// room window's natural bounds (STARTS = now, ENDS = tomorrow) on every
559/// open, the previous selection never prefills them (the label must not
560/// claim a default the form doesn't apply); text fields start empty.
561fn default_field_value(m: &LoadedMod, field: &UiField) -> PopupValue {
562    match field.kind {
563        UiFieldKind::Number => PopupValue::Number(0.0),
564        UiFieldKind::Select | UiFieldKind::Duration => {
565            let current = tournament_param_override_key(&field.id)
566                .and_then(|param| m.configuration.get(param))
567                .and_then(Value::as_str)
568                .filter(|value| field.options.iter().any(|option| option == *value))
569                .map(ToOwned::to_owned);
570            PopupValue::Text(
571                current.unwrap_or_else(|| field.options.first().cloned().unwrap_or_default()),
572            )
573        }
574        UiFieldKind::DateTime => {
575            let default_epoch = match field.id.as_str() {
576                "ends_at" => now_epoch() + 86_400,
577                _ => now_epoch(),
578            };
579            PopupValue::Text(default_epoch.to_string())
580        }
581        UiFieldKind::Text | UiFieldKind::Unknown(_) => PopupValue::Text(String::new()),
582    }
583}
584
585/// `Utc::now` as epoch seconds, the datetime fields' default anchor.
586fn now_epoch() -> i64 {
587    chrono::Utc::now().timestamp()
588}
589
590// ---------------------------------------------------------------------------
591// Popups (rendered at page level, above the panels)
592// ---------------------------------------------------------------------------
593
594/// Renders the currently open popup (if any) and the tournament error
595/// window. Called once per frame from `mods_page`. Returns whether the
596/// mod configuration changed (the account popup's Log out restores the
597/// guest player name).
598pub(super) fn render_popups(
599    ctx: &egui::Context,
600    storage: &GameStorage,
601    state: &mut ModsPageState,
602) -> bool {
603    let mut config_changed = false;
604    if let Some(open) = state.open_popup.clone() {
605        // The owning mod/button must still exist and still point at this
606        // popup, a live-reload may have swapped mod.json underneath us.
607        let popup = state
608            .mods
609            .iter()
610            .find(|m| {
611                m.dir_name == open.mod_dir
612                    && m.config.ui.as_ref().is_some_and(|ui_schema| {
613                        ui_schema.buttons.iter().any(|b| {
614                            b.id == open.button_id
615                                && b.popup.as_deref() == Some(open.popup_id.as_str())
616                        })
617                    })
618            })
619            .and_then(|m| m.config.ui.as_ref())
620            .and_then(|ui_schema| ui_schema.popups.get(&open.popup_id))
621            .cloned();
622
623        match popup {
624            Some(popup) => match popup.kind {
625                UiPopupKind::Form => render_form_popup(ctx, storage, state, &open, &popup),
626                UiPopupKind::RoomList => render_room_list_popup(ctx, storage, state, &open, &popup),
627                UiPopupKind::Unknown(_) => state.open_popup = None,
628            },
629            None => state.open_popup = None,
630        }
631    }
632
633    if let Some(error) = state.tournament_error.clone() {
634        let mut is_open = true;
635        egui::Window::new("Tournament")
636            .id(egui::Id::new("mods_tournament_error"))
637            .open(&mut is_open)
638            .collapsible(false)
639            .resizable(false)
640            .show(ctx, |ui| {
641                ui.colored_label(ui.visuals().error_fg_color, &error);
642                ui.add_space(4.0);
643                ui.vertical_centered(|ui| {
644                    if ui.button("Close").clicked() {
645                        state.tournament_error = None;
646                    }
647                });
648            });
649        if !is_open {
650            state.tournament_error = None;
651        }
652    }
653
654    if let Some(remove) = state.remove_confirm.clone() {
655        render_remove_confirm_popup(ctx, storage, state, &remove);
656    }
657
658    // The account popup (Waiting / Linked / Failed), above the rest like
659    // the other tournament popups.
660    config_changed |= super::account::render_login_popup(ctx, storage, state);
661    config_changed
662}
663
664/// The removal confirmation: names the room, states the effect, and only
665/// the explicit Remove click starts the request (the server deletes the
666/// room's files; an accidental click must never do that).
667fn render_remove_confirm_popup(
668    ctx: &egui::Context,
669    storage: &GameStorage,
670    state: &mut ModsPageState,
671    remove: &RemoveConfirm,
672) {
673    let mut is_open = true;
674    let mut confirmed = false;
675    // The Cancel button can't touch `is_open` directly (the Window
676    // builder holds that borrow until `show` returns), it sets this flag
677    // instead, the same shape as the form popup.
678    let mut cancelled = false;
679    egui::Window::new("Remove room")
680        .id(egui::Id::new((
681            "mods_tournament_remove_confirm",
682            &remove.room_id,
683        )))
684        .open(&mut is_open)
685        .collapsible(false)
686        .resizable(false)
687        .show(ctx, |ui| {
688            ui.label(format!("Remove \"{}\" from the server?", remove.name));
689            ui.add_space(4.0);
690            ui.label(
691                "The room is deleted for everyone and cannot be restored. \
692                 Only you (the creator) see this because nobody has joined \
693                 and the room has not started yet.",
694            );
695            ui.add_space(6.0);
696            ui.horizontal(|ui| {
697                let remove_button = ui
698                    .button(RichText::new("Remove").color(ui.visuals().error_fg_color));
699                if remove_button.clicked() {
700                    confirmed = true;
701                }
702                if ui.button("Cancel").clicked() {
703                    cancelled = true;
704                }
705            });
706        });
707    if confirmed {
708        state.remove_confirm = None;
709        start_remove_task(ctx, storage, state, remove);
710    } else if cancelled || !is_open {
711        state.remove_confirm = None;
712    }
713}
714
715fn render_form_popup(
716    ctx: &egui::Context,
717    storage: &GameStorage,
718    state: &mut ModsPageState,
719    open: &OpenPopup,
720    popup: &UiPopup,
721) {
722    let mut is_open = true;
723    let mut confirmed = false;
724    // The Cancel button can't touch `is_open` directly, the Window builder
725    // holds that borrow until `show` returns, so it sets this flag instead.
726    let mut cancelled = false;
727
728    // Every authored field's current validity, computed BEFORE the window
729    // renders so the confirm button can be disabled with the reason on
730    // hover while anything is invalid (the user sees why, not just that).
731    let field_value = |field_id: &str| -> Option<String> {
732        state
733            .popup_values
734            .get(&(
735                open.mod_dir.clone(),
736                open.button_id.clone(),
737                field_id.to_owned(),
738            ))
739            .and_then(as_text)
740            .cloned()
741    };
742    let confirm_blockers = confirm_blockers(popup, field_value);
743
744    egui::Window::new(popup.title.as_str())
745        .id(egui::Id::new((
746            "mods_tournament_popup",
747            &open.mod_dir,
748            &open.button_id,
749            open.open_serial,
750        )))
751        .open(&mut is_open)
752        .collapsible(false)
753        // NOT `auto_sized`, in egui 0.35 that opens the window at
754        // INFINITY clamped to the app's content rect (nearly the full
755        // app width) and the window never shrinks afterwards. A fixed
756        // form-sized width does the hugging instead; the widgets below do
757        // not fill the available width, so nothing pushes it wider. The
758        // fresh per-open serial in the id still gives every open clean
759        // geometry state.
760        .resizable(false)
761        .default_width(CREATE_POPUP_WIDTH)
762        .show(ctx, |ui| {
763            ui.add_space(2.0);
764            egui::Grid::new("tournament_form_fields")
765                .num_columns(2)
766                .spacing([12.0, 8.0])
767                .min_col_width(90.0)
768                .show(ui, |ui| {
769                    for field in &popup.fields {
770                        ui.label(&field.label);
771                        render_field(ui, open, field, &mut state.popup_values);
772                        ui.end_row();
773                    }
774                });
775            ui.add_space(6.0);
776            ui.separator();
777            ui.horizontal(|ui| {
778                let confirm = ui.add_enabled(
779                    state.action_task.is_none() && confirm_blockers.is_empty(),
780                    egui::Button::new(confirm_label(&open.action)),
781                );
782                if confirm.clicked() {
783                    confirmed = true;
784                }
785                if let Some(reason) = confirm_blockers.first() {
786                    // Disabled with the WHY on hover: the fix is in the
787                    // field, not in a hidden rule.
788                    confirm.on_disabled_hover_text(reason);
789                } else if state.action_task.is_some() {
790                    confirm
791                        .on_disabled_hover_text("Another tournament request is already running.");
792                }
793                if ui.button("Cancel").clicked() {
794                    cancelled = true;
795                }
796            });
797        });
798
799    if confirmed {
800        handle_form_confirm(ctx, storage, state, open);
801        is_open = false; // the action fired, close the form either way
802    }
803    if cancelled {
804        is_open = false;
805    }
806    if !is_open {
807        state.open_popup = None;
808    }
809}
810
811fn render_room_list_popup(
812    ctx: &egui::Context,
813    storage: &GameStorage,
814    state: &mut ModsPageState,
815    open: &OpenPopup,
816    popup: &UiPopup,
817) {
818    let mut is_open = true;
819
820    egui::Window::new(popup.title.as_str())
821        .id(egui::Id::new((
822            "mods_tournament_popup",
823            &open.mod_dir,
824            &open.button_id,
825        )))
826        .open(&mut is_open)
827        .collapsible(false)
828        .resizable(true)
829        .default_width(560.0)
830        .default_height(430.0)
831        .show(ctx, |ui| {
832            ui.separator();
833
834            // Authored fields (none in the room_list popups, but render
835            // them generically if a mod adds some). The player identity
836            // is NOT asked here: it is the mod page's visible
837            // `tournament_player` parameter (prefilled from
838            // Saved_Data/Arcade_Player.txt once); a join without one is
839            // refused at the card click and highlights that parameter.
840            if !popup.fields.is_empty() {
841                egui::Grid::new("tournament_room_list_fields")
842                    .num_columns(2)
843                    .spacing([12.0, 8.0])
844                    .min_col_width(90.0)
845                    .show(ui, |ui| {
846                        for field in &popup.fields {
847                            ui.label(&field.label);
848                            render_field(ui, open, field, &mut state.popup_values);
849                            ui.end_row();
850                        }
851                    });
852                ui.separator();
853            }
854
855            ui.horizontal(|ui| {
856                ui.strong("Open rooms");
857                ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
858                    let refresh =
859                        ui.add_enabled(state.list_task.is_none(), egui::Button::new("🔄 Refresh"));
860                    if refresh.clicked() {
861                        start_list_task(
862                            ctx,
863                            storage,
864                            &mut state.room_cache,
865                            &mut state.list_task,
866                        );
867                    }
868                    refresh.on_hover_text("Reload the list of open rooms from the server.");
869                    if state.list_task.is_some() {
870                        ui.spinner();
871                    }
872                });
873            });
874            ui.separator();
875
876            // Clone the room list out of the cache first, card clicks
877            // mutate state (start the join), which can't borrow it.
878            enum RoomListBody {
879                Loading,
880                Failed,
881                Empty,
882                Rooms(Vec<RoomSummaryDto>),
883            }
884            let body = match &state.room_cache {
885                RoomCache::Idle | RoomCache::Loading => RoomListBody::Loading,
886                RoomCache::Failed => RoomListBody::Failed,
887                RoomCache::Ready(rooms) => {
888                    if rooms.is_empty() {
889                        RoomListBody::Empty
890                    } else {
891                        RoomListBody::Rooms(rooms.clone())
892                    }
893                }
894            };
895
896            match body {
897                RoomListBody::Loading => {
898                    ui.horizontal(|ui| {
899                        ui.spinner();
900                        ui.label("Loading rooms…");
901                    });
902                }
903                RoomListBody::Failed => {
904                    ui.colored_label(
905                        ui.visuals().error_fg_color,
906                        "Couldn't load rooms. Is the server reachable? Try Refresh.",
907                    );
908                }
909                RoomListBody::Empty => {
910                    ui.weak("No open rooms right now. Create one with the other button, or hit Refresh.");
911                }
912                RoomListBody::Rooms(rooms) => {
913                    egui::ScrollArea::vertical().show(ui, |ui| {
914                        let now = chrono::Utc::now().timestamp();
915                        // square card buttons instead of the old
916                        // full-width text rows (name + track + difficulty
917                        // + host + players + records + end crammed into a
918                        // single label). One card per room, the track's
919                        // menu texture as the background, the name on the
920                        // card, the details in the tooltip.
921                        ui.horizontal_wrapped(|ui| {
922                            ui.add_space(2.0);
923                            for room in &rooms {
924                                render_room_card(ui, ctx, storage, state, open, room, now);
925                            }
926                        });
927                    });
928                }
929            }
930        });
931
932    if !is_open {
933        state.open_popup = None;
934    }
935}
936
937/// Refined one room as a square card button, fully
938/// self-contained, no content below the tile. The background is the
939/// track's `Menu_Texture_Normal.tga` (fallback: an environment-tinted
940/// panel); the room NAME sits on a darkened band for legibility with the
941/// "N players" count right beneath it; the top-left carries a rough
942/// countdown chip (the biggest remaining denomination only: months,
943/// weeks, days, hours, minutes) and the top-right the Results icon that
944/// opens the room's standings page in the browser. The rest (track,
945/// host, planning window) stays in the tooltip. A room that has not
946/// started (or has expired) renders dimmed and inert, with the reason in
947/// the tooltip.
948fn render_room_card(
949    ui: &mut egui::Ui,
950    ctx: &egui::Context,
951    storage: &GameStorage,
952    state: &mut ModsPageState,
953    open: &OpenPopup,
954    room: &RoomSummaryDto,
955    now: i64,
956) {
957    let live = room.is_live(now);
958    let name = if room.name.is_empty() {
959        format!("{} / {}", room.env, room.difficulty)
960    } else {
961        room.name.clone()
962    };
963
964    ui.vertical(|ui| {
965        let (rect, response) = ui.allocate_exact_size(
966            egui::vec2(ROOM_CARD_SIZE, ROOM_CARD_SIZE),
967            if live { Sense::click() } else { Sense::hover() },
968        );
969        // Background: the track's menu texture (cached per env/difficulty).
970        let texture = room_card_texture(ctx, storage, state, &room.env, &room.difficulty);
971        match &texture {
972            Some(texture) => {
973                let tint = if live {
974                    Color32::WHITE
975                } else {
976                    Color32::from_gray(105)
977                };
978                ui.painter().image(
979                    texture.id(),
980                    rect,
981                    egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
982                    tint,
983                );
984            }
985            None => {
986                let base = env_card_color(&room.env);
987                let base = if live {
988                    base
989                } else {
990                    base.gamma_multiply(0.55)
991                };
992                ui.painter().rect_filled(rect, 6.0, base);
993            }
994        }
995
996        // Top-left: the rough countdown chip (clock + biggest remaining
997        // denomination). Live rooms count down to their end; scheduled
998        // rooms to their start; expired rooms show nothing.
999        let countdown_target = if room.starts_at > now {
1000            Some(room.starts_at)
1001        } else if live {
1002            Some(room.ends_at)
1003        } else {
1004            None
1005        };
1006        if let Some(target) = countdown_target {
1007            paint_chip(
1008                ui,
1009                egui::pos2(rect.left() + 5.0, rect.top() + 5.0),
1010                format!("\u{1F550} {}", rough_countdown(target - now)),
1011                FontId::proportional(12.5),
1012            );
1013        }
1014
1015        // Top-right: the Results icon, the room's standings page (ranking
1016        // + stats) in the browser. The URL is the SERVER's (it knows how
1017        // it is exposed to browsers); when the server does not publish
1018        // one, no icon renders, the launcher never assembles the URL
1019        // itself. Registered after the card response so clicks inside the
1020        // icon go to the icon, not the card.
1021        if let Some(results_url) = &room.results_url {
1022            let icon_size = 26.0;
1023            let icon_rect = egui::Rect::from_min_size(
1024                egui::pos2(rect.right() - icon_size - 5.0, rect.top() + 5.0),
1025                egui::vec2(icon_size, icon_size),
1026            );
1027            let icon_response = ui.interact(
1028                icon_rect,
1029                egui::Id::new(("tournament_room_card_results", &room.id)),
1030                Sense::click(),
1031            );
1032            ui.painter().rect_filled(
1033                icon_rect,
1034                6.0,
1035                if icon_response.hovered() {
1036                    Color32::from_black_alpha(215)
1037                } else {
1038                    Color32::from_black_alpha(150)
1039                },
1040            );
1041            paint_galley_centered(
1042                ui,
1043                icon_rect.center(),
1044                "\u{1F4CA}",
1045                FontId::proportional(14.0),
1046            );
1047            if icon_response.clicked() {
1048                if let Err(e) = open::that(results_url) {
1049                    eprintln!("Tournament: could not open browser for {results_url}: {e}");
1050                    state.tournament_error = Some(format!("Could not open the browser: {e}"));
1051                }
1052            }
1053            icon_response
1054                .on_hover_text("Open this room's results page (ranking and stats) in your browser");
1055        }
1056
1057        // Below the Results icon: the Remove chip, offered ONLY on the
1058        // player's own un-started rooms nobody else joined (the server
1059        // re-checks all three guards; this gate only decides whether the
1060        // affordance exists). Opens the confirmation popup, never deletes
1061        // directly. A logged-in account also owns rooms carrying its
1062        // user id as the owner.
1063        let account_user_id = state.account.as_ref().map(|account| account.user_id);
1064        if let Some(player) = tournament_player_of(state, &open.mod_dir)
1065            && room_can_be_removed(room, &player, account_user_id, now)
1066        {
1067            let icon_size = 26.0;
1068            let icon_rect = egui::Rect::from_min_size(
1069                egui::pos2(rect.right() - icon_size - 5.0, rect.top() + icon_size + 9.0),
1070                egui::vec2(icon_size, icon_size),
1071            );
1072            let icon_response = ui.interact(
1073                icon_rect,
1074                egui::Id::new(("tournament_room_card_remove", &room.id)),
1075                Sense::click(),
1076            );
1077            ui.painter().rect_filled(
1078                icon_rect,
1079                6.0,
1080                if icon_response.hovered() {
1081                    Color32::from_black_alpha(215)
1082                } else {
1083                    Color32::from_black_alpha(150)
1084                },
1085            );
1086            paint_galley_centered(
1087                ui,
1088                icon_rect.center(),
1089                "\u{1F5D1}",
1090                FontId::proportional(14.0),
1091            );
1092            if icon_response.clicked() {
1093                state.remove_confirm = Some(RemoveConfirm {
1094                    mod_dir: open.mod_dir.clone(),
1095                    room_id: room.id.clone(),
1096                    name: name.clone(),
1097                });
1098            }
1099            icon_response.on_hover_text(
1100                "Remove this room (you created it, nobody has joined, it has not started)",
1101            );
1102        }
1103
1104        // Name band: darken the lower third so the text always reads; the
1105        // room NAME with the "N players" count right beneath it.
1106        let players_text = format!(
1107            "{} player{}",
1108            room.player_count,
1109            if room.player_count == 1 { "" } else { "s" }
1110        );
1111        let name_galley = ui.painter().layout(
1112            name.clone(),
1113            FontId::proportional(15.0),
1114            Color32::WHITE,
1115            ROOM_CARD_SIZE - 12.0,
1116        );
1117        let players_galley = ui.painter().layout(
1118            players_text,
1119            FontId::proportional(11.5),
1120            Color32::from_gray(215),
1121            ROOM_CARD_SIZE - 12.0,
1122        );
1123        let band_height = name_galley.size().y + players_galley.size().y + 11.0;
1124        let band = egui::Rect::from_min_max(
1125            egui::pos2(rect.left(), rect.bottom() - band_height),
1126            egui::pos2(rect.right(), rect.bottom()),
1127        );
1128        ui.painter()
1129            .rect_filled(band, 0.0, Color32::from_black_alpha(150));
1130        let name_pos = egui::pos2(
1131            rect.left() + 6.0,
1132            band.bottom() - players_galley.size().y - name_galley.size().y - 6.0,
1133        );
1134        ui.painter().galley(name_pos, name_galley, Color32::WHITE);
1135        let players_pos = egui::pos2(
1136            rect.left() + 6.0,
1137            band.bottom() - players_galley.size().y - 4.0,
1138        );
1139        ui.painter()
1140            .galley(players_pos, players_galley, Color32::from_gray(215));
1141
1142        let when = if room.starts_at > now {
1143            format!("starts {}", format_tournament_when(room.starts_at))
1144        } else if live {
1145            format!("ends {}", format_tournament_when(room.ends_at))
1146        } else {
1147            format!("ended {}", format_tournament_when(room.ends_at))
1148        };
1149        let hint = if live {
1150            "Click to join this room as your player"
1151        } else if room.starts_at > now {
1152            "Not started yet; join when it goes live"
1153        } else {
1154            "This room has expired"
1155        };
1156        let tooltip = format!(
1157            "{name}\n{} / {} · host {}\n{} players\n{when}\n\n{hint}",
1158            room.env, room.difficulty, room.host, room.player_count,
1159        );
1160        if live && response.clicked() {
1161            handle_room_selected(ctx, storage, state, open, &room.id);
1162        }
1163        response.on_hover_text(tooltip);
1164    });
1165    ui.add_space(4.0);
1166}
1167
1168/// paints a small dark chip with `text` at `top_left` and returns
1169/// nothing, used for the card's countdown chip (clock emoji + unit).
1170fn paint_chip(ui: &mut egui::Ui, top_left: egui::Pos2, text: String, font: FontId) {
1171    let galley = ui
1172        .painter()
1173        .layout(text, font, Color32::WHITE, f32::INFINITY);
1174    let chip = egui::Rect::from_min_size(
1175        top_left,
1176        egui::vec2(galley.size().x + 10.0, galley.size().y + 6.0),
1177    );
1178    ui.painter()
1179        .rect_filled(chip, 4.0, Color32::from_black_alpha(150));
1180    ui.painter().galley(
1181        egui::pos2(chip.left() + 5.0, chip.center().y - galley.size().y / 2.0),
1182        galley,
1183        Color32::WHITE,
1184    );
1185}
1186
1187/// one centered text glyph inside `rect` (the Results icon).
1188fn paint_galley_centered(ui: &mut egui::Ui, center: egui::Pos2, text: &str, font: FontId) {
1189    let galley = ui
1190        .painter()
1191        .layout(text.to_owned(), font, Color32::WHITE, f32::INFINITY);
1192    ui.painter().galley(
1193        egui::pos2(
1194            center.x - galley.size().x / 2.0,
1195            center.y - galley.size().y / 2.0,
1196        ),
1197        galley,
1198        Color32::WHITE,
1199    );
1200}
1201
1202/// the card countdown's rough text, the biggest remaining time
1203/// denomination ONLY (month, week, day, hour, minute), e.g. "5 weeks" or
1204/// "13 minutes". Rough by design: a denomination appears from two units
1205/// of it upwards (60 days → "2 months", 45 days → "6 weeks", 90 minutes
1206/// → "90 minutes"), everything below a minute rounds up to "1 minute".
1207fn rough_countdown(seconds: i64) -> String {
1208    let seconds = seconds.max(0);
1209    const MINUTE: i64 = 60;
1210    const HOUR: i64 = 60 * MINUTE;
1211    const DAY: i64 = 24 * HOUR;
1212    const WEEK: i64 = 7 * DAY;
1213    const MONTH: i64 = 30 * DAY;
1214    let (value, unit) = if seconds >= 2 * MONTH {
1215        (seconds / MONTH, "month")
1216    } else if seconds >= 2 * WEEK {
1217        (seconds / WEEK, "week")
1218    } else if seconds >= 2 * DAY {
1219        (seconds / DAY, "day")
1220    } else if seconds >= 2 * HOUR {
1221        (seconds / HOUR, "hour")
1222    } else {
1223        (seconds / MINUTE, "minute")
1224    };
1225    let value = value.max(1);
1226    if value == 1 {
1227        format!("1 {unit}")
1228    } else {
1229        format!("{value} {unit}s")
1230    }
1231}
1232
1233/// The card texture for one env/difficulty track: the game's
1234/// `Data/Levels/<Environment>/Tracks/<Difficulty>/Menu_Texture_Normal.tga`,
1235/// resolved case-insensitively by the shared texture loader and cached on
1236/// the page state (`None` = tried and failed → the tinted fallback card).
1237fn room_card_texture(
1238    ctx: &egui::Context,
1239    storage: &GameStorage,
1240    state: &mut ModsPageState,
1241    env: &str,
1242    difficulty: &str,
1243) -> Option<egui::TextureHandle> {
1244    let key = format!("{env}/{difficulty}");
1245    if let Some(cached) = state.room_card_textures.get(&key) {
1246        return cached.clone();
1247    }
1248    let loaded = load_room_menu_texture(ctx, storage, env, difficulty);
1249    state.room_card_textures.insert(key, loaded.clone());
1250    loaded
1251}
1252
1253fn load_room_menu_texture(
1254    ctx: &egui::Context,
1255    storage: &GameStorage,
1256    env: &str,
1257    difficulty: &str,
1258) -> Option<egui::TextureHandle> {
1259    let base = storage.game_path().join(MENU_TEXTURE_RELPATH);
1260    // The game ships capitalized level/difficulty directories; try the
1261    // wire's lowercase first (Windows is case-insensitive either way),
1262    // then the capitalized spelling for case-sensitive filesystems.
1263    for (env_dir, diff_dir) in [
1264        (env.to_owned(), difficulty.to_owned()),
1265        (capitalize(env), capitalize(difficulty)),
1266    ] {
1267        let path = base
1268            .join(&env_dir)
1269            .join("Tracks")
1270            .join(&diff_dir)
1271            .join(MENU_TEXTURE_FILE);
1272        if let Ok(texture) =
1273            load_texture_robust(ctx, &path, &format!("room_card_{env_dir}_{diff_dir}"))
1274        {
1275            return Some(texture);
1276        }
1277    }
1278    eprintln!("Tournament: no menu texture for {env}/{difficulty}; using the fallback card");
1279    None
1280}
1281
1282fn capitalize(word: &str) -> String {
1283    let mut chars = word.chars();
1284    match chars.next() {
1285        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1286        None => String::new(),
1287    }
1288}
1289
1290/// Fallback card color when a track's menu texture is unavailable.
1291fn env_card_color(env: &str) -> Color32 {
1292    match env {
1293        "alpine" => Color32::from_rgb(74, 111, 165),  // ice blue
1294        "forest" => Color32::from_rgb(62, 112, 77),   // pine green
1295        "village" => Color32::from_rgb(126, 100, 84), // warm wood
1296        other => {
1297            // Deterministic tint from the name so unknown envs still differ.
1298            let hash: u32 = other.bytes().map(u32::from).sum::<u32>() % 251;
1299            Color32::from_rgb(
1300                (70 + hash * 13 % 60) as u8,
1301                (90 + hash * 29 % 50) as u8,
1302                120,
1303            )
1304        }
1305    }
1306}
1307
1308/// Renders one authored (or synthetic) field into the shared value map.
1309fn render_field(
1310    ui: &mut egui::Ui,
1311    open: &OpenPopup,
1312    field: &UiField,
1313    values: &mut HashMap<(String, String, String), PopupValue>,
1314) {
1315    let key = (
1316        open.mod_dir.clone(),
1317        open.button_id.clone(),
1318        field.id.clone(),
1319    );
1320    match &field.kind {
1321        UiFieldKind::Select | UiFieldKind::Duration => {
1322            let mut current = values
1323                .get(&key)
1324                .and_then(as_text)
1325                .map(String::as_str)
1326                .unwrap_or("")
1327                .to_owned();
1328            // fixed width, a ComboBox sizes to its selected text
1329            // (fine), but an unfixed one still participates in the grid's
1330            // available-width stretch; the explicit width keeps the form
1331            // column at its authored size.
1332            egui::ComboBox::from_id_salt((
1333                "mods_tournament_field",
1334                &open.mod_dir,
1335                &open.button_id,
1336                &field.id,
1337            ))
1338            .width(FORM_FIELD_WIDTH)
1339            .selected_text(if current.is_empty() {
1340                "(None)"
1341            } else {
1342                current.as_str()
1343            })
1344            .show_ui(ui, |ui| {
1345                for option in &field.options {
1346                    if ui.selectable_label(current == *option, option).clicked() {
1347                        values.insert(key.clone(), PopupValue::Text(option.clone()));
1348                        current = option.clone();
1349                    }
1350                }
1351            });
1352        }
1353        UiFieldKind::Text => {
1354            let mut value = values
1355                .get(&key)
1356                .and_then(as_text)
1357                .cloned()
1358                .unwrap_or_default();
1359            // Snapshot for the live validation below: the TextEdit holds
1360            // the value mutably for the whole frame (immediate mode), so
1361            // the check reads the pre-frame value; the edited value is
1362            // stored right away and the reason catches up next frame.
1363            let current = value.clone();
1364            let mut edit = egui::TextEdit::singleline(&mut value);
1365            // fixed width, the default TextEdit FILLS the
1366            // available width, which is exactly what pushed the old
1367            // auto-sized window to the app's full width (and what would
1368            // pin any window at its default width forever, since egui
1369            // windows only grow). A authored-size box lets the window hug
1370            // the form.
1371            edit = edit.desired_width(FORM_FIELD_WIDTH);
1372            // the mod's hint renders INSIDE the box (e.g. the
1373            // room name's "(optional)"), not as label padding.
1374            if let Some(hint) = &field.hint {
1375                edit = edit.hint_text(hint);
1376            }
1377            // Live validation while the user types: an invalid value
1378            // gets an error stroke around the box and a warning icon
1379            // whose hover carries the reason. The confirm button is
1380            // disabled by the same rule, the reason is never a secret.
1381            let reason = field
1382                .validation
1383                .as_deref()
1384                .and_then(|tag| validation::validate_value(tag, &current).err());
1385            let mut edited = false;
1386            ui.horizontal(|ui| {
1387                let response = ui.add(edit);
1388                edited = response.changed();
1389                if let Some(reason) = &reason {
1390                    ui.painter().rect_stroke(
1391                        response.rect,
1392                        2.0,
1393                        egui::Stroke::new(1.4, ui.visuals().error_fg_color),
1394                        egui::StrokeKind::Inside,
1395                    );
1396                    ui.label(RichText::new("\u{26A0}").color(ui.visuals().error_fg_color))
1397                        .on_hover_text(reason);
1398                }
1399            });
1400            if edited {
1401                values.insert(key.clone(), PopupValue::Text(value));
1402            }
1403        }
1404        UiFieldKind::Number => {
1405            let mut value = values.get(&key).and_then(as_number).copied().unwrap_or(0.0);
1406            if ui
1407                .add(egui::DragValue::new(&mut value).speed(1.0))
1408                .changed()
1409            {
1410                values.insert(key.clone(), PopupValue::Number(value));
1411            }
1412        }
1413        UiFieldKind::DateTime => {
1414            // date + time pickers from the existing egui widgets,
1415            // egui_extras' DatePickerButton for the calendar, hour/minute
1416            // DragValues for the time-of-day. The stored value is a UTC
1417            // epoch-seconds string; what the user picks is LOCAL time.
1418            let epoch = values
1419                .get(&key)
1420                .and_then(as_text)
1421                .and_then(|text| text.parse::<i64>().ok())
1422                .unwrap_or_else(now_epoch);
1423            let local = chrono::DateTime::from_timestamp(epoch, 0)
1424                .map(|utc| utc.with_timezone(&chrono::Local))
1425                .unwrap_or_else(chrono::Local::now);
1426            let (mut hour, mut minute) = (local.hour() as i32, local.minute() as i32);
1427            // egui_extras' DatePickerButton is jiff-based (its `selection`
1428            // is a `jiff::civil::Date`), bridge chrono→jiff for the
1429            // picker and jiff→chrono for the epoch math.
1430            let mut jdate =
1431                jiff::civil::date(local.year() as i16, local.month() as i8, local.day() as i8);
1432            ui.horizontal(|ui| {
1433                let id_salt = format!(
1434                    "mods_tournament_datepicker/{}/{}/{}",
1435                    open.mod_dir, open.button_id, field.id
1436                );
1437                // The button's label comes from `.format(...)`, the
1438                // jiff strftime of the picked date itself.
1439                ui.add(
1440                    egui_extras::DatePickerButton::new(&mut jdate)
1441                        .id_salt(&id_salt)
1442                        .format("%Y-%m-%d (%a)"),
1443                );
1444                ui.add(
1445                    egui::DragValue::new(&mut hour)
1446                        .range(0..=23)
1447                        .suffix("h")
1448                        .update_while_editing(false),
1449                );
1450                ui.add(
1451                    egui::DragValue::new(&mut minute)
1452                        .range(0..=59)
1453                        .suffix("m")
1454                        .update_while_editing(false),
1455                );
1456            });
1457            let picked = chrono::NaiveDate::from_ymd_opt(
1458                i32::from(jdate.year()),
1459                u32::from(jdate.month() as u8),
1460                u32::from(jdate.day() as u8),
1461            )
1462            .and_then(|date| {
1463                date.and_hms_opt(hour.clamp(0, 23) as u32, minute.clamp(0, 59) as u32, 0)
1464            })
1465            .and_then(|naive| chrono::Local.from_local_datetime(&naive).single())
1466            .map(|local| local.timestamp())
1467            .unwrap_or(epoch);
1468            values.insert(key.clone(), PopupValue::Text(picked.to_string()));
1469        }
1470        UiFieldKind::Unknown(kind) => {
1471            // Rejected by ModUi::validate long before this point; shown
1472            // inertly rather than swallowed, just in case.
1473            ui.label(RichText::new(format!("unsupported field kind `{kind}`")).weak());
1474        }
1475    }
1476}
1477
1478fn as_text(value: &PopupValue) -> Option<&String> {
1479    match value {
1480        PopupValue::Text(text) => Some(text),
1481        PopupValue::Number(_) => None,
1482    }
1483}
1484
1485fn as_number(value: &PopupValue) -> Option<&f64> {
1486    match value {
1487        PopupValue::Text(_) => None,
1488        PopupValue::Number(number) => Some(number),
1489    }
1490}
1491
1492// ---------------------------------------------------------------------------
1493// Action handling (tournament.create / tournament.join)
1494// ---------------------------------------------------------------------------
1495
1496fn is_tournament_action(action: &str) -> bool {
1497    action.starts_with("tournament.")
1498}
1499
1500fn confirm_label(action: &str) -> &'static str {
1501    match action {
1502        "tournament.create" => "Create",
1503        "tournament.join" => "Join",
1504        _ => "OK",
1505    }
1506}
1507
1508/// The reasons a form popup's Confirm button must stay disabled: every
1509/// authored field whose validation tag refuses its current value, plus
1510/// the planning-window cross-field rule (END after STARTS). Empty =
1511/// the form is confirmable. Pure so the gating copy can be unit tested
1512/// without a live egui context; the render path feeds it the popup's
1513/// live values, the reasons surface on the disabled button's hover.
1514fn confirm_blockers(popup: &UiPopup, value_of: impl Fn(&str) -> Option<String>) -> Vec<String> {
1515    let mut blockers: Vec<String> = Vec::new();
1516    for field in &popup.fields {
1517        let Some(tag) = &field.validation else {
1518            continue;
1519        };
1520        let value = value_of(&field.id).unwrap_or_default();
1521        if let Err(reason) = validation::validate_value(tag, &value) {
1522            blockers.push(format!("{}: {reason}", field.label));
1523        }
1524    }
1525    // Cross-field: the planning window must END after it STARTS (the
1526    // datetime defaults are now/tomorrow, so a fresh form never trips).
1527    if popup.fields.iter().any(|f| f.id == "starts_at")
1528        && popup.fields.iter().any(|f| f.id == "ends_at")
1529    {
1530        let parse_epoch =
1531            |field_id: &str| value_of(field_id).and_then(|text| text.trim().parse::<i64>().ok());
1532        if let (Some(starts_at), Some(ends_at)) = (parse_epoch("starts_at"), parse_epoch("ends_at"))
1533            && ends_at <= starts_at
1534        {
1535            blockers.push(
1536                "The room must END after it STARTS. Adjust the date/time pickers.".to_owned(),
1537            );
1538        }
1539    }
1540    blockers
1541}
1542
1543/// The player identity a create/join runs as, read from the mod's
1544/// `tournament_player` parameter (the mod page's visible, live-validated
1545/// control). Refuses with the reason when the value is missing or
1546/// invalid, and highlights the parameter on the mod page
1547/// ([`ModsPageState::tournament_param_error`]) so the fix happens where
1548/// the value lives.
1549fn player_for_action(state: &mut ModsPageState, mod_dir: &str) -> Result<String, String> {
1550    let raw = state
1551        .mods
1552        .iter()
1553        .find(|m| m.dir_name == mod_dir)
1554        .and_then(|m| m.configuration.get(TOURNAMENT_PLAYER_PARAM))
1555        .and_then(Value::as_str)
1556        .unwrap_or_default();
1557    match validation::validate_player_name(raw) {
1558        Ok(name) => {
1559            // A valid value clears any stale highlight from a previous
1560            // refusal.
1561            if let Some((dir, param)) = &state.tournament_param_error
1562                && dir == mod_dir
1563                && param == TOURNAMENT_PLAYER_PARAM
1564            {
1565                state.tournament_param_error = None;
1566            }
1567            Ok(name)
1568        }
1569        Err(reason) => {
1570            state.tournament_param_error =
1571                Some((mod_dir.to_owned(), TOURNAMENT_PLAYER_PARAM.to_owned()));
1572            Err(reason)
1573        }
1574    }
1575}
1576
1577/// One popup field's current text value, keyed (mod dir, button id,
1578/// field id). A free function, not a closure, so call sites that also
1579/// need `&mut` access to the page state (the player highlight) never
1580/// hold the immutable borrow across it.
1581fn popup_field_value(state: &ModsPageState, open: &OpenPopup, field_id: &str) -> Option<String> {
1582    state
1583        .popup_values
1584        .get(&(
1585            open.mod_dir.clone(),
1586            open.button_id.clone(),
1587            field_id.to_owned(),
1588        ))
1589        .and_then(as_text)
1590        .cloned()
1591}
1592
1593/// The form's confirm button was pressed: gather values and start the
1594/// corresponding network action on a background task.
1595fn handle_form_confirm(
1596    ctx: &egui::Context,
1597    storage: &GameStorage,
1598    state: &mut ModsPageState,
1599    open: &OpenPopup,
1600) {
1601    match open.action.as_str() {
1602        "tournament.create" => {
1603            // The player identity is the mod page's visible
1604            // `tournament_player` parameter (validated live while it is
1605            // edited). Missing or invalid: the create is refused and the
1606            // parameter is highlighted, the fix happens there, not in a
1607            // popup field.
1608            let player = match player_for_action(state, &open.mod_dir) {
1609                Ok(player) => player,
1610                Err(reason) => {
1611                    state.tournament_error = Some(reason);
1612                    return;
1613                }
1614            };
1615            // Field ids per the `ui` schema (shape); unknown/missing ones fall
1616            // back to safe defaults instead of guessing at other ids.
1617            let env = popup_field_value(state, open, "env").unwrap_or_else(|| "alpine".to_owned());
1618            let difficulty =
1619                popup_field_value(state, open, "difficulty").unwrap_or_else(|| "easy".to_owned());
1620            // Optional display name, empty means the server autogenerates
1621            // a snowboarding name (and enforces its blocklist on a given
1622            // one, surfacing refusals as the error popup). The charset is
1623            // validated client-side so the refusal never surprises.
1624            let name = popup_field_value(state, open, "name")
1625                .unwrap_or_default()
1626                .trim()
1627                .to_owned();
1628            if let Err(reason) = validation::validate_room_name(&name) {
1629                state.tournament_error = Some(reason);
1630                return;
1631            }
1632            let parse_epoch = |field_id: &str, fallback: i64| -> i64 {
1633                popup_field_value(state, open, field_id)
1634                    .and_then(|text| text.trim().parse::<i64>().ok())
1635                    .unwrap_or(fallback)
1636            };
1637            let starts_at = parse_epoch("starts_at", now_epoch());
1638            let ends_at = parse_epoch("ends_at", now_epoch() + 86_400);
1639            if ends_at <= starts_at {
1640                state.tournament_error = Some(
1641                    "The room must END after it STARTS. Adjust the date/time pickers.".to_owned(),
1642                );
1643                return;
1644            }
1645            start_create_task(
1646                ctx,
1647                storage,
1648                state,
1649                CreateRoomForm {
1650                    mod_dir: open.mod_dir.clone(),
1651                    env,
1652                    difficulty,
1653                    player,
1654                    name,
1655                    starts_at,
1656                    ends_at,
1657                },
1658            );
1659        }
1660        "tournament.join" => {
1661            // Joining needs a room id, which only a room_list popup provides
1662            // (the `ui` schema wires tournament.join to a room_list popup).
1663            state.tournament_error = Some(
1664                "This button must open a `room_list` popup so a room to join can be picked."
1665                    .to_owned(),
1666            );
1667        }
1668        action if action.starts_with("tournament.") => {
1669            state.tournament_error = Some(format!("Unsupported tournament action `{action}`."));
1670        }
1671        action => {
1672            // Free-form actions are reserved for future use, not
1673            // launcher-handled; log like the rest of the app and close.
1674            eprintln!("Mods page: popup action `{action}` is not launcher-handled; nothing to do.");
1675        }
1676    }
1677}
1678
1679/// One-time prefill of the visible `tournament_player` parameter from
1680/// `Saved_Data/Arcade_Player.txt` (the game writes the file when a rider
1681/// is picked; the value must match what the game writes since it travels
1682/// inside every uploaded .dat). Only touches enabled, loadable mods that
1683/// declare the parameter VISIBLE (hidden launcher-set parameters are
1684/// written by the create/join flow, never prefilled) and whose current
1685/// value is empty. Runs once per page initialization, the override then
1686/// persists like any user edit.
1687pub(super) fn prefill_tournament_player(
1688    storage: &GameStorage,
1689    mods: &mut [LoadedMod],
1690    config_changed: &mut bool,
1691) {
1692    let Some(name) = read_player_name(storage) else {
1693        return;
1694    };
1695    for m in mods {
1696        if !m.enabled || m.load_error.is_some() {
1697            continue;
1698        }
1699        let declares_visible_param = m
1700            .config
1701            .parameters
1702            .iter()
1703            .any(|p| p.id == TOURNAMENT_PLAYER_PARAM && !p.hidden);
1704        if !declares_visible_param {
1705            continue;
1706        }
1707        let empty = m
1708            .configuration
1709            .get(TOURNAMENT_PLAYER_PARAM)
1710            .and_then(Value::as_str)
1711            .unwrap_or("")
1712            .is_empty();
1713        if empty {
1714            m.configuration.insert(
1715                TOURNAMENT_PLAYER_PARAM.to_owned(),
1716                Value::String(name.clone()),
1717            );
1718            *config_changed = true;
1719        }
1720    }
1721}
1722
1723/// Reads the player name from `<game_dir>/Saved_Data/Arcade_Player.txt`.
1724/// The game writes it quoted, strip surrounding quotes and whitespace.
1725/// This is the ONE-TIME prefill source for the mod page's visible
1726/// `tournament_player` parameter (the name travels inside every uploaded
1727/// .dat, so the value must match what the game writes). `None` when
1728/// unreadable/empty (the parameter just starts empty, the user types the
1729/// name; create/join refuse until it is valid).
1730pub(super) fn read_player_name(storage: &GameStorage) -> Option<String> {
1731    let path = storage
1732        .game_path()
1733        .join("Saved_Data")
1734        .join("Arcade_Player.txt");
1735    let raw = std::fs::read_to_string(&path).ok()?;
1736    let mut name = raw.trim();
1737    if name.len() >= 2 && name.starts_with('"') && name.ends_with('"') {
1738        name = name[1..name.len() - 1].trim();
1739    }
1740    if name.is_empty() {
1741        None
1742    } else {
1743        Some(name.to_owned())
1744    }
1745}
1746
1747/// The logged-in identity a room action carries, cloned out of the
1748/// page state: [`RoomIdentity`] borrows its key, and the borrow cannot
1749/// travel into a `'static` task closure - the owned pair stays alive
1750/// INSIDE the closure and only lends itself to the call. `None` =
1751/// guest.
1752fn account_identity_for_task(account: Option<&AccountConfig>) -> Option<(i64, String)> {
1753    account.map(|account| (account.user_id, account.auth_key.clone()))
1754}
1755
1756/// The borrowed wire view of one cloned identity pair (the pair lives
1757/// in the task closure, the identity only borrows it for the call).
1758fn borrowed_identity(pair: &(i64, String)) -> RoomIdentity<'_> {
1759    RoomIdentity {
1760        user_id: pair.0,
1761        auth_key: pair.1.as_str(),
1762    }
1763}
1764
1765fn start_create_task(
1766    ctx: &egui::Context,
1767    storage: &GameStorage,
1768    state: &mut ModsPageState,
1769    form: CreateRoomForm,
1770) {
1771    if state.action_task.is_some() {
1772        return; // double-submit guard
1773    }
1774    let identity = account_identity_for_task(state.account.as_ref());
1775    state.pending_selection = Some(PendingSelection {
1776        mod_dir: form.mod_dir,
1777        player: form.player.clone(),
1778    });
1779    let storage = storage.clone();
1780    state.action_task = Some(spawn_task(ctx.clone(), move |_tx| {
1781        let identity = identity.as_ref().map(borrowed_identity);
1782        storage
1783            .create_room(
1784                &form.env,
1785                &form.difficulty,
1786                &form.player,
1787                &form.name,
1788                form.starts_at,
1789                form.ends_at,
1790                identity,
1791            )
1792            .map(RoomTaskResult::Room)
1793            .map_err(|e| e.to_string())
1794    }));
1795}
1796/// The create-room form values, exactly as the popup collected them.
1797struct CreateRoomForm {
1798    mod_dir: String,
1799    env: String,
1800    difficulty: String,
1801    player: String,
1802    name: String,
1803    starts_at: i64,
1804    ends_at: i64,
1805}
1806
1807/// The shared mutable state the tournament action helpers pass along:
1808/// everything a button render or click may touch, grouped so the
1809/// helpers stay under the argument-count limit and the call sites
1810/// read as one unit.
1811pub(super) struct RoomActions<'a> {
1812    pub(super) room_cache: &'a mut RoomCache,
1813    pub(super) open_popup: &'a mut Option<OpenPopup>,
1814    pub(super) popup_values: &'a mut HashMap<(String, String, String), PopupValue>,
1815    pub(super) list_task: &'a mut Option<TaskHandle<RoomTaskResult>>,
1816    pub(super) popup_serial: &'a mut u64,
1817    /// The logged-in account, read-only in the action row (the account
1818    /// popup and the poll pass own the changes to it).
1819    pub(super) account: &'a Option<AccountConfig>,
1820    /// The account avatar's load state, read-only here.
1821    pub(super) avatar: &'a super::account::AvatarState,
1822    /// The account popup, opened by the account chip.
1823    pub(super) login_popup: &'a mut Option<super::account::LoginPopup>,
1824}
1825
1826/// A card in a room_list popup was selected → join it as the player the
1827/// `tournament_player` parameter carries ("selecting a card = join
1828/// action"). Missing or invalid parameter: refused here and highlighted
1829/// on the mod page.
1830fn handle_room_selected(
1831    ctx: &egui::Context,
1832    storage: &GameStorage,
1833    state: &mut ModsPageState,
1834    open: &OpenPopup,
1835    room_id: &str,
1836) {
1837    let player = match player_for_action(state, &open.mod_dir) {
1838        Ok(player) => player,
1839        Err(reason) => {
1840            state.tournament_error = Some(reason);
1841            return;
1842        }
1843    };
1844    if state.action_task.is_some() {
1845        return; // double-submit guard
1846    }
1847    let identity = account_identity_for_task(state.account.as_ref());
1848
1849    state.pending_selection = Some(PendingSelection {
1850        mod_dir: open.mod_dir.clone(),
1851        player: player.clone(),
1852    });
1853    let storage = storage.clone();
1854    let room_id = room_id.to_owned();
1855    state.action_task = Some(spawn_task(ctx.clone(), move |_tx| {
1856        let identity = identity.as_ref().map(borrowed_identity);
1857        storage
1858            .join_room(&room_id, &player, identity)
1859            .map(RoomTaskResult::Room)
1860            .map_err(|e| e.to_string())
1861    }));
1862
1863    // The action started; the room list closes. On failure the error window
1864    // opens with the server's message.
1865    state.open_popup = None;
1866}
1867
1868/// The `tournament_player` value of the mod owning the open popup (the
1869/// identity a join or ownership check runs as). `None` when unset.
1870pub(super) fn tournament_player_of(state: &ModsPageState, mod_dir: &str) -> Option<String> {
1871    state
1872        .mods
1873        .iter()
1874        .find(|m| m.dir_name == mod_dir)
1875        .and_then(|m| m.configuration.get(TOURNAMENT_PLAYER_PARAM))
1876        .and_then(Value::as_str)
1877        .map(str::to_owned)
1878}
1879
1880/// Whether the player (or their logged-in account) created this room: the
1881/// host name matches the tournament player (the guest identity), or the
1882/// room carries the account's user id as its owner. Pure so the removal
1883/// gating can be unit tested.
1884fn room_owned_by(room: &RoomSummaryDto, player: &str, account_user_id: Option<i64>) -> bool {
1885    room.host == player || account_user_id.is_some_and(|uid| room.owner_user_id == Some(uid))
1886}
1887
1888/// Whether the room list should offer a Remove button for this room: the
1889/// player's own, not started yet, and joined by nobody else (the server
1890/// enforces the same guards under its room lock, this gate only decides
1891/// whether the affordance exists).
1892fn room_can_be_removed(
1893    room: &RoomSummaryDto,
1894    player: &str,
1895    account_user_id: Option<i64>,
1896    now: i64,
1897) -> bool {
1898    room.is_removable(now) && room_owned_by(room, player, account_user_id)
1899}
1900
1901/// The remove confirmation's Remove button was pressed: start the
1902/// `room_remove` request on the action slot (the same one-at-a-time slot
1903/// create/join use, so a removal can never race a join).
1904fn start_remove_task(
1905    ctx: &egui::Context,
1906    storage: &GameStorage,
1907    state: &mut ModsPageState,
1908    remove: &RemoveConfirm,
1909) {
1910    let player = match player_for_action(state, &remove.mod_dir) {
1911        Ok(player) => player,
1912        Err(reason) => {
1913            state.tournament_error = Some(reason);
1914            return;
1915        }
1916    };
1917    if state.action_task.is_some() {
1918        return; // double-submit guard
1919    }
1920    let identity = account_identity_for_task(state.account.as_ref());
1921    let storage = storage.clone();
1922    let room_id = remove.room_id.clone();
1923    state.action_task = Some(spawn_task(ctx.clone(), move |_tx| {
1924        let identity = identity.as_ref().map(borrowed_identity);
1925        storage
1926            .remove_room(&room_id, &player, identity)
1927            .map(|_| RoomTaskResult::Removed(room_id.clone()))
1928            .map_err(|e| e.to_string())
1929    }));
1930}
1931
1932/// Called on the UI thread once a room removal succeeded: refreshes the
1933/// room list (the popup cache and the game's offline menu list both
1934/// re-derive from it), and when the removed room was the SELECTED one,
1935/// clears the selection overrides and removes the mod's local managed
1936/// copies, mirroring the cleanup a room switch performs (the files are
1937/// the removed room's content and would serve stale ghosts).
1938///
1939/// Returns whether `mods.json` changed (→ refresh the hook resolver).
1940fn handle_room_removed(
1941    ctx: &egui::Context,
1942    storage: &GameStorage,
1943    state: &mut ModsPageState,
1944    room_id: &str,
1945) -> bool {
1946    start_list_task(ctx, storage, &mut state.room_cache, &mut state.list_task);
1947
1948    let selected: Vec<usize> = state
1949        .mods
1950        .iter()
1951        .enumerate()
1952        .filter(|(_, m)| {
1953            m.configuration
1954                .get(TOURNAMENT_ROOM_ID_PARAM)
1955                .and_then(Value::as_str)
1956                == Some(room_id)
1957        })
1958        .map(|(index, _)| index)
1959        .collect();
1960    let mut config_changed = false;
1961    for index in selected {
1962        let m = &mut state.mods[index];
1963        wipe_stale_managed_files(storage, m);
1964        config_changed |= clear_room_selection(&mut m.configuration);
1965    }
1966    if !config_changed {
1967        return false;
1968    }
1969    if let Err(e) = mods_tasks::persist_mod_configurations(storage, &state.mods) {
1970        let message = format!("Failed to persist tournament configuration: {e}");
1971        eprintln!("Tournament: {message}");
1972        state.tournament_error = Some(message);
1973        return false;
1974    }
1975    true
1976}
1977
1978/// Drops the selected-room overrides from one mod's configuration: every
1979/// key [`persist_selection`] writes EXCEPT the player's own name (that one
1980/// is the user's visible control, it survives room switches). An empty
1981/// `tournament_room_id` afterwards means "no selection", exactly the
1982/// shipped default. Returns whether anything changed.
1983fn clear_room_selection(configuration: &mut HashMap<String, Value>) -> bool {
1984    const SELECTION_KEYS: [&str; 6] = [
1985        TOURNAMENT_ENV_PARAM,
1986        TOURNAMENT_DIFFICULTY_PARAM,
1987        TOURNAMENT_ROOM_ID_PARAM,
1988        TOURNAMENT_ROOM_NAME_CFG,
1989        TOURNAMENT_STARTS_AT_CFG,
1990        TOURNAMENT_ENDS_AT_CFG,
1991    ];
1992    let mut changed = false;
1993    for key in SELECTION_KEYS {
1994        changed |= configuration.remove(key).is_some();
1995    }
1996    changed
1997}
1998
1999/// Called on the UI thread once a create/join task succeeded: writes the
2000/// selection into the mod's active configuration, the room/player values
2001/// the rules interpolate (`env`, `difficulty`, `room_id`, `player`; the
2002/// server URL stays the mod's own default) plus the display and
2003/// gating values (`room_name`, `starts_at`, `ends_at`), then persists
2004/// `mods.json` the same way a parameter edit does. The selection lives
2005/// only in the mod's active configuration, and the hook resolver already
2006/// merges exactly these values on Play.
2007///
2008/// Returns whether `mods.json` changed (→ refresh the hook resolver).
2009fn persist_selection(storage: &GameStorage, state: &mut ModsPageState, room: &RoomDto) -> bool {
2010    let Some(pending) = state.pending_selection.clone() else {
2011        eprintln!("Tournament: room result arrived without a pending selection; ignoring");
2012        return false;
2013    };
2014
2015    if let Some(m) = state
2016        .mods
2017        .iter_mut()
2018        .find(|m| m.dir_name == pending.mod_dir)
2019    {
2020        // selecting a DIFFERENT room wipes the mod's locally saved
2021        // remote-backed files first (the five replay slots and the
2022        // high-times table). They are the old room's content and would
2023        // serve stale ghosts and stale rows until the daemon's first
2024        // refresh of the new room landed; the .dat files the game finds
2025        // on disk must belong to the room being entered. Re-joining the
2026        // SAME room keeps everything (that is a refresh, not a switch).
2027        let room_changed = m
2028            .configuration
2029            .get(TOURNAMENT_ROOM_ID_PARAM)
2030            .and_then(Value::as_str)
2031            .is_none_or(|previous| previous != room.id.as_str());
2032        if room_changed {
2033            wipe_stale_managed_files(storage, m);
2034        }
2035
2036        // The interpolated values (contract E; the rules' enable_if and
2037        // remote URLs read exactly these keys).
2038        m.configuration.insert(
2039            TOURNAMENT_ENV_PARAM.to_owned(),
2040            Value::String(room.env.clone()),
2041        );
2042        m.configuration.insert(
2043            TOURNAMENT_DIFFICULTY_PARAM.to_owned(),
2044            Value::String(room.difficulty.clone()),
2045        );
2046        m.configuration.insert(
2047            TOURNAMENT_ROOM_ID_PARAM.to_owned(),
2048            Value::String(room.id.as_str().to_owned()),
2049        );
2050        m.configuration.insert(
2051            TOURNAMENT_PLAYER_PARAM.to_owned(),
2052            Value::String(pending.player.clone()),
2053        );
2054        // The display + gating values : the room NAME (so the UI
2055        // never has to show the id) and the planning window (so Play can
2056        // be disabled for scheduled/expired rooms).
2057        m.configuration.insert(
2058            TOURNAMENT_ROOM_NAME_CFG.to_owned(),
2059            Value::String(room.name.clone()),
2060        );
2061        m.configuration.insert(
2062            TOURNAMENT_STARTS_AT_CFG.to_owned(),
2063            Value::Number(room.starts_at.into()),
2064        );
2065        m.configuration.insert(
2066            TOURNAMENT_ENDS_AT_CFG.to_owned(),
2067            Value::Number(room.ends_at.into()),
2068        );
2069    } else {
2070        eprintln!(
2071            "Tournament: mod '{}' vanished before its configuration could be updated",
2072            pending.mod_dir
2073        );
2074    }
2075
2076    if let Err(e) = mods_tasks::persist_mod_configurations(storage, &state.mods) {
2077        let message = format!("Failed to persist tournament configuration: {e}");
2078        eprintln!("Tournament: {message}");
2079        state.tournament_error = Some(message);
2080        return false;
2081    }
2082    true
2083}
2084
2085/// Persists the launcher-computed room list into every enabled mod that
2086/// declares the `TheListOfRooms` parameter (the offline main-menu patch
2087/// interpolates it while the play-online toggle is off). `rooms` arrive
2088/// already sorted by expiration proximity (the server's `room_list`
2089/// order). Returns whether `mods.json` changed.
2090fn persist_room_list(
2091    storage: &GameStorage,
2092    state: &mut ModsPageState,
2093    rooms: &[RoomSummaryDto],
2094) -> bool {
2095    let list = room_list_text(rooms);
2096    let mut changed = false;
2097    for m in state.mods.iter_mut() {
2098        if !m.enabled
2099            || m.load_error.is_some()
2100            || !m
2101                .config
2102                .parameters
2103                .iter()
2104                .any(|p| p.id == TOURNAMENT_ROOMS_CFG)
2105        {
2106            continue;
2107        }
2108        if m.configuration
2109            .get(TOURNAMENT_ROOMS_CFG)
2110            .and_then(Value::as_str)
2111            != Some(list.as_str())
2112        {
2113            m.configuration.insert(
2114                TOURNAMENT_ROOMS_CFG.to_owned(),
2115                Value::String(list.clone()),
2116            );
2117            changed = true;
2118        }
2119    }
2120    if !changed {
2121        return false;
2122    }
2123    if let Err(e) = mods_tasks::persist_mod_configurations(storage, &state.mods) {
2124        eprintln!("Tournament: failed to persist the room list: {e}");
2125        return false;
2126    }
2127    true
2128}
2129
2130/// The offline room-list text: `"Name (records)", ...` comma-separated,
2131/// the game's Menu_Strings room list renders it verbatim.
2132fn room_list_text(rooms: &[RoomSummaryDto]) -> String {
2133    rooms
2134        .iter()
2135        .map(|room| format!("{} ({})", room.name, room.record_count))
2136        .collect::<Vec<_>>()
2137        .join(", ")
2138}
2139
2140/// Deletes every managed-file local copy the mod declares (for the online
2141/// tournament: the five replay slots and the high-times table). Called
2142/// when the selected room changes so no file of the previous room can be
2143/// served from disk. Missing files are fine; other failures are logged
2144/// and left for the daemon's next copy-if-different refresh to reconcile.
2145fn wipe_stale_managed_files(storage: &GameStorage, m: &LoadedMod) {
2146    let config = HookConfig {
2147        rules: m.config.rules.clone(),
2148    };
2149    let mut wiped = 0;
2150    for spec in managed_file_specs(storage.game_path(), &config) {
2151        match std::fs::remove_file(&spec.local) {
2152            Ok(()) => {
2153                wiped += 1;
2154                eprintln!(
2155                    "Tournament: room switch; removed stale local copy {}",
2156                    spec.local.display()
2157                );
2158            }
2159            Err(e) if e.kind() == ErrorKind::NotFound => {}
2160            Err(e) => eprintln!(
2161                "Tournament: room switch; could not remove stale local copy {}: {e}",
2162                spec.local.display()
2163            ),
2164        }
2165    }
2166    if wiped > 0 {
2167        eprintln!(
2168            "Tournament: room switch; {wiped} stale managed file(s) removed; \
2169             the new room's content is fetched on the next game launch"
2170        );
2171    }
2172}
2173
2174// ---------------------------------------------------------------------------
2175// Helpers
2176// ---------------------------------------------------------------------------
2177
2178/// Maps a `ui` form field id to the mod parameter id whose override carries
2179/// the same value (contract E). `None` = not persisted as an override.
2180fn tournament_param_override_key(field_id: &str) -> Option<&'static str> {
2181    match field_id {
2182        "env" => Some(TOURNAMENT_ENV_PARAM),
2183        "difficulty" => Some(TOURNAMENT_DIFFICULTY_PARAM),
2184        _ => None,
2185    }
2186}
2187
2188// ---------------------------------------------------------------------------
2189// Play button label
2190// ---------------------------------------------------------------------------
2191
2192/// The Play button becomes **"Online"** when an enabled mod with
2193/// tournament `ui` actions has a selection in its active configuration
2194/// (there is no extra selection file).
2195/// Returns that selection (for the footer tooltip), or `None` for a normal
2196/// `Play!`.
2197///
2198/// When the Mods page hasn't been opened this session (`mods` is empty) the
2199/// check falls back to disk: the enabled list + per-mod overrides from
2200/// `mods.json`, the same values the hook resolver interpolates on Play.
2201pub fn tournament_play_selection(
2202    storage: &GameStorage,
2203    mods_state: &ModsPageState,
2204) -> Option<TournamentSelection> {
2205    if mods_state.mods.is_empty() {
2206        let active = load_mods_state(&mods_tasks::mods_dir(storage))
2207            .map(|state| state.active)
2208            .unwrap_or_default();
2209        for dir in &active.enabled {
2210            if let Some(selection) = active
2211                .configuration
2212                .get(dir)
2213                .filter(|cfg| is_online(cfg))
2214                .and_then(TournamentSelection::from_configuration)
2215            {
2216                return Some(selection);
2217            }
2218        }
2219        return None;
2220    }
2221
2222    for m in &mods_state.mods {
2223        if !m.enabled || m.load_error.is_some() {
2224            continue; // a broken mod's rules can't be applied, never claim Online
2225        }
2226        let has_tournament_actions = m.config.ui.as_ref().is_some_and(|ui_schema: &ModUi| {
2227            ui_schema
2228                .buttons
2229                .iter()
2230                .any(|b| is_tournament_action(&b.action))
2231        });
2232        if !has_tournament_actions || !is_online(&m.configuration) {
2233            continue; // the toggle is off: the game stays offline, never claim Online
2234        }
2235        if let Some(selection) = TournamentSelection::from_configuration(&m.configuration) {
2236            return Some(selection);
2237        }
2238    }
2239    None
2240}
2241
2242// ---------------------------------------------------------------------------
2243// High-times population (owned by the DLL)
2244// ---------------------------------------------------------------------------
2245
2246// The game reads `Saved_Data/<env>_<diff>_high_times.txt` once at startup
2247// and builds its high-score list, and the ghosts it wants to race
2248// against, from those entries, pairing entry rank N with the replay in
2249// `Replay_N.dat`.
2250//
2251// The LAUNCHER performs no downloads at all on the launch path: the
2252// hooking DLL owns the file at runtime. The mod's managed-file rule
2253// fetches the server-rendered text when the game opens the file (a
2254// bounded wait on first contact, then always serve-from-disk while the
2255// background cadence refreshes it), and a missing copy degrades to the
2256// vanilla file. The launch path only writes configs and starts the game.
2257
2258#[cfg(test)]
2259mod tests {
2260    use super::super::types::{LoadedMod, ModsPageState};
2261    use super::{
2262        TOURNAMENT_PLAYER_PARAM, clear_room_selection, confirm_blockers, player_for_action,
2263        prefill_tournament_player, read_player_name, room_can_be_removed, room_owned_by,
2264        rough_countdown, tournament_player_of,
2265    };
2266    use supreme_fs::GameStorage;
2267    use supreme_fs::RoomSummaryDto;
2268    use supreme_hook_config::{
2269        ModConfig, ModParameter, PLAYER_NAME_TAG, ParameterKind, ROOM_NAME_TAG, UiField,
2270        UiFieldKind, UiPopup, UiPopupKind,
2271    };
2272    use tempfile::tempdir;
2273
2274    const MIN: i64 = 60;
2275    const HOUR: i64 = 60 * MIN;
2276    const DAY: i64 = 24 * HOUR;
2277
2278    /// A text field for the blocker tests, carrying an optional
2279    /// validation tag.
2280    fn text_field(id: &str, label: &str, tag: Option<&str>) -> UiField {
2281        UiField {
2282            id: id.to_owned(),
2283            kind: UiFieldKind::Text,
2284            label: label.to_owned(),
2285            options: Vec::new(),
2286            hint: None,
2287            validation: tag.map(str::to_owned),
2288        }
2289    }
2290
2291    fn form_popup(fields: Vec<UiField>) -> UiPopup {
2292        UiPopup {
2293            title: "Create Tournament Room".to_owned(),
2294            kind: UiPopupKind::Form,
2295            fields,
2296        }
2297    }
2298
2299    /// A page state with one enabled, loadable mod whose
2300    /// `tournament_player` override is `value` (None = unset).
2301    fn state_with_player(value: Option<&str>) -> ModsPageState {
2302        let mut configuration = std::collections::HashMap::new();
2303        if let Some(value) = value {
2304            configuration.insert(
2305                TOURNAMENT_PLAYER_PARAM.to_owned(),
2306                serde_json::Value::String(value.to_owned()),
2307            );
2308        }
2309        let mut state = ModsPageState::default();
2310        state.mods.push(LoadedMod {
2311            dir_name: "online-tournament".to_owned(),
2312            config: ModConfig::default(),
2313            configuration,
2314            enabled: true,
2315            load_error: None,
2316            warnings: Vec::new(),
2317        });
2318        state
2319    }
2320
2321    /// A mod declaring `tournament_player` with the given visibility and
2322    /// current override, the shape `prefill_tournament_player` filters on.
2323    fn mod_declaring_player(visible: bool, current: Option<&str>) -> LoadedMod {
2324        let mut configuration = std::collections::HashMap::new();
2325        if let Some(current) = current {
2326            configuration.insert(
2327                TOURNAMENT_PLAYER_PARAM.to_owned(),
2328                serde_json::Value::String(current.to_owned()),
2329            );
2330        }
2331        LoadedMod {
2332            dir_name: "online-tournament".to_owned(),
2333            config: ModConfig {
2334                parameters: vec![ModParameter {
2335                    id: TOURNAMENT_PLAYER_PARAM.to_owned(),
2336                    name: "Tournament player name".to_owned(),
2337                    help_text: String::new(),
2338                    kind: ParameterKind::Text,
2339                    enable_if: None,
2340                    hidden: !visible,
2341                    validation: Some(PLAYER_NAME_TAG.to_owned()),
2342                }],
2343                ..ModConfig::default()
2344            },
2345            configuration,
2346            enabled: true,
2347            load_error: None,
2348            warnings: Vec::new(),
2349        }
2350    }
2351
2352    /// A game dir whose `Saved_Data/Arcade_Player.txt` holds the exact
2353    /// bytes the game writes. The TempDir guard travels with the
2354    /// storage: `GameStorage` keeps only the path, the directory dies
2355    /// with the guard.
2356    fn storage_with_player_file(raw: &str) -> (GameStorage, tempfile::TempDir) {
2357        let dir = tempdir().expect("game dir");
2358        let saved_data = dir.path().join("Saved_Data");
2359        std::fs::create_dir_all(&saved_data).expect("Saved_Data");
2360        std::fs::write(saved_data.join("Arcade_Player.txt"), raw).expect("player file");
2361        (GameStorage::new(dir.path()), dir)
2362    }
2363
2364    /// A room-list entry with the given shape, for the removal-gating
2365    /// tests.
2366    fn summary_room(host: &str, starts_at: i64, player_count: i64) -> RoomSummaryDto {
2367        RoomSummaryDto {
2368            id: "r_test00000000".to_owned(),
2369            name: "Fresh Powder".to_owned(),
2370            env: "alpine".to_owned(),
2371            difficulty: "easy".to_owned(),
2372            host: host.to_owned(),
2373            starts_at,
2374            ends_at: starts_at + DAY,
2375            player_count,
2376            record_count: 0,
2377            results_url: None,
2378            owner_user_id: None,
2379        }
2380    }
2381
2382    #[test]
2383    fn remove_button_shows_only_on_own_unstarted_unjoined_rooms() {
2384        let now = 1_000_000;
2385        let starts_at = now + HOUR;
2386
2387        // Own, un-started, alone: the one shape that may be removed.
2388        let own = summary_room("Xinfe", starts_at, 1);
2389        assert!(room_can_be_removed(&own, "Xinfe", None, now));
2390
2391        // Somebody else's room: never, regardless of shape.
2392        let other = summary_room("GhostRdr", starts_at, 1);
2393        assert!(!room_can_be_removed(&other, "Xinfe", None, now));
2394        // Own but started, or someone joined: not removable.
2395        let started = summary_room("Xinfe", now - MIN, 1);
2396        assert!(!room_can_be_removed(&started, "Xinfe", None, now));
2397        let joined = summary_room("Xinfe", starts_at, 2);
2398        assert!(!room_can_be_removed(&joined, "Xinfe", None, now));
2399
2400        // A logged-in account also owns rooms carrying its user id,
2401        // even under a different host name.
2402        let mut owned = summary_room("someoneelse", starts_at, 1);
2403        owned.owner_user_id = Some(7);
2404        assert!(!room_owned_by(&owned, "Xinfe", None), "guest cannot remove it");
2405        assert!(room_owned_by(&owned, "Xinfe", Some(7)));
2406        assert!(room_can_be_removed(&owned, "Xinfe", Some(7), now));
2407        // A DIFFERENT account's room is not ours.
2408        assert!(!room_can_be_removed(&owned, "Xinfe", Some(8), now));
2409    }
2410
2411    #[test]
2412    fn clearing_a_removed_selection_drops_the_room_keys_keeps_the_player() {
2413        let mut configuration = std::collections::HashMap::new();
2414        configuration.insert(
2415            "tournament_env".to_owned(),
2416            serde_json::json!("village"),
2417        );
2418        configuration.insert(
2419            "tournament_difficulty".to_owned(),
2420            serde_json::json!("hard"),
2421        );
2422        configuration.insert(
2423            "tournament_room_id".to_owned(),
2424            serde_json::json!("r_gone0000000000"),
2425        );
2426        configuration.insert(
2427            "tournament_room_name".to_owned(),
2428            serde_json::json!("Fresh Powder"),
2429        );
2430        configuration.insert(
2431            "tournament_starts_at".to_owned(),
2432            serde_json::json!(1_760_000_000),
2433        );
2434        configuration.insert(
2435            "tournament_ends_at".to_owned(),
2436            serde_json::json!(1_760_086_400),
2437        );
2438        configuration.insert(
2439            TOURNAMENT_PLAYER_PARAM.to_owned(),
2440            serde_json::json!("Xinfe"),
2441        );
2442        assert!(clear_room_selection(&mut configuration));
2443        // The player's own name survives; every room key is gone, the
2444        // absent room id reads as "no selection".
2445        assert_eq!(
2446            configuration.get(TOURNAMENT_PLAYER_PARAM),
2447            Some(&serde_json::json!("Xinfe"))
2448        );
2449        assert!(
2450            !configuration.contains_key("tournament_room_id")
2451                && !configuration.contains_key("tournament_room_name")
2452                && !configuration.contains_key("tournament_env")
2453                && !configuration.contains_key("tournament_difficulty")
2454                && !configuration.contains_key("tournament_starts_at")
2455                && !configuration.contains_key("tournament_ends_at"),
2456            "every selected-room override is dropped: {configuration:?}"
2457        );
2458        // Clearing again changes nothing (the room was already gone).
2459        assert!(!clear_room_selection(&mut configuration));
2460    }
2461
2462    #[test]
2463    fn tournament_player_of_reads_the_owning_mods_value() {
2464        let state = state_with_player(Some("Xinfe"));
2465        assert_eq!(
2466            tournament_player_of(&state, "online-tournament").as_deref(),
2467            Some("Xinfe")
2468        );
2469        assert_eq!(
2470            tournament_player_of(&state, "unknown-mod"),
2471            None,
2472            "an uninstalled mod dir reads as no player"
2473        );
2474    }
2475
2476    #[test]
2477    fn confirm_blockers_pass_clean_and_untagged_forms() {
2478        // A fresh create form: no validation tags, no values yet.
2479        let popup = form_popup(vec![
2480            text_field("name", "Room name", None),
2481            text_field("env", "Environment", None),
2482        ]);
2483        assert!(confirm_blockers(&popup, |_| None).is_empty());
2484        // A tagged room name that is EMPTY is confirmable: the server
2485        // autogenerates a name, only the charset is refused.
2486        let popup = form_popup(vec![text_field("name", "Room name", Some(ROOM_NAME_TAG))]);
2487        let empty = |_: &str| None::<String>;
2488        assert!(confirm_blockers(&popup, empty).is_empty());
2489    }
2490
2491    #[test]
2492    fn confirm_blockers_surface_each_fields_reason_under_its_label() {
2493        let popup = form_popup(vec![
2494            text_field("name", "Room name", Some(ROOM_NAME_TAG)),
2495            text_field("player", "Player name", Some(PLAYER_NAME_TAG)),
2496        ]);
2497        let values = |id: &str| match id {
2498            "name" => Some("粉末".to_owned()),
2499            "player" => Some("player1".to_owned()),
2500            _ => None,
2501        };
2502        let blockers = confirm_blockers(&popup, values);
2503        assert_eq!(blockers.len(), 2, "one reason per invalid field");
2504        assert!(
2505            blockers[0].starts_with("Room name: "),
2506            "the reason names its field: {blockers:?}"
2507        );
2508        assert!(
2509            blockers[1].starts_with("Player name: "),
2510            "the reason names its field: {blockers:?}"
2511        );
2512        // A missing player value is refused (the identity is required),
2513        // a valid room name is not.
2514        let popup = form_popup(vec![
2515            text_field("name", "Room name", Some(ROOM_NAME_TAG)),
2516            text_field("player", "Player name", Some(PLAYER_NAME_TAG)),
2517        ]);
2518        let blockers = confirm_blockers(&popup, |id| {
2519            (id == "name").then(|| "Fresh Powder".to_owned())
2520        });
2521        assert_eq!(blockers.len(), 1);
2522        assert!(
2523            blockers[0].starts_with("Player name: "),
2524            "the missing identity is the only blocker: {blockers:?}"
2525        );
2526    }
2527
2528    #[test]
2529    fn confirm_blockers_require_the_window_to_end_after_it_starts() {
2530        let popup = form_popup(vec![
2531            text_field("starts_at", "Starts", None),
2532            text_field("ends_at", "Ends", None),
2533        ]);
2534        let equal = |_: &str| Some("1000".to_owned());
2535        assert_eq!(
2536            confirm_blockers(&popup, equal),
2537            vec!["The room must END after it STARTS. Adjust the date/time pickers.".to_owned()],
2538            "an equal window is a zero-length room, refused"
2539        );
2540        let ordered = |id: &str| {
2541            Some(
2542                (id == "ends_at")
2543                    .then(|| "1001".to_owned())
2544                    .unwrap_or_default(),
2545            )
2546        };
2547        assert!(
2548            confirm_blockers(&popup, ordered).is_empty(),
2549            "ends after starts is the valid window"
2550        );
2551        // Unparseable/absent values never block: the popup-open path
2552        // seeds real now/tomorrow defaults, an absent value means the
2553        // form predates the pickers, not that the window is invalid.
2554        assert!(
2555            confirm_blockers(&popup, |id| {
2556                (id == "starts_at").then(|| "not a number".to_owned())
2557            })
2558            .is_empty()
2559        );
2560    }
2561
2562    #[test]
2563    fn player_for_action_accepts_a_valid_name_and_clears_a_stale_highlight() {
2564        let mut state = state_with_player(Some("Xinfe"));
2565        state.tournament_param_error = Some((
2566            "online-tournament".to_owned(),
2567            TOURNAMENT_PLAYER_PARAM.to_owned(),
2568        ));
2569        assert_eq!(
2570            player_for_action(&mut state, "online-tournament"),
2571            Ok("Xinfe".to_owned())
2572        );
2573        assert!(
2574            state.tournament_param_error.is_none(),
2575            "a valid value clears the highlight a previous refusal set"
2576        );
2577    }
2578
2579    #[test]
2580    fn player_for_action_refuses_and_highlights_missing_or_invalid_names() {
2581        let mut state = state_with_player(None);
2582        let reason = player_for_action(&mut state, "online-tournament").unwrap_err();
2583        assert_eq!(
2584            state.tournament_param_error,
2585            Some((
2586                "online-tournament".to_owned(),
2587                TOURNAMENT_PLAYER_PARAM.to_owned()
2588            )),
2589            "the refusal points at the mod page's parameter"
2590        );
2591        assert!(reason.contains("player name"), "{reason}");
2592
2593        let mut state = state_with_player(Some("player1"));
2594        assert!(player_for_action(&mut state, "online-tournament").is_err());
2595        assert_eq!(
2596            state.tournament_param_error,
2597            Some((
2598                "online-tournament".to_owned(),
2599                TOURNAMENT_PLAYER_PARAM.to_owned()
2600            )),
2601            "non-letters are refused the same way"
2602        );
2603
2604        // An unknown mod dir (uninstalled mid-flow) never panics, it
2605        // reads as an unset value and refuses with guidance.
2606        let mut state = state_with_player(Some("Xinfe"));
2607        assert!(player_for_action(&mut state, "gone-mod").is_err());
2608    }
2609
2610    #[test]
2611    fn read_player_name_strips_the_games_quoting() {
2612        let (storage, _dir) = storage_with_player_file("\"Xinfe\"\r\n");
2613        assert_eq!(read_player_name(&storage).as_deref(), Some("Xinfe"));
2614        // Unquoted (hand-edited) files read the same.
2615        let (storage, _dir) = storage_with_player_file("  Xinfe  ");
2616        assert_eq!(read_player_name(&storage).as_deref(), Some("Xinfe"));
2617        // Missing file, empty and quote-only values read as absent.
2618        let dir = tempdir().expect("game dir");
2619        let storage = GameStorage::new(dir.path());
2620        assert_eq!(read_player_name(&storage), None);
2621        let (storage, _dir) = storage_with_player_file("\"\"");
2622        assert_eq!(read_player_name(&storage), None);
2623    }
2624
2625    #[test]
2626    fn prefill_tournament_player_fills_only_empty_visible_parameters() {
2627        let (storage, _dir) = storage_with_player_file("\"Xinfe\"\r\n");
2628
2629        // Visible + empty: prefilled, exactly once, flagged for persist.
2630        let mut mods = vec![mod_declaring_player(true, None)];
2631        let mut changed = false;
2632        prefill_tournament_player(&storage, &mut mods, &mut changed);
2633        assert_eq!(changed, true);
2634        assert_eq!(
2635            mods[0].configuration.get(TOURNAMENT_PLAYER_PARAM),
2636            Some(&serde_json::json!("Xinfe"))
2637        );
2638
2639        // Already set (a user edit or a previous run): untouched.
2640        let mut mods = vec![mod_declaring_player(true, Some("GhostRdr"))];
2641        let mut changed = false;
2642        prefill_tournament_player(&storage, &mut mods, &mut changed);
2643        assert_eq!(changed, false);
2644        assert_eq!(
2645            mods[0].configuration.get(TOURNAMENT_PLAYER_PARAM),
2646            Some(&serde_json::json!("GhostRdr"))
2647        );
2648
2649        // Hidden launcher-set parameter: never prefilled.
2650        let mut mods = vec![mod_declaring_player(false, None)];
2651        let mut changed = false;
2652        prefill_tournament_player(&storage, &mut mods, &mut changed);
2653        assert_eq!(changed, false);
2654        assert!(!mods[0].configuration.contains_key(TOURNAMENT_PLAYER_PARAM));
2655
2656        // Disabled mod: untouched regardless of its shape.
2657        let mut mods = vec![mod_declaring_player(true, None)];
2658        mods[0].enabled = false;
2659        let mut changed = false;
2660        prefill_tournament_player(&storage, &mut mods, &mut changed);
2661        assert_eq!(changed, false);
2662        assert!(!mods[0].configuration.contains_key(TOURNAMENT_PLAYER_PARAM));
2663    }
2664
2665    #[test]
2666    fn prefill_tournament_player_without_a_player_file_changes_nothing() {
2667        let dir = tempdir().expect("game dir");
2668        let storage = GameStorage::new(dir.path());
2669        let mut mods = vec![mod_declaring_player(true, None)];
2670        let mut changed = false;
2671        prefill_tournament_player(&storage, &mut mods, &mut changed);
2672        assert_eq!(changed, false);
2673        assert!(!mods[0].configuration.contains_key(TOURNAMENT_PLAYER_PARAM));
2674    }
2675
2676    #[test]
2677    fn countdown_uses_the_biggest_denomination_only() {
2678        assert_eq!(rough_countdown(5 * 7 * DAY), "5 weeks");
2679        assert_eq!(rough_countdown(13 * MIN), "13 minutes");
2680        // Single-value ladders.
2681        assert_eq!(rough_countdown(75 * DAY), "2 months");
2682        assert_eq!(rough_countdown(45 * DAY), "6 weeks");
2683        assert_eq!(rough_countdown(13 * DAY), "13 days");
2684        assert_eq!(rough_countdown(2 * DAY), "2 days");
2685        assert_eq!(rough_countdown(5 * HOUR + 12 * MIN), "5 hours");
2686        assert_eq!(rough_countdown(2 * HOUR), "2 hours");
2687        assert_eq!(rough_countdown(59 * MIN), "59 minutes");
2688        // Sub-minute rounds up; zero clamps.
2689        assert_eq!(rough_countdown(30), "1 minute");
2690        assert_eq!(rough_countdown(0), "1 minute");
2691        // Negative (clock skew) never panics.
2692        assert_eq!(rough_countdown(-5), "1 minute");
2693    }
2694
2695    #[test]
2696    fn countdown_pluralizes() {
2697        // Rungs start at two units, so the singular forms below the minute
2698        // rung never appear: exactly one day renders as "24 hours", one
2699        // week as "7 days".
2700        assert_eq!(rough_countdown(MIN), "1 minute");
2701        assert_eq!(rough_countdown(2 * MIN), "2 minutes");
2702        assert_eq!(rough_countdown(DAY), "24 hours");
2703        assert_eq!(rough_countdown(3 * DAY), "3 days");
2704        assert_eq!(rough_countdown(7 * DAY), "7 days");
2705        // 30 days is under the two-month rung → weeks.
2706        assert_eq!(rough_countdown(30 * DAY), "4 weeks");
2707        assert_eq!(rough_countdown(60 * DAY), "2 months");
2708    }
2709}