botstadia-interface 0.1.0

BotStadia's shared contracts: arena and agent WIT worlds, arena declarations, payloads, replay archives, metrics files and player messages.
Documentation
//! An arena's `arena.toml`: its registry row and everything the site says
//! about it that isn't in the declaration.
//!
//! Site copy is for people who've never read the brief, so [`ArenaCopy::problems`]
//! flags the brief's own terms (anchor, void, digest, …) in it.

use serde::{Deserialize, Serialize};

use crate::declaration::Declaration;

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ArenaCopy {
    /// The slug, matching the declaration's arena id.
    pub id: String,
    /// The display name.
    pub name: String,
    /// One line for cards and the arena's page.
    pub tagline: String,
    /// Three short words for a card footer.
    pub tags: [String; 3],
    /// The key of the declared stat the leaderboard's form column aggregates.
    pub form_stat: String,
    /// The form column's heading, e.g. "Punches, last 10".
    pub form_label: String,
    /// The arena page's Brief: what wins.
    pub goal: String,
    /// How a session is decided.
    pub deciding: String,
    /// What the robot is.
    pub robot: String,
    /// Arena-specific notes for the build guide's sections. Each is optional;
    /// the site's own generic guide covers what isn't said.
    #[serde(default)]
    pub guide: GuideNotes,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GuideNotes {
    pub setup: Option<String>,
    /// The "with AI/ML" track.
    pub build_ai_ml: Option<String>,
    /// The "with code" track.
    pub build_code: Option<String>,
    pub test: Option<String>,
    pub upload: Option<String>,
}

/// Words the site never uses, with what to say instead.
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
    }

    /// Everything wrong with this copy, checked against the declaration when
    /// there is one.
    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());
        // "replays" and "voided" are still the terms.
        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"));
    }
}