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>
This commit is contained in:
parent
a87afca48f
commit
e75f3afe4d
3 changed files with 655 additions and 0 deletions
182
games/ground/src/catalog.rs
Normal file
182
games/ground/src/catalog.rs
Normal file
|
|
@ -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<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())
|
||||
}
|
||||
}
|
||||
460
games/ground/src/config.rs
Normal file
460
games/ground/src/config.rs
Normal file
|
|
@ -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<String, String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<Self, String> {
|
||||
let cat = crate::catalog::catalog()?;
|
||||
let mut chosen: BTreeMap<String, String> = cat.defaults();
|
||||
let mut claimed: BTreeMap<String, String> = 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<Self, String> {
|
||||
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::<Vec<_>>()
|
||||
.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<Self, String> {
|
||||
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::<Vec<_>>()
|
||||
.join(", "),
|
||||
cat.profiles
|
||||
.iter()
|
||||
.filter_map(|p| p.legacy_experiment_id.as_deref())
|
||||
.collect::<Vec<_>>()
|
||||
.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<String> {
|
||||
let Ok(cat) = crate::catalog::catalog() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<String> = 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<Rules, String> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue