diff --git a/games/ground/src/catalog.rs b/games/ground/src/catalog.rs new file mode 100644 index 0000000..dcec8b7 --- /dev/null +++ b/games/ground/src/catalog.rs @@ -0,0 +1,182 @@ +//! ground-game's rules catalog, schema 2 (CB-WP-0048, [ADR-0022]). +//! +//! An **aspect** is an orthogonal design dimension of the game; a +//! **module** is one concrete answer on one aspect; a **profile** is a +//! named list of modules. A configuration is a baseline plus at most one +//! module per aspect — a point in aspect space. +//! +//! **This reads; it does not decide.** The aspect list, the module list, +//! which modules are default and which are merely proposed are all +//! ground-game's (ADR-0011). Hardcoding any of them here is F25's shape: +//! a second copy of a number the edition already prints. +//! +//! [ADR-0022]: ../../../decisions/ADR-0022-a-configuration-is-a-point-in-aspect-space.md + +use serde::Deserialize; +use std::collections::BTreeMap; + +/// The vendored catalog. Digested and freshness-checked by +/// `edition-check` against the sibling checkout. +const CATALOG_YAML: &str = include_str!("../../../editions/catalog.yaml"); + +/// An orthogonal design dimension. +#[derive(Debug, Clone, Deserialize)] +pub struct Aspect { + pub id: String, + #[serde(default)] + pub title: String, + pub default_module: String, + #[serde(default)] + pub summary: String, +} + +/// One concrete design on one aspect. +#[derive(Debug, Clone, Deserialize)] +pub struct Module { + pub module_id: String, + pub aspect: String, + #[serde(default)] + pub path: String, + #[serde(default)] + pub is_default: bool, + #[serde(default = "yes")] + pub selectable: bool, + /// `baseline-default`, `measured`, `unmeasured`, `proposed`, … + /// + /// **Read, never assumed.** CB-RES-0010 §5.6 requires a proposed + /// module to be refused or to no-op *loudly*; we refuse, and this is + /// the field that says which are which. + #[serde(default)] + pub status: String, + #[serde(default)] + pub rules_delta: Option, + /// The monolithic experiment ids this module used to be part of. + #[serde(default)] + pub legacy_experiment_ids: Vec, + #[serde(default)] + pub summary: String, +} + +fn yes() -> bool { + true +} + +/// A named list of modules. +#[derive(Debug, Clone, Deserialize)] +pub struct Profile { + pub profile_id: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub modules: Vec, + /// The monolithic experiment this profile replaces, if any. **This is + /// where `--variant h2` resolves**, and it is read from the catalog + /// rather than mapped in our source (ADR-0022 D2). + #[serde(default)] + pub legacy_experiment_id: Option, + #[serde(default)] + pub summary: String, + #[serde(default)] + pub status: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct CatalogFile { + schema_version: u32, + default_baseline: String, + #[serde(default)] + default_profile: Option, + #[serde(default)] + aspects: Vec, + #[serde(default)] + modules: Vec, + #[serde(default)] + profiles: Vec, +} + +/// The catalog, parsed. +#[derive(Debug, Clone)] +pub struct Catalog { + pub default_baseline: String, + pub default_profile: Option, + pub aspects: Vec, + pub modules: Vec, + pub profiles: Vec, +} + +/// The schema this reader understands. +/// +/// **Refusing a newer schema is the point.** Schema 1 named monolithic +/// `variant_id` packages; a reader that silently accepted either would +/// answer questions about aspects over a file that has none, which is the +/// wrong-subject family (ADR-0018). If ground-game moves to schema 3 this +/// says so by name rather than returning an empty aspect list. +pub const SCHEMA: u32 = 2; + +/// Parse the vendored catalog. +pub fn catalog() -> Result { + let f: CatalogFile = + serde_yaml::from_str(CATALOG_YAML).map_err(|e| format!("catalog.yaml: {e}"))?; + if f.schema_version != SCHEMA { + return Err(format!( + "catalog.yaml is schema {}; this reader understands schema {SCHEMA} \ + — the aspect vocabulary may have moved", + f.schema_version + )); + } + if f.aspects.is_empty() { + return Err("catalog.yaml declares no aspects".into()); + } + Ok(Catalog { + default_baseline: f.default_baseline, + default_profile: f.default_profile, + aspects: f.aspects, + modules: f.modules, + profiles: f.profiles, + }) +} + +impl Catalog { + pub fn aspect(&self, id: &str) -> Option<&Aspect> { + self.aspects.iter().find(|a| a.id == id) + } + + pub fn module(&self, id: &str) -> Option<&Module> { + self.modules.iter().find(|m| m.module_id == id) + } + + pub fn profile(&self, id: &str) -> Option<&Profile> { + self.profiles.iter().find(|p| p.profile_id == id) + } + + /// Every aspect at its default module — the printed game. + pub fn defaults(&self) -> BTreeMap { + self.aspects + .iter() + .map(|a| (a.id.clone(), a.default_module.clone())) + .collect() + } + + /// The profile a legacy experiment id names (ADR-0022 D2). + /// + /// **From the catalog's own `legacy_experiment_id`**, so the mapping + /// has one home. A table in our source would be a second copy of + /// ground-game's decision, which is exactly what F25 is about. + pub fn profile_for_legacy(&self, experiment_id: &str) -> Option<&Profile> { + self.profiles + .iter() + .find(|p| p.legacy_experiment_id.as_deref() == Some(experiment_id)) + } + + /// A module id's aspect, taken from the module entry rather than by + /// splitting on the dot. + /// + /// **The dot is a naming convention, not a schema.** `module_id` + /// carries the aspect prefix today and the entry carries an explicit + /// `aspect:` field; parsing the string would work until the day a + /// module id contains a second dot, and would then be confidently + /// wrong rather than absent. + pub fn aspect_of(&self, module_id: &str) -> Option<&str> { + self.module(module_id).map(|m| m.aspect.as_str()) + } +} diff --git a/games/ground/src/config.rs b/games/ground/src/config.rs new file mode 100644 index 0000000..4337cbf --- /dev/null +++ b/games/ground/src/config.rs @@ -0,0 +1,460 @@ +//! A configuration is a point in aspect space ([ADR-0022]). +//! +//! ## Two types, because there are two jobs +//! +//! [`Configuration`] is **identity**: which baseline, which module on each +//! aspect. It is GAME-stratum data and it round-trips **anything the +//! catalog names**, including modules this kernel cannot run. +//! +//! [`Rules`] is **behaviour**: what the kernel does. It is ENGINE-stratum +//! code, exhaustive, with no catch-all — so a module ground-game adds +//! cannot be silently ignored, the compiler names it. +//! +//! [`Configuration::resolve`] is the boundary, and it is where the two +//! errors live: +//! +//! ```text +//! problem_deal.pressure_deck is a known module with no kernel path +//! (catalog status: proposed) +//! problem_deal.presure_deck is not a module the catalog has +//! ``` +//! +//! **Two facts, two errors.** A per-aspect enum could only say "unknown +//! module" for both, making a proposed module indistinguishable from a +//! typo — a false statement about the edition, and this project's +//! signature failure shape (ADR-0018). +//! +//! [ADR-0022]: ../../../decisions/ADR-0022-a-configuration-is-a-point-in-aspect-space.md + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Which baseline, and which module on each aspect (ADR-0022 D1). +/// +/// **In the state, therefore in the hash, therefore in the recording** — +/// the same reasoning that put `variant` there. `modules` is always +/// *resolved*: every aspect the catalog declares has an entry, defaults +/// filled in, so a recording states the whole point in aspect space +/// rather than the delta from an assumed origin. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Configuration { + /// The content package (ADR-0011), e.g. `ground-darvo-r0`. + pub baseline: String, + /// aspect id → module id, resolved. + pub modules: BTreeMap, + /// The profile that was asked for, if one was. **Kept for the + /// account, never consulted for behaviour** — two runs with the same + /// modules play identically whether or not a profile named them, and + /// a reader still wants to know what was requested. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, +} + +impl Default for Configuration { + /// The printed game: every aspect at its default module. + /// + /// Falls back to an empty module map if the catalog cannot be read, + /// which is the baseline by construction — every `Rules` field + /// defaults to the printed behaviour, so a missing catalog cannot + /// silently turn a module *on*. + fn default() -> Self { + let modules = crate::catalog::catalog() + .map(|c| c.defaults()) + .unwrap_or_default(); + Configuration { + baseline: "ground-darvo-r0".to_string(), + modules, + profile: None, + } + } +} + +/// How unclaimed Problems raise Stress at Round End. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum ProblemStress { + /// `problem_stress.none` — the printed rules. + #[default] + None, + /// `problem_stress.flat_any_open` — +1 to every seat if any Problem + /// is unclaimed. Former H1-A. + FlatAnyOpen, + /// `problem_stress.scoped` — +1 per unclaimed Problem to the seats in + /// its `stress_scope`. Former H2. + Scoped, +} + +/// Whether resolving ATTACK can reduce the attacker's Stress. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum AttackRelief { + /// `attack_relief.none` — the printed rules. + #[default] + None, + /// `attack_relief.self_soothe_ge4` — an uncancelled ATTACK by a seat + /// at Stress ≥4 gives that seat −1. Former H1-B. + SelfSootheGe4, +} + +/// What the kernel actually does (ENGINE stratum). +/// +/// **No catch-all anywhere.** CB-WP-0034's exhaustive match caught two +/// commands that would have shipped as `Debug` dumps, before any test +/// ran; the same discipline here means a module added to the catalog is a +/// compile error at the resolve site rather than a silent no-op. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Rules { + pub problem_stress: ProblemStress, + pub attack_relief: AttackRelief, +} + +impl Configuration { + /// Build from a baseline plus an explicit module list, filling every + /// other aspect with its default. + /// + /// **Refuses two modules on one aspect** — that is ground-game's rule + /// about the game, so checking it is GAME↔MODEL *validation*, not + /// verification of our arithmetic (ADR-0022 D0). + pub fn from_modules(baseline: &str, modules: &[String]) -> Result { + let cat = crate::catalog::catalog()?; + let mut chosen: BTreeMap = cat.defaults(); + let mut claimed: BTreeMap = BTreeMap::new(); + for id in modules { + let Some(aspect) = cat.aspect_of(id) else { + return Err(unknown_module(&cat, id)); + }; + if let Some(first) = claimed.get(aspect) { + return Err(format!( + "{first} and {id} are both on aspect {aspect} — a configuration \ + takes at most one module per aspect" + )); + } + claimed.insert(aspect.to_string(), id.clone()); + chosen.insert(aspect.to_string(), id.clone()); + } + Ok(Configuration { + baseline: baseline.to_string(), + modules: chosen, + profile: None, + }) + } + + /// Build from a named profile. + pub fn from_profile(profile_id: &str) -> Result { + let cat = crate::catalog::catalog()?; + let p = cat.profile(profile_id).ok_or_else(|| { + format!( + "unknown profile {profile_id:?} (the catalog has {})", + cat.profiles + .iter() + .map(|p| p.profile_id.as_str()) + .collect::>() + .join(", ") + ) + })?; + let mut c = Configuration::from_modules(&cat.default_baseline, &p.modules)?; + c.profile = Some(p.profile_id.clone()); + Ok(c) + } + + /// Build from anything a person might type: a profile id, a legacy + /// experiment id, or a short alias (ADR-0022 D2). + /// + /// **Legacy ids resolve through the catalog's own + /// `legacy_experiment_id`**, so ground-game's mapping has one home. + pub fn select(name: &str) -> Result { + let cat = crate::catalog::catalog()?; + let want = name.trim(); + if cat.profile(want).is_some() { + return Configuration::from_profile(want); + } + if let Some(p) = cat.profile_for_legacy(want) { + let id = p.profile_id.clone(); + return Configuration::from_profile(&id); + } + // Short aliases the maintainer actually types. `baseline` and + // `r0` name the printed game; `h1`/`h2` are profile ids already. + if matches!(want, "ground-darvo-r0" | "r0" | "baseline") { + return Configuration::from_profile("baseline") + .or_else(|_| Ok(Configuration::default())); + } + Err(format!( + "unknown configuration {name:?} (profiles: {}; legacy ids: {})", + cat.profiles + .iter() + .map(|p| p.profile_id.as_str()) + .collect::>() + .join(", "), + cat.profiles + .iter() + .filter_map(|p| p.legacy_experiment_id.as_deref()) + .collect::>() + .join(", ") + )) + } + + /// The module on one aspect, if the catalog declares that aspect. + pub fn module_on(&self, aspect: &str) -> Option<&str> { + self.modules.get(aspect).map(|s| s.as_str()) + } + + /// Every module that is not its aspect's default — what a reader + /// means by "which modules are live". + pub fn active_modules(&self) -> Vec { + let Ok(cat) = crate::catalog::catalog() else { + return Vec::new(); + }; + let mut out: Vec = self + .modules + .iter() + .filter(|(aspect, id)| cat.aspect(aspect).is_some_and(|a| a.default_module != **id)) + .map(|(_, id)| id.clone()) + .collect(); + out.sort(); + out + } + + /// **Identity → behaviour.** The boundary in ADR-0022 D1. + /// + /// Refuses, by name and with the reason, any module the catalog has + /// and this kernel cannot run. + pub fn resolve(&self) -> Result { + let cat = crate::catalog::catalog()?; + let mut rules = Rules::default(); + for (aspect, id) in &self.modules { + // An aspect the catalog does not declare is not something to + // guess about. + if cat.aspect(aspect).is_none() { + return Err(format!("{aspect:?} is not an aspect the catalog declares")); + } + match id.as_str() { + "problem_stress.none" => rules.problem_stress = ProblemStress::None, + "problem_stress.flat_any_open" => rules.problem_stress = ProblemStress::FlatAnyOpen, + "problem_stress.scoped" => rules.problem_stress = ProblemStress::Scoped, + "attack_relief.none" => rules.attack_relief = AttackRelief::None, + "attack_relief.self_soothe_ge4" => { + rules.attack_relief = AttackRelief::SelfSootheGe4 + } + // Aspects whose only implemented module is the default. + // Naming them is not padding: it is the difference + // between "we run the printed rules here" and "we did not + // look", and the `other` arm below can then be a real + // refusal rather than a shrug. + "end_condition.fixed_rounds_5" | "problem_deal.fixed_setup" => {} + other => return Err(no_kernel_path(&cat, other)), + } + } + Ok(rules) + } +} + +/// The module is not in the catalog at all — probably a typo. +fn unknown_module(cat: &crate::catalog::Catalog, id: &str) -> String { + let known: Vec<&str> = cat.modules.iter().map(|m| m.module_id.as_str()).collect(); + format!( + "{id:?} is not a module the catalog has (it has {})", + known.join(", ") + ) +} + +/// The module IS in the catalog, and this kernel cannot run it. +/// +/// **A different sentence from `unknown_module`, deliberately.** These +/// are different facts about the world and reporting them identically is +/// how a proposed module reads as a typo (ADR-0022 D1). +fn no_kernel_path(cat: &crate::catalog::Catalog, id: &str) -> String { + match cat.module(id) { + Some(m) => format!( + "{id} is a known module with no kernel path (catalog status: {}{})", + if m.status.is_empty() { + "unstated" + } else { + &m.status + }, + if m.rules_delta.is_none() { + ", and it declares no rules_delta" + } else { + "" + } + ), + None => unknown_module(cat, id), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// **A proposed module is refused BY NAME, distinguishably from a + /// typo** (ADR-0022 D1). + /// + /// This is the test the whole design was chosen for. A per-aspect + /// enum could only say "unknown module" for both, which makes a + /// module the edition really has read as a misspelling — a false + /// statement about the edition, and the wrong-subject family again. + #[test] + fn a_proposed_module_and_a_typo_are_different_errors() { + let cat = crate::catalog::catalog().expect("catalog.yaml"); + // A module the catalog has, with a rules_delta, that no kernel + // path implements. Found in the catalog, not hardcoded: if + // ground-game implements it upstream this test looks elsewhere + // rather than going stale. + let proposed = cat + .modules + .iter() + .find(|m| m.status == "proposed") + .expect("the catalog has at least one proposed module"); + + let mut c = Configuration::default(); + c.modules + .insert(proposed.aspect.clone(), proposed.module_id.clone()); + let refused = c.resolve().expect_err("a proposed module must be refused"); + assert!( + refused.contains(&proposed.module_id) && refused.contains("no kernel path"), + "a proposed module was not refused by name: {refused}" + ); + assert!( + !refused.contains("is not a module the catalog has"), + "a module the catalog HAS was reported as unknown: {refused}" + ); + + // And a real typo says the other thing. + let mut typo = Configuration::default(); + typo.modules.insert( + proposed.aspect.clone(), + format!("{}_zzz", proposed.module_id), + ); + let unknown = typo.resolve().expect_err("a typo must be refused"); + assert!( + unknown.contains("is not a module the catalog has"), + "a typo was not reported as unknown: {unknown}" + ); + + // The two sentences must not be the same sentence — which is the + // entire claim, so it is asserted rather than left to reading. + assert_ne!(refused, unknown); + } + + /// **Two modules on one aspect are refused** — GAME↔MODEL validation. + #[test] + fn a_configuration_takes_at_most_one_module_per_aspect() { + let e = Configuration::from_modules( + "ground-darvo-r0", + &[ + "problem_stress.scoped".to_string(), + "problem_stress.flat_any_open".to_string(), + ], + ) + .expect_err("two modules on one aspect must be refused"); + assert!( + e.contains("problem_stress") && e.contains("at most one module per aspect"), + "{e}" + ); + } + + /// **A configuration must be NAMEABLE before it is runnable.** + /// + /// The identity type round-trips anything the catalog says, including + /// what this kernel cannot execute — that is the property that keeps + /// a GAME-stratum addition from becoming an ENGINE-stratum parse + /// failure (ADR-0022 D0). + #[test] + fn a_module_with_no_kernel_path_still_names_a_configuration() { + let cat = crate::catalog::catalog().expect("catalog.yaml"); + let proposed = cat + .modules + .iter() + .find(|m| m.status == "proposed") + .expect("a proposed module"); + let c = Configuration::from_modules( + "ground-darvo-r0", + std::slice::from_ref(&proposed.module_id), + ) + .expect("selecting a catalog module must NAME a configuration"); + assert_eq!( + c.module_on(&proposed.aspect), + Some(proposed.module_id.as_str()) + ); + // It round-trips, so a recording can carry it. + let s = serde_yaml::to_string(&c).unwrap(); + let back: Configuration = serde_yaml::from_str(&s).unwrap(); + assert_eq!(back, c); + // And it still refuses to RUN. + assert!(c.resolve().is_err()); + } + + /// The printed game is every aspect at its default, and it resolves + /// to the printed behaviour. + #[test] + fn the_default_configuration_is_the_printed_game() { + let cat = crate::catalog::catalog().expect("catalog.yaml"); + let c = Configuration::default(); + assert_eq!(c.modules.len(), cat.aspects.len(), "an aspect is unfilled"); + for a in &cat.aspects { + assert_eq!(c.module_on(&a.id), Some(a.default_module.as_str())); + } + assert_eq!(c.resolve().unwrap(), Rules::default()); + assert!( + c.active_modules().is_empty(), + "the printed game has no live modules" + ); + } + + /// **Legacy ids alias forever, through the CATALOG** (ADR-0022 D2). + /// + /// Asserted on the resolved configuration rather than on outcomes, so + /// the equivalence is exact and not a coincidence of behaviour. + #[test] + fn legacy_experiment_ids_expand_to_their_profiles() { + let cat = crate::catalog::catalog().expect("catalog.yaml"); + for p in &cat.profiles { + let Some(legacy) = p.legacy_experiment_id.as_deref() else { + continue; + }; + let by_legacy = + Configuration::select(legacy).unwrap_or_else(|e| panic!("{legacy}: {e}")); + let by_profile = Configuration::from_profile(&p.profile_id).unwrap(); + assert_eq!( + by_legacy.modules, by_profile.modules, + "{legacy} and profile {} are not the same point in aspect space", + p.profile_id + ); + } + // The two the maintainer types, spelled out — a loop over the + // catalog would pass if the catalog listed no legacy ids at all. + assert_eq!( + Configuration::select("h2").unwrap().active_modules(), + vec!["problem_stress.scoped".to_string()] + ); + assert_eq!( + Configuration::select("h1").unwrap().active_modules(), + vec![ + "attack_relief.self_soothe_ge4".to_string(), + "problem_stress.flat_any_open".to_string(), + ], + "H1 is TWO modules on two aspects — that decomposition is the \ + whole point of schema 2" + ); + assert!(Configuration::select("r0") + .unwrap() + .active_modules() + .is_empty()); + } + + /// **The combination that could not previously be named.** + #[test] + fn two_aspects_can_carry_a_module_at_once() { + let c = Configuration::select("scoped_plus_attack_soothe") + .expect("the catalog ships this profile"); + assert_eq!( + c.active_modules(), + vec![ + "attack_relief.self_soothe_ge4".to_string(), + "problem_stress.scoped".to_string(), + ] + ); + let r = c.resolve().expect("both modules have kernel paths"); + assert_eq!(r.problem_stress, ProblemStress::Scoped); + assert_eq!(r.attack_relief, AttackRelief::SelfSootheGe4); + // The old three-armed selector had no arm for this, which is why + // it was not "unimplemented" but unrepresentable. + } +} diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 7a55309..cdaab0b 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -16,6 +16,19 @@ pub mod bot; /// only one of them is optional. pub mod edition; +/// ground-game's rules catalog, schema 2 (CB-WP-0048, ADR-0022). +/// +/// Gated with `scenarios` because it parses YAML and that is where +/// `serde_yaml` lives. The *identity* type in `config` is not gated: a +/// configuration must be readable off a recording whether or not this +/// build can validate it against the catalog. +#[cfg(feature = "scenarios")] +pub mod catalog; + +/// A configuration is a point in aspect space (ADR-0022). +#[cfg(feature = "scenarios")] +pub mod config; + /// K13's per-player projection (CB-WP-0008 T02) — the trait's first /// implementor. Needs the runtime's `Project`, which the game already /// depends on, so it is not feature-gated either.