use serde::{Deserialize, Serialize};
use crate::declaration::Declaration;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ArenaCopy {
pub id: String,
pub name: String,
pub tagline: String,
pub tags: [String; 3],
pub form_stat: String,
pub form_label: String,
pub goal: String,
pub deciding: String,
pub robot: String,
#[serde(default)]
pub guide: GuideNotes,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GuideNotes {
pub setup: Option<String>,
pub build_ai_ml: Option<String>,
pub build_code: Option<String>,
pub test: Option<String>,
pub upload: Option<String>,
}
pub const BRIEF_TERMS: &[(&str, &str)] = &[
(
"anchor",
"say what happened in plain words, e.g. \"the session started\"",
),
("void", "\"refunded because of a system fault\""),
(
"digest",
"keep checkable facts in a labelled details section",
),
("enclave", "\"our locked-down machines\""),
("standing order", "\"auto-back\""),
("inference charge", "\"model complexity\""),
("replay", "\"watching a session\""),
("keeper", "say what happens, not which program does it"),
("bundle", "\"bot\" or \"your bot's files\""),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CopyProblem {
pub field: &'static str,
pub text: String,
}
impl ArenaCopy {
#[cfg(feature = "toml")]
pub fn from_toml(text: &str) -> Result<Self, toml::de::Error> {
toml::from_str(text)
}
#[cfg(feature = "toml")]
pub fn to_toml(&self) -> String {
toml::to_string_pretty(self).expect("arena copy always serialises")
}
fn fields(&self) -> Vec<(&'static str, &str)> {
let mut fields = vec![
("name", self.name.as_str()),
("tagline", self.tagline.as_str()),
("tags", self.tags[0].as_str()),
("tags", self.tags[1].as_str()),
("tags", self.tags[2].as_str()),
("form_label", self.form_label.as_str()),
("goal", self.goal.as_str()),
("deciding", self.deciding.as_str()),
("robot", self.robot.as_str()),
];
let guide = &self.guide;
for (field, text) in [
("guide.setup", &guide.setup),
("guide.build_ai_ml", &guide.build_ai_ml),
("guide.build_code", &guide.build_code),
("guide.test", &guide.test),
("guide.upload", &guide.upload),
] {
if let Some(text) = text {
fields.push((field, text.as_str()));
}
}
fields
}
pub fn problems(&self, declaration: Option<&Declaration>) -> Vec<CopyProblem> {
let mut problems = Vec::new();
let mut problem =
|field: &'static str, text: String| problems.push(CopyProblem { field, text });
if !crate::declaration::is_slug(&self.id) {
problem(
"id",
format!("`{}` must be lowercase letters, digits and dashes", self.id),
);
}
for (field, text) in self.fields() {
if text.trim().is_empty() {
problem(field, "is empty".into());
}
let lower = text.to_lowercase();
for (term, instead) in BRIEF_TERMS {
if contains_word(&lower, term) {
problem(
field,
format!("uses the brief's term \"{term}\": {instead}"),
);
}
}
}
if let Some(declaration) = declaration {
if declaration.arena_id != self.id {
problem(
"id",
format!(
"`{}` doesn't match the declaration's `{}`",
self.id, declaration.arena_id
),
);
}
if declaration.stat_index(&self.form_stat).is_none() {
problem(
"form_stat",
format!("`{}` isn't a declared stat", self.form_stat),
);
}
}
problems
}
}
fn contains_word(text: &str, term: &str) -> bool {
text.match_indices(term).any(|(start, _)| {
let before = text[..start].chars().next_back();
let after = text[start + term.len()..].chars().next();
let boundary = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric());
boundary(before)
&& (boundary(after) || after.is_some_and(|c| c == 's' || c == 'e' || c == 'i'))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::declaration::tests::sample;
fn copy() -> ArenaCopy {
toml::from_str(
r#"
id = "sample"
name = "Sample"
tagline = "Two bots push each other around."
tags = ["1v1", "Push", "Contact"]
form_stat = "hits"
form_label = "Hits, last 10"
goal = "Land more hits."
deciding = "Most hits wins."
robot = "A sliding block with two thrusters."
[guide]
build_code = "Start from the scripted bot."
"#,
)
.unwrap()
}
#[test]
fn good_copy_has_no_problems() {
assert_eq!(copy().problems(Some(&sample())), Vec::new());
}
#[test]
fn brief_terms_and_unknown_stats_are_flagged() {
let mut copy = copy();
copy.goal = "Win before the anchor. Replays are fun.".into();
copy.tagline = "Avoid the voidance zone.".into();
copy.form_stat = "nope".into();
let problems = copy.problems(Some(&sample()));
assert!(
problems
.iter()
.any(|problem| problem.field == "goal" && problem.text.contains("anchor"))
);
assert!(
problems
.iter()
.any(|problem| problem.field == "goal" && problem.text.contains("replay"))
);
assert!(!problems.iter().any(|problem| problem.field == "tagline"));
assert!(problems.iter().any(|problem| problem.field == "form_stat"));
}
}