Skip to main content

botstadia_interface/
copy.rs

1//! An arena's `arena.toml`: its registry row and everything the site says
2//! about it that isn't in the declaration.
3//!
4//! Site copy is for people who've never read the brief, so [`ArenaCopy::problems`]
5//! flags the brief's own terms (anchor, void, digest, …) in it.
6
7use serde::{Deserialize, Serialize};
8
9use crate::declaration::Declaration;
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct ArenaCopy {
14    /// The slug, matching the declaration's arena id.
15    pub id: String,
16    /// The display name.
17    pub name: String,
18    /// One line for cards and the arena's page.
19    pub tagline: String,
20    /// Three short words for a card footer.
21    pub tags: [String; 3],
22    /// The key of the declared stat the leaderboard's form column aggregates.
23    pub form_stat: String,
24    /// The form column's heading, e.g. "Punches, last 10".
25    pub form_label: String,
26    /// The arena page's Brief: what wins.
27    pub goal: String,
28    /// How a session is decided.
29    pub deciding: String,
30    /// What the robot is.
31    pub robot: String,
32    /// Arena-specific notes for the build guide's sections. Each is optional;
33    /// the site's own generic guide covers what isn't said.
34    #[serde(default)]
35    pub guide: GuideNotes,
36}
37
38#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct GuideNotes {
41    pub setup: Option<String>,
42    /// The "with AI/ML" track.
43    pub build_ai_ml: Option<String>,
44    /// The "with code" track.
45    pub build_code: Option<String>,
46    pub test: Option<String>,
47    pub upload: Option<String>,
48}
49
50/// Words the site never uses, with what to say instead.
51pub const BRIEF_TERMS: &[(&str, &str)] = &[
52    (
53        "anchor",
54        "say what happened in plain words, e.g. \"the session started\"",
55    ),
56    ("void", "\"refunded because of a system fault\""),
57    (
58        "digest",
59        "keep checkable facts in a labelled details section",
60    ),
61    ("enclave", "\"our locked-down machines\""),
62    ("standing order", "\"auto-back\""),
63    ("inference charge", "\"model complexity\""),
64    ("replay", "\"watching a session\""),
65    ("keeper", "say what happens, not which program does it"),
66    ("bundle", "\"bot\" or \"your bot's files\""),
67];
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct CopyProblem {
71    pub field: &'static str,
72    pub text: String,
73}
74
75impl ArenaCopy {
76    #[cfg(feature = "toml")]
77    pub fn from_toml(text: &str) -> Result<Self, toml::de::Error> {
78        toml::from_str(text)
79    }
80
81    #[cfg(feature = "toml")]
82    pub fn to_toml(&self) -> String {
83        toml::to_string_pretty(self).expect("arena copy always serialises")
84    }
85
86    fn fields(&self) -> Vec<(&'static str, &str)> {
87        let mut fields = vec![
88            ("name", self.name.as_str()),
89            ("tagline", self.tagline.as_str()),
90            ("tags", self.tags[0].as_str()),
91            ("tags", self.tags[1].as_str()),
92            ("tags", self.tags[2].as_str()),
93            ("form_label", self.form_label.as_str()),
94            ("goal", self.goal.as_str()),
95            ("deciding", self.deciding.as_str()),
96            ("robot", self.robot.as_str()),
97        ];
98        let guide = &self.guide;
99        for (field, text) in [
100            ("guide.setup", &guide.setup),
101            ("guide.build_ai_ml", &guide.build_ai_ml),
102            ("guide.build_code", &guide.build_code),
103            ("guide.test", &guide.test),
104            ("guide.upload", &guide.upload),
105        ] {
106            if let Some(text) = text {
107                fields.push((field, text.as_str()));
108            }
109        }
110        fields
111    }
112
113    /// Everything wrong with this copy, checked against the declaration when
114    /// there is one.
115    pub fn problems(&self, declaration: Option<&Declaration>) -> Vec<CopyProblem> {
116        let mut problems = Vec::new();
117        let mut problem =
118            |field: &'static str, text: String| problems.push(CopyProblem { field, text });
119        if !crate::declaration::is_slug(&self.id) {
120            problem(
121                "id",
122                format!("`{}` must be lowercase letters, digits and dashes", self.id),
123            );
124        }
125        for (field, text) in self.fields() {
126            if text.trim().is_empty() {
127                problem(field, "is empty".into());
128            }
129            let lower = text.to_lowercase();
130            for (term, instead) in BRIEF_TERMS {
131                if contains_word(&lower, term) {
132                    problem(
133                        field,
134                        format!("uses the brief's term \"{term}\": {instead}"),
135                    );
136                }
137            }
138        }
139        if let Some(declaration) = declaration {
140            if declaration.arena_id != self.id {
141                problem(
142                    "id",
143                    format!(
144                        "`{}` doesn't match the declaration's `{}`",
145                        self.id, declaration.arena_id
146                    ),
147                );
148            }
149            if declaration.stat_index(&self.form_stat).is_none() {
150                problem(
151                    "form_stat",
152                    format!("`{}` isn't a declared stat", self.form_stat),
153                );
154            }
155        }
156        problems
157    }
158}
159
160fn contains_word(text: &str, term: &str) -> bool {
161    text.match_indices(term).any(|(start, _)| {
162        let before = text[..start].chars().next_back();
163        let after = text[start + term.len()..].chars().next();
164        let boundary = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric());
165        // "replays" and "voided" are still the terms.
166        boundary(before)
167            && (boundary(after) || after.is_some_and(|c| c == 's' || c == 'e' || c == 'i'))
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::declaration::tests::sample;
175
176    fn copy() -> ArenaCopy {
177        toml::from_str(
178            r#"
179id = "sample"
180name = "Sample"
181tagline = "Two bots push each other around."
182tags = ["1v1", "Push", "Contact"]
183form_stat = "hits"
184form_label = "Hits, last 10"
185goal = "Land more hits."
186deciding = "Most hits wins."
187robot = "A sliding block with two thrusters."
188
189[guide]
190build_code = "Start from the scripted bot."
191"#,
192        )
193        .unwrap()
194    }
195
196    #[test]
197    fn good_copy_has_no_problems() {
198        assert_eq!(copy().problems(Some(&sample())), Vec::new());
199    }
200
201    #[test]
202    fn brief_terms_and_unknown_stats_are_flagged() {
203        let mut copy = copy();
204        copy.goal = "Win before the anchor. Replays are fun.".into();
205        copy.tagline = "Avoid the voidance zone.".into();
206        copy.form_stat = "nope".into();
207        let problems = copy.problems(Some(&sample()));
208        assert!(
209            problems
210                .iter()
211                .any(|problem| problem.field == "goal" && problem.text.contains("anchor"))
212        );
213        assert!(
214            problems
215                .iter()
216                .any(|problem| problem.field == "goal" && problem.text.contains("replay"))
217        );
218        assert!(!problems.iter().any(|problem| problem.field == "tagline"));
219        assert!(problems.iter().any(|problem| problem.field == "form_stat"));
220    }
221}