clay-borg/games/ground/src/catalog.rs
tegwick e75f3afe4d CB-WP-0048 T01: Configuration and Rules — identity split from behaviour
catalog.rs reads ground-game's schema 2 and refuses an unknown schema by
name: a reader that silently accepted schema 1 would answer questions
about aspects over a file that has none.

config.rs holds the two types ADR-0022 chose. Configuration is identity
and round-trips anything the catalog names, including modules with no
kernel path; Rules is behaviour, exhaustive, no catch-all. resolve() is
the boundary and emits the two distinct errors -- "known module with no
kernel path (status: proposed)" vs "not a module the catalog has" --
which is the entire reason this shape was chosen over a per-aspect enum.

Nothing about aspects, defaults or module status is written in our
source; all of it is read. The tests find the proposed module by
searching for status: proposed rather than naming one, so implementing
it upstream makes the test look elsewhere instead of going stale.

Four mutations, four red. scoped_plus_attack_soothe now resolves -- the
combination the three-armed enum could not express.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:30:05 +02:00

182 lines
5.9 KiB
Rust

//! 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<String>,
/// The monolithic experiment ids this module used to be part of.
#[serde(default)]
pub legacy_experiment_ids: Vec<String>,
#[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<String>,
/// 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<String>,
#[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<String>,
#[serde(default)]
aspects: Vec<Aspect>,
#[serde(default)]
modules: Vec<Module>,
#[serde(default)]
profiles: Vec<Profile>,
}
/// The catalog, parsed.
#[derive(Debug, Clone)]
pub struct Catalog {
pub default_baseline: String,
pub default_profile: Option<String>,
pub aspects: Vec<Aspect>,
pub modules: Vec<Module>,
pub profiles: Vec<Profile>,
}
/// 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<Catalog, String> {
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<String, String> {
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())
}
}