launcher/
tournament_selection.rs1use serde_json::Value;
21use std::collections::HashMap;
22
23pub const TOURNAMENT_ROOM_ID_CFG: &str = "tournament_room_id";
26pub const TOURNAMENT_ROOM_NAME_CFG: &str = "tournament_room_name";
29pub const TOURNAMENT_STARTS_AT_CFG: &str = "tournament_starts_at";
31pub const TOURNAMENT_ENDS_AT_CFG: &str = "tournament_ends_at";
33const TOURNAMENT_ENV_CFG: &str = "tournament_env";
34const TOURNAMENT_DIFFICULTY_CFG: &str = "tournament_difficulty";
35const TOURNAMENT_PLAYER_CFG: &str = "tournament_player";
36pub const TOURNAMENT_ONLINE_CFG: &str = "tournament_online";
40pub const TOURNAMENT_ROOMS_CFG: &str = "tournament_rooms";
43
44pub fn is_online(config: &HashMap<String, Value>) -> bool {
48 config
49 .get(TOURNAMENT_ONLINE_CFG)
50 .and_then(Value::as_bool)
51 .unwrap_or(true)
52}
53
54pub fn validate_player_name(raw: &str) -> Result<String, String> {
59 let name = raw.trim();
60 if name.is_empty() {
61 return Err("Enter a player name: 1 to 9 letters (a-z, A-Z).".to_owned());
62 }
63 if !(1..=9).contains(&name.len()) {
64 return Err("The player name is limited to 9 letters (a-z, A-Z).".to_owned());
65 }
66 if !name.chars().all(|c| c.is_ascii_alphabetic()) {
67 return Err("The player name takes letters only (a-z, A-Z).".to_owned());
68 }
69 Ok(name.to_owned())
70}
71
72#[derive(Debug, Clone, PartialEq)]
76pub struct TournamentSelection {
77 pub room_id: String,
80 pub name: String,
83 pub env: String,
85 pub difficulty: String,
87 pub starts_at: i64,
90 pub ends_at: i64,
92 pub player: String,
95}
96
97impl TournamentSelection {
98 #[must_use]
103 pub fn from_configuration(config: &HashMap<String, Value>) -> Option<Self> {
104 let room_id = config
105 .get(TOURNAMENT_ROOM_ID_CFG)
106 .and_then(Value::as_str)
107 .unwrap_or_default()
108 .to_owned();
109 if room_id.is_empty() {
110 return None;
111 }
112 let string_of = |key: &str| {
113 config
114 .get(key)
115 .and_then(Value::as_str)
116 .unwrap_or_default()
117 .to_owned()
118 };
119 let epoch_of = |key: &str| config.get(key).and_then(Value::as_i64).unwrap_or(0);
120 Some(Self {
121 room_id,
122 name: string_of(TOURNAMENT_ROOM_NAME_CFG),
123 env: string_of(TOURNAMENT_ENV_CFG),
124 difficulty: string_of(TOURNAMENT_DIFFICULTY_CFG),
125 starts_at: epoch_of(TOURNAMENT_STARTS_AT_CFG),
126 ends_at: epoch_of(TOURNAMENT_ENDS_AT_CFG),
127 player: string_of(TOURNAMENT_PLAYER_CFG),
128 })
129 }
130
131 #[must_use]
137 pub fn is_playable(&self, now: i64) -> bool {
138 (self.starts_at == 0 || self.starts_at <= now) && self.ends_at > now
139 }
140
141 #[must_use]
144 pub fn play_blocker(&self, now: i64) -> Option<String> {
145 if self.is_playable(now) {
146 return None;
147 }
148 if self.starts_at > now {
149 Some(format!(
150 "the room starts {}",
151 format_tournament_when(self.starts_at)
152 ))
153 } else {
154 Some(format!(
155 "the room expired {}",
156 format_tournament_when(self.ends_at)
157 ))
158 }
159 }
160}
161#[must_use]
163pub fn format_tournament_when(ends_at: i64) -> String {
164 chrono::DateTime::from_timestamp(ends_at, 0)
165 .map(|utc| {
166 utc.with_timezone(&chrono::Local)
167 .format("%Y-%m-%d %H:%M")
168 .to_string()
169 })
170 .unwrap_or_else(|| ends_at.to_string())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use serde_json::json;
177
178 fn sample_map() -> HashMap<String, Value> {
179 HashMap::from([
180 (TOURNAMENT_ROOM_ID_CFG.to_owned(), json!("r_0123456789ab")),
181 (TOURNAMENT_ROOM_NAME_CFG.to_owned(), json!("Fresh Powder")),
182 (TOURNAMENT_ENV_CFG.to_owned(), json!("village")),
183 (TOURNAMENT_DIFFICULTY_CFG.to_owned(), json!("medium")),
184 (TOURNAMENT_STARTS_AT_CFG.to_owned(), json!(1_760_000_000i64)),
185 (TOURNAMENT_ENDS_AT_CFG.to_owned(), json!(1_760_086_400i64)),
186 (TOURNAMENT_PLAYER_CFG.to_owned(), json!("psycho sam")),
187 ])
188 }
189
190 #[test]
191 fn selection_reads_from_configuration() {
192 let selection = TournamentSelection::from_configuration(&sample_map()).unwrap();
193 assert_eq!(selection.room_id, "r_0123456789ab");
194 assert_eq!(selection.name, "Fresh Powder");
195 assert_eq!(selection.env, "village");
196 assert_eq!(selection.difficulty, "medium");
197 assert_eq!(selection.starts_at, 1_760_000_000);
198 assert_eq!(selection.ends_at, 1_760_086_400);
199 assert_eq!(selection.player, "psycho sam");
200 }
201
202 #[test]
203 fn empty_room_id_means_no_selection() {
204 let mut map = sample_map();
205 map.insert(TOURNAMENT_ROOM_ID_CFG.to_owned(), json!(""));
206 assert_eq!(
207 TournamentSelection::from_configuration(&map),
208 None,
209 "empty room id = no selection (the mod's shipped default)"
210 );
211 assert_eq!(
212 TournamentSelection::from_configuration(&HashMap::new()),
213 None
214 );
215 }
216
217 #[test]
218 fn play_gate_requires_started_and_not_expired() {
219 let selection = TournamentSelection::from_configuration(&sample_map()).unwrap();
220 let starts = 1_760_000_000;
221 let ends = 1_760_086_400;
222 assert!(!selection.is_playable(starts - 1), "not started yet");
223 assert!(selection.is_playable(starts), "started");
224 assert!(selection.is_playable(ends - 1), "last live second");
225 assert!(!selection.is_playable(ends), "expired");
226 }
227
228 #[test]
229 fn legacy_selection_without_starts_at_counts_as_started() {
230 let mut map = sample_map();
231 map.remove(TOURNAMENT_STARTS_AT_CFG);
232 let selection = TournamentSelection::from_configuration(&map).unwrap();
233 assert!(selection.is_playable(0), "starts_at 0 = already started");
234 }
235
236 #[test]
237 fn play_blocker_names_the_reason() {
238 let selection = TournamentSelection::from_configuration(&sample_map()).unwrap();
239 assert_eq!(selection.play_blocker(1_760_005_000), None);
240 assert!(
241 selection
242 .play_blocker(1_760_000_000 - 60)
243 .unwrap()
244 .contains("starts"),
245 "before the window: 'starts'"
246 );
247 assert!(
248 selection
249 .play_blocker(1_760_086_400 + 60)
250 .unwrap()
251 .contains("expired"),
252 "after the window: 'expired'"
253 );
254 }
255}