1use 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};
47use 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
65const ROOM_COUNT_PLACEHOLDER: &str = "{room_count}";
68
69const TOURNAMENT_ENV_PARAM: &str = "tournament_env";
72const TOURNAMENT_DIFFICULTY_PARAM: &str = "tournament_difficulty";
73const TOURNAMENT_ROOM_ID_PARAM: &str = "tournament_room_id";
79pub(super) const TOURNAMENT_PLAYER_PARAM: &str = "tournament_player";
80
81const MENU_TEXTURE_RELPATH: &str = "Data/Levels";
85const MENU_TEXTURE_FILE: &str = "Menu_Texture_Normal.tga";
86const ROOM_CARD_SIZE: f32 = 148.0;
88
89const CREATE_POPUP_WIDTH: f32 = 320.0;
96
97const FORM_FIELD_WIDTH: f32 = 170.0;
101
102#[derive(Debug, Clone, PartialEq)]
109pub(super) enum PopupValue {
110 Text(String),
111 Number(f64),
112}
113
114#[derive(Debug, Clone)]
117pub(super) struct OpenPopup {
118 pub mod_dir: String,
120 pub button_id: String,
122 pub popup_id: String,
124 pub action: String,
126 pub open_serial: u64,
131}
132
133#[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 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#[derive(Debug)]
160pub(super) enum RoomTaskResult {
161 Rooms(Vec<RoomSummaryDto>),
162 Room(RoomDto),
163 Removed(String),
166}
167
168#[derive(Debug, Clone)]
171pub(super) struct PendingSelection {
172 pub mod_dir: String,
173 pub player: String,
174}
175
176#[derive(Debug, Clone)]
178pub(super) struct RemoveConfirm {
179 pub mod_dir: String,
182 pub room_id: String,
183 pub name: String,
186}
187
188pub(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 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(_))) => {} Some(Ok(RoomTaskResult::Removed(_))) => {} Some(Err(e)) => {
231 eprintln!("Tournament: failed to list rooms: {e}");
232 state.room_cache = RoomCache::Failed;
233 }
234 None => {}
235 }
236
237 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(_)) => {} Err(e) => {
264 eprintln!("Tournament: room request failed: {e}");
266 state.tournament_error = Some(e);
267 }
268 }
269 state.pending_selection = None;
272 }
273
274 config_changed |= super::account::poll_tasks(ctx, storage, state);
277
278 config_changed
279}
280
281pub(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
301fn 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
321fn 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; }
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
343pub(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 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 render_account_chip(ui, m, account, avatar, login_popup);
417 });
418
419 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
452fn 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
494fn 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 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; };
525
526 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 if popup.kind == UiPopupKind::RoomList {
538 start_list_task(ctx, storage, room_cache, list_task);
539 }
540
541 *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
555fn 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
585fn now_epoch() -> i64 {
587 chrono::Utc::now().timestamp()
588}
589
590pub(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 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 config_changed |= super::account::render_login_popup(ctx, storage, state);
661 config_changed
662}
663
664fn 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 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 let mut cancelled = false;
727
728 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 .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 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; }
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 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 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 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
937fn 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 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 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 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 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 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
1168fn 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
1187fn 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
1202fn 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
1233fn 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 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
1290fn env_card_color(env: &str) -> Color32 {
1292 match env {
1293 "alpine" => Color32::from_rgb(74, 111, 165), "forest" => Color32::from_rgb(62, 112, 77), "village" => Color32::from_rgb(126, 100, 84), other => {
1297 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
1308fn 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 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 let current = value.clone();
1364 let mut edit = egui::TextEdit::singleline(&mut value);
1365 edit = edit.desired_width(FORM_FIELD_WIDTH);
1372 if let Some(hint) = &field.hint {
1375 edit = edit.hint_text(hint);
1376 }
1377 let reason = field
1382 .validation
1383 .as_deref()
1384 .and_then(|tag| validation::validate_value(tag, ¤t).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 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 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 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 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
1492fn 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
1508fn 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 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
1543fn 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 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
1577fn 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
1593fn 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 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 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 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 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 eprintln!("Mods page: popup action `{action}` is not launcher-handled; nothing to do.");
1675 }
1676 }
1677}
1678
1679pub(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
1723pub(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
1747fn account_identity_for_task(account: Option<&AccountConfig>) -> Option<(i64, String)> {
1753 account.map(|account| (account.user_id, account.auth_key.clone()))
1754}
1755
1756fn 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; }
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}
1796struct 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
1807pub(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 pub(super) account: &'a Option<AccountConfig>,
1820 pub(super) avatar: &'a super::account::AvatarState,
1822 pub(super) login_popup: &'a mut Option<super::account::LoginPopup>,
1824}
1825
1826fn 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; }
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 state.open_popup = None;
1866}
1867
1868pub(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
1880fn 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
1888fn 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
1901fn 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; }
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
1932fn 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
1978fn 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
1999fn 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 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 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 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
2085fn 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
2130fn 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
2140fn 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
2174fn 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
2188pub 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; }
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; }
2235 if let Some(selection) = TournamentSelection::from_configuration(&m.configuration) {
2236 return Some(selection);
2237 }
2238 }
2239 None
2240}
2241
2242#[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 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 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 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 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 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 let own = summary_room("Xinfe", starts_at, 1);
2389 assert!(room_can_be_removed(&own, "Xinfe", None, now));
2390
2391 let other = summary_room("GhostRdr", starts_at, 1);
2393 assert!(!room_can_be_removed(&other, "Xinfe", None, now));
2394 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 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 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 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 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 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 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 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 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 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 let (storage, _dir) = storage_with_player_file(" Xinfe ");
2616 assert_eq!(read_player_name(&storage).as_deref(), Some("Xinfe"));
2617 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 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 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 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 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 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 assert_eq!(rough_countdown(30), "1 minute");
2690 assert_eq!(rough_countdown(0), "1 minute");
2691 assert_eq!(rough_countdown(-5), "1 minute");
2693 }
2694
2695 #[test]
2696 fn countdown_pluralizes() {
2697 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 assert_eq!(rough_countdown(30 * DAY), "4 weeks");
2707 assert_eq!(rough_countdown(60 * DAY), "2 months");
2708 }
2709}