CB-WP-0048 T02/T03/T04: Configuration replaces Variant in the state
Some checks failed
ci / check (push) Has been cancelled
Some checks failed
ci / check (push) Has been cancelled
The three-armed enum could not express a configuration carrying two
modules. It does now: --variant h1 expands to problem_stress.flat_any_open
AND attack_relief.self_soothe_ge4, and scoped_plus_attack_soothe plays.
Legacy names alias forever via serde(alias="variant") plus a
scalar-or-map deserialiser. serde(default) alone would have been a silent
migration bug -- every H1/H2 recording would have come back as baseline.
All 26 scenarios pass unchanged; none pins a state hash.
Three things shipped broken first, all caught by gates rather than by
reading:
1. `profile` was in the state hash. with_config(select("ground-darvo-r0"))
sets profile=Some("baseline") where setup alone leaves None, so two
states at THE SAME POINT IN ASPECT SPACE hashed differently and a
replay bundle stopped reproducing its own initial state. Now
serde(skip): a hash covers what determines play. This was the open
judgement from T01 and it did not survive contact with the replay path.
2. A YAML parse inside the event loop. rules() -> resolve() -> catalog()
re-parsed catalog.yaml per rule check; AM-6 fell to 9,345 events/s
against a 100,000 target. OnceLock, and aspect validation moved to
where a configuration is BUILT.
Then I nearly optimised a phantom: 470k still looked like a 3x
regression against the "~1.7M on bnt-lap001" reference in the gate's
own message. Making rules() free measured 491k -- this machine's
ceiling. Before optimising against a reference, measure the ceiling
with the suspect code removed.
3. The refusal did not fire on the path a player takes.
`--module problem_deal.pressure_deck` played a full baseline game and
reported success, because with_config is a builder and fell back to
the printed rules -- the silent no-op ADR-0022 exists to refuse. Every
unit test of resolve() passed. The helper was tested and the driver
was not, which is CB-WP-0033's finding verbatim. The new test asserts
refusal BY NAME and that no game was played.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
704b99975b
commit
48a7bce04d
12 changed files with 538 additions and 67 deletions
|
|
@ -70,6 +70,25 @@ pub struct TrialNote {
|
|||
pub text: String,
|
||||
}
|
||||
|
||||
/// A configuration, as one token for a log line (CB-WP-0048).
|
||||
///
|
||||
/// **The live modules, or the baseline id when none are.** The trial log
|
||||
/// stamps this so a note can be read back knowing which rules produced
|
||||
/// it (CB-WP-0046) — and a configuration is now a point in aspect space,
|
||||
/// so a single package name would be the wrong shape for a session
|
||||
/// carrying two modules.
|
||||
///
|
||||
/// Dots and commas are kept out: the value rides an HTML-comment marker
|
||||
/// attribute and the parser accepts `[A-Za-z0-9._-]`, so modules are
|
||||
/// joined with `+`.
|
||||
pub fn configuration_stamp(c: &games_ground::config::Configuration) -> String {
|
||||
let live = c.active_modules();
|
||||
if live.is_empty() {
|
||||
return c.baseline.clone();
|
||||
}
|
||||
live.join("+")
|
||||
}
|
||||
|
||||
/// Where game `n`'s recording goes (ADR-0019 D2).
|
||||
///
|
||||
/// **Game 1 keeps the path it was given.** GameDesign §5 documents that
|
||||
|
|
@ -225,7 +244,7 @@ impl Server {
|
|||
state_hash: hash[..12].to_string(),
|
||||
text: note.text.clone(),
|
||||
});
|
||||
write_trial_log(path, &log, state.variant.id())
|
||||
write_trial_log(path, &log, &configuration_stamp(&state.config))
|
||||
}
|
||||
|
||||
/// Begin the next game (ADR-0019 D1).
|
||||
|
|
@ -1290,7 +1309,7 @@ mod tests {
|
|||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: crate::table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
config: games_ground::config::Configuration::default(),
|
||||
},
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
out,
|
||||
|
|
@ -1482,7 +1501,7 @@ mod tests {
|
|||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: crate::table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
config: games_ground::config::Configuration::default(),
|
||||
},
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
out,
|
||||
|
|
|
|||
|
|
@ -137,6 +137,18 @@ fn render_player(id: PlayerId, p: &PlayerView, is_viewer: bool) -> String {
|
|||
}
|
||||
|
||||
/// One seat's whole picture, from the projection and nothing else.
|
||||
/// Every aspect and its module, in catalog order where available.
|
||||
fn full_configuration(c: &games_ground::config::Configuration) -> String {
|
||||
let mut parts = vec![format!("baseline={}", c.baseline)];
|
||||
for (aspect, module) in &c.modules {
|
||||
parts.push(format!("{aspect}={module}"));
|
||||
}
|
||||
if let Some(p) = &c.profile {
|
||||
parts.push(format!("profile={p}"));
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
pub fn render(view: &GroundView) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
|
|
@ -146,7 +158,12 @@ pub fn render(view: &GroundView) -> String {
|
|||
view.step,
|
||||
seat_name(view.lead),
|
||||
view.mode,
|
||||
view.variant.id(),
|
||||
// **Every aspect, not only the live ones** (ADR-0022 D3). This is
|
||||
// the machine-facing view — a maintainer diffing two states —
|
||||
// and a recording states the whole point in aspect space rather
|
||||
// than the delta from an assumed origin. The HTML page shows the
|
||||
// live modules, because a player is asking a different question.
|
||||
full_configuration(&view.config),
|
||||
// CB-WP-0047: WHICH of the four boards. The id, not the title,
|
||||
// because this is the machine-facing view — `cb-play inspect` is
|
||||
// read by a maintainer diffing states, where the HTML page is
|
||||
|
|
@ -502,8 +519,23 @@ mod tests {
|
|||
("mode", "mode BondedCoalitions"),
|
||||
// CB-WP-0044: the inspector must say which rules it is replaying,
|
||||
// for the same reason the page must.
|
||||
("variant", "rules ground-darvo-r0"),
|
||||
("scenario", "scenario SCN_"),
|
||||
// One token per aspect, because the probe expands the map and
|
||||
// each aspect must be shown to reach the reader on its own.
|
||||
("config.baseline", "baseline=ground-darvo-r0"),
|
||||
(
|
||||
"config.modules.problem_stress",
|
||||
"problem_stress=problem_stress.",
|
||||
),
|
||||
(
|
||||
"config.modules.attack_relief",
|
||||
"attack_relief=attack_relief.",
|
||||
),
|
||||
(
|
||||
"config.modules.end_condition",
|
||||
"end_condition=end_condition.",
|
||||
),
|
||||
("config.modules.problem_deal", "problem_deal=problem_deal."),
|
||||
("viewer", "(you)"),
|
||||
("solution_deck_len", "deck 11"),
|
||||
("solution_discard.*.suit", "discard [Repair, Change]"),
|
||||
|
|
@ -629,7 +661,7 @@ mod tests {
|
|||
]),
|
||||
problems,
|
||||
problem_markers: Default::default(),
|
||||
variant: games_ground::Variant::Baseline,
|
||||
config: games_ground::config::Configuration::default(),
|
||||
focus: BTreeMap::from([(p1, p3)]),
|
||||
selections: BTreeMap::from([
|
||||
(
|
||||
|
|
@ -780,7 +812,7 @@ mod tests {
|
|||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: crate::table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
config: games_ground::config::Configuration::default(),
|
||||
};
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let summary =
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@ not deal one, so a seed or a seat count would be silently ignored.
|
|||
/// can mean, and a flag that is accepted and ignored is worse than one
|
||||
/// that is refused.
|
||||
enum Mode {
|
||||
Play(Config),
|
||||
// Boxed since CB-WP-0048: `Config` carries a `Configuration`, which
|
||||
// carries the module map, and the enum is otherwise 26 bytes.
|
||||
Play(Box<Config>),
|
||||
Inspect {
|
||||
source: std::path::PathBuf,
|
||||
eyes: inspect::Eyes,
|
||||
|
|
@ -206,9 +208,25 @@ fn parse_args(argv: &[String]) -> Result<Mode, String> {
|
|||
config.scenario = normalise_scenario(&v)?;
|
||||
i += 2;
|
||||
}
|
||||
"--variant" => {
|
||||
// CB-WP-0048: legacy names ALIAS FOREVER (ADR-0022 D2).
|
||||
// 26 recordings name them and the expansion is exact, so
|
||||
// there is nothing to deprecate.
|
||||
"--variant" | "--profile" => {
|
||||
play_flags.push(flag.into());
|
||||
config.variant = value(i, argv, flag)?.parse()?;
|
||||
let v = value(i, argv, flag)?;
|
||||
config.config = games_ground::config::Configuration::select(&v)?;
|
||||
i += 2;
|
||||
}
|
||||
// A module directly, repeatable: one per aspect.
|
||||
"--module" => {
|
||||
play_flags.push(flag.into());
|
||||
let v = value(i, argv, flag)?;
|
||||
let mut live = config.config.active_modules();
|
||||
live.push(v);
|
||||
config.config = games_ground::config::Configuration::from_modules(
|
||||
&config.config.baseline,
|
||||
&live,
|
||||
)?;
|
||||
i += 2;
|
||||
}
|
||||
"--pace" => {
|
||||
|
|
@ -285,7 +303,7 @@ fn parse_args(argv: &[String]) -> Result<Mode, String> {
|
|||
config.players
|
||||
));
|
||||
}
|
||||
Ok(Mode::Play(config))
|
||||
Ok(Mode::Play(Box::new(config)))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
|
@ -381,7 +399,7 @@ mod tests {
|
|||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
config: games_ground::config::Configuration::default(),
|
||||
};
|
||||
let script = "0\n".repeat(400);
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
|
|
@ -429,7 +447,7 @@ mod tests {
|
|||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
config: games_ground::config::Configuration::default(),
|
||||
};
|
||||
let script = "0\n".repeat(400);
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
|
|
@ -499,7 +517,7 @@ mod tests {
|
|||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
config: games_ground::config::Configuration::default(),
|
||||
};
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game");
|
||||
|
|
@ -566,7 +584,7 @@ mod tests {
|
|||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
config: games_ground::config::Configuration::default(),
|
||||
};
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game");
|
||||
|
|
@ -577,7 +595,7 @@ mod tests {
|
|||
/// flag that quietly switched modes could not pass for a play flag.
|
||||
fn play_args(list: &[&str]) -> Result<Config, String> {
|
||||
match parse_args(&args(list))? {
|
||||
Mode::Play(c) => Ok(c),
|
||||
Mode::Play(c) => Ok(*c),
|
||||
Mode::Inspect { .. } => panic!("{list:?} parsed as inspect, not play"),
|
||||
}
|
||||
}
|
||||
|
|
@ -631,7 +649,7 @@ mod tests {
|
|||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
config: games_ground::config::Configuration::default(),
|
||||
};
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let summary = table::play(&config, "".as_bytes(), &mut out).expect("game");
|
||||
|
|
|
|||
|
|
@ -67,7 +67,12 @@ pub struct Config {
|
|||
///
|
||||
/// **Unlike `pace`, this changes the game** — outcome, state hash and
|
||||
/// recording all move, which is correct: a variant is mechanism.
|
||||
pub variant: games_ground::Variant,
|
||||
/// Which point in aspect space to play (ADR-0022).
|
||||
///
|
||||
/// **Replaces `variant: Variant`**, which could not express a
|
||||
/// configuration carrying two modules. Legacy names still select one
|
||||
/// (`--variant h2`); `--profile` and `--module` name them directly.
|
||||
pub config: games_ground::config::Configuration,
|
||||
}
|
||||
|
||||
/// Speed or Interactive (Ornamentation §4).
|
||||
|
|
@ -113,7 +118,7 @@ impl Default for Config {
|
|||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
config: games_ground::config::Configuration::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -417,10 +422,21 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
|||
initial.mode = config.mode;
|
||||
// CB-WP-0038: set beside `mode` and before the hash, so a recorded
|
||||
// session replays under the variant it was played under.
|
||||
// `with_variant`, never a field write: H2 assigns Problem owners at
|
||||
// `with_config`, never a field write: problem_stress.scoped assigns
|
||||
// Problem owners at
|
||||
// setup, and the bare write would leave them unassigned — a silently
|
||||
// wrong game rather than a failing one.
|
||||
let initial = initial.with_variant(config.variant);
|
||||
// **Refused here, loudly** (ADR-0022 D1, CB-RES-0010 §5.6).
|
||||
//
|
||||
// `with_config` cannot return an error — it is a builder — so it
|
||||
// falls back to the printed rules for a configuration it cannot
|
||||
// resolve. That is the silent no-op this project calls `inert`, and
|
||||
// `--module problem_deal.pressure_deck` played a full baseline game
|
||||
// and reported success. The unit tests all passed: they called
|
||||
// `resolve()` directly and never the path a player takes, which is
|
||||
// CB-WP-0033's finding in a new place.
|
||||
config.config.resolve()?;
|
||||
let initial = initial.with_config(config.config.clone());
|
||||
let initial_json = serde_json::to_value(&initial).map_err(|e| e.to_string())?;
|
||||
let initial_hash = cb_events::state_hash_hex(&initial);
|
||||
|
||||
|
|
@ -557,3 +573,71 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
|||
end_choice,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_refusal_tests {
|
||||
use super::*;
|
||||
|
||||
/// **A module with no kernel path is refused by the DRIVER**
|
||||
/// (ADR-0022 D1, CB-WP-0048 T03).
|
||||
///
|
||||
/// Every unit test of `resolve()` passed while
|
||||
/// `--module problem_deal.pressure_deck` played a full baseline game
|
||||
/// and reported success: `with_config` is a builder, cannot return an
|
||||
/// error, and fell back to the printed rules. **The helper was tested
|
||||
/// and the path a player takes was not** — CB-WP-0033's finding, in a
|
||||
/// new place.
|
||||
#[test]
|
||||
fn a_configuration_the_kernel_cannot_run_stops_the_game() {
|
||||
let Some(module) = games_ground::catalog::catalog().ok().and_then(|c| {
|
||||
c.modules.iter().find_map(|m| {
|
||||
let mut cfg = games_ground::config::Configuration::default();
|
||||
cfg.modules.insert(m.aspect.clone(), m.module_id.clone());
|
||||
cfg.resolve().is_err().then(|| m.module_id.clone())
|
||||
})
|
||||
}) else {
|
||||
// The kernel implements every module the catalog has.
|
||||
return;
|
||||
};
|
||||
let mut config = Config {
|
||||
players: 3,
|
||||
human_seats: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
config.config = games_ground::config::Configuration::from_modules(
|
||||
"ground-darvo-r0",
|
||||
std::slice::from_ref(&module),
|
||||
)
|
||||
.expect("a catalog module must NAME a configuration");
|
||||
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let err = play(&config, "".as_bytes(), &mut sink)
|
||||
.expect_err("a module with no kernel path must stop the game");
|
||||
assert!(
|
||||
err.contains(&module) && err.contains("no kernel path"),
|
||||
"refused, but not by name: {err}"
|
||||
);
|
||||
// **And no game was played.** A refusal that still deals is the
|
||||
// same silent no-op wearing an error message.
|
||||
let out = String::from_utf8_lossy(&sink);
|
||||
assert!(!out.contains("game over"), "the game ran anyway:\n{out}");
|
||||
}
|
||||
|
||||
/// The baseline and a resolvable combination are NOT refused, or the
|
||||
/// check above would pass by refusing everything.
|
||||
#[test]
|
||||
fn a_resolvable_configuration_still_plays() {
|
||||
for name in ["ground-darvo-r0", "h2", "scoped_plus_attack_soothe"] {
|
||||
let mut config = Config {
|
||||
players: 3,
|
||||
human_seats: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
config.config = games_ground::config::Configuration::select(name)
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
play(&config, "".as_bytes(), &mut sink)
|
||||
.unwrap_or_else(|e| panic!("{name} must play: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue