supreme_hook_config/validation.rs
1//! Shared field-validation tags for mod-authored inputs.
2//!
3//! A `validation` tag on a [`crate::ui::UiField`] or a
4//! [`crate::mod_config::ModParameter`] names a rule set that both the
5//! launcher (live, while the user types) and the backend (authoritative,
6//! on receipt) enforce identically. The rules live here, one module, so
7//! the client-side refusal and a server-side one can never drift apart:
8//! the tournament room-name check is mirrored in PHP by contract, and
9//! both sides quote the same limits and charsets.
10//!
11//! Two tags ship with this launcher generation:
12//!
13//! - [`PLAYER_NAME_TAG`], the tournament player identity: 1 to 9 ASCII
14//! letters. The name travels inside every replay `.dat` and lands in
15//! the game's high-times table, so it stays as narrow as the game's
16//! own name handling needs.
17//! - [`ROOM_NAME_TAG`], the room display name: up to 24 printable
18//! extended-ASCII characters (what a 1999 Windows application displays
19//! without surprises: ASCII 0x20-0x7E plus the Latin-1 supplement
20//! 0xA0-0xFF; the name lands in ANSI game files). Empty is valid, the
21//! server autogenerates a snowboarding word pair for it.
22//!
23//! Unknown tags are rejected by `ModUi::validate` / `ModConfig::validate`
24//! (parse liberally, validate strictly, same mechanism as unknown popup
25//! kinds) so a newer mod on an older launcher reports itself instead of
26//! silently skipping the check.
27
28/// The tournament player-name rule: 1 to 9 ASCII letters (a-z, A-Z).
29pub const PLAYER_NAME_TAG: &str = "player_name";
30/// The room display-name rule: up to 24 printable extended-ASCII
31/// characters, empty means the server autogenerates one.
32pub const ROOM_NAME_TAG: &str = "room_name";
33
34/// Every tag this launcher generation knows.
35pub const KNOWN_TAGS: [&str; 2] = [PLAYER_NAME_TAG, ROOM_NAME_TAG];
36
37/// Whether `tag` names a rule set this launcher enforces.
38#[must_use]
39pub fn is_known_tag(tag: &str) -> bool {
40 KNOWN_TAGS.contains(&tag)
41}
42
43/// Validates one value against the rule set `tag` names.
44///
45/// # Errors
46///
47/// Returns the human-readable reason the value was refused, phrased so
48/// it can be shown next to the field as-is. An empty room name is valid
49/// (the server autogenerates one); an empty player name is not.
50pub fn validate_value(tag: &str, value: &str) -> Result<(), String> {
51 match tag {
52 PLAYER_NAME_TAG => validate_player_name(value).map(|_| ()),
53 ROOM_NAME_TAG => validate_room_name(value),
54 other => Err(format!(
55 "unknown validation tag `{other}` (expected one of: {})",
56 KNOWN_TAGS.join(", ")
57 )),
58 }
59}
60
61/// The player-name rule set: trimmed, 1 to 9 ASCII letters. Returns the
62/// trimmed name, or the reason the input was refused.
63///
64/// # Errors
65///
66/// The three refusal reasons: missing, too long, non-letters.
67pub fn validate_player_name(raw: &str) -> Result<String, String> {
68 let name = raw.trim();
69 if name.is_empty() {
70 return Err("Enter a player name: 1 to 9 letters (a-z, A-Z).".to_owned());
71 }
72 if !(1..=9).contains(&name.len()) {
73 return Err("The player name is limited to 9 letters (a-z, A-Z).".to_owned());
74 }
75 if !name.chars().all(|c| c.is_ascii_alphabetic()) {
76 return Err("The player name takes letters only (a-z, A-Z).".to_owned());
77 }
78 Ok(name.to_owned())
79}
80
81/// The room-name rule set: empty is fine (server autogenerates),
82/// otherwise up to 24 printable extended-ASCII characters.
83///
84/// # Errors
85///
86/// The two refusal reasons: too long, characters outside the
87/// Windows-1252 printable range.
88pub fn validate_room_name(name: &str) -> Result<(), String> {
89 if name.is_empty() {
90 return Ok(());
91 }
92 if name.chars().count() > 24 {
93 return Err("The room name is limited to 24 characters.".to_owned());
94 }
95 if name
96 .chars()
97 .any(|c| !matches!(u32::from(c), 0x20..=0x7E | 0xA0..=0xFF))
98 {
99 return Err(
100 "The room name must use extended ASCII only (what a 1999 Windows app displays \
101 without surprises)."
102 .to_owned(),
103 );
104 }
105 Ok(())
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn player_name_accepts_one_to_nine_letters() {
114 // The boundary IS the contract: exactly 9 letters is valid.
115 assert_eq!(validate_value(PLAYER_NAME_TAG, "abcd").map(|_| ()), Ok(()));
116 assert_eq!(validate_value(PLAYER_NAME_TAG, "I").map(|_| ()), Ok(()));
117 assert_eq!(
118 validate_value(PLAYER_NAME_TAG, "SupremeXz").map(|_| ()),
119 Ok(())
120 );
121 assert!(validate_value(PLAYER_NAME_TAG, "SupremeXyz").is_err());
122 assert!(validate_value(PLAYER_NAME_TAG, "").is_err());
123 assert!(validate_value(PLAYER_NAME_TAG, " ").is_err());
124 assert!(validate_value(PLAYER_NAME_TAG, "player1").is_err());
125 assert!(validate_value(PLAYER_NAME_TAG, "café").is_err());
126 // Trimming is part of the rule, the stored name has no padding.
127 assert_eq!(validate_player_name(" sam ").as_deref(), Ok("sam"));
128 }
129
130 #[test]
131 fn room_name_allows_empty_and_extended_ascii_up_to_24() {
132 assert_eq!(validate_value(ROOM_NAME_TAG, ""), Ok(()));
133 assert_eq!(
134 validate_value(ROOM_NAME_TAG, "Fresh Powder").map(|_| ()),
135 Ok(())
136 );
137 // Latin-1 supplement letters are printable extended ASCII.
138 assert_eq!(
139 validate_value(ROOM_NAME_TAG, "Piste Français").map(|_| ()),
140 Ok(())
141 );
142 // The boundary IS the contract: exactly 24 characters is valid
143 // and 25 is refused, counting characters and not bytes, so
144 // multibyte extended-ASCII names measure like the server's
145 // mb_strlen.
146 let twentyfour = "a".repeat(24);
147 assert_eq!(validate_value(ROOM_NAME_TAG, &twentyfour).map(|_| ()), Ok(()));
148 assert!(validate_value(ROOM_NAME_TAG, &"a".repeat(25)).is_err());
149 assert_eq!(
150 validate_value(ROOM_NAME_TAG, &"é".repeat(24)).map(|_| ()),
151 Ok(())
152 );
153 assert!(validate_value(ROOM_NAME_TAG, &"é".repeat(25)).is_err());
154 // Outside the printable range: above 0xFF, control chars, CJK.
155 assert!(validate_value(ROOM_NAME_TAG, "powder\u{100}").is_err());
156 assert!(validate_value(ROOM_NAME_TAG, "powder\u{7F}").is_err());
157 assert!(validate_value(ROOM_NAME_TAG, "粉末").is_err());
158 }
159
160 #[test]
161 fn unknown_tags_are_refused_with_the_known_list() {
162 let reason = validate_value("datepicker", "x").unwrap_err();
163 assert!(reason.contains("player_name") && reason.contains("room_name"));
164 assert!(is_known_tag(ROOM_NAME_TAG));
165 assert!(!is_known_tag("datepicker"));
166 }
167}