1use 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 pub id: String,
16 pub name: String,
18 pub tagline: String,
20 pub tags: [String; 3],
22 pub form_stat: String,
24 pub form_label: String,
26 pub goal: String,
28 pub deciding: String,
30 pub robot: String,
32 #[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 pub build_ai_ml: Option<String>,
44 pub build_code: Option<String>,
46 pub test: Option<String>,
47 pub upload: Option<String>,
48}
49
50pub 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 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 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}