CB-WP-0048 T02/T03/T04: Configuration replaces Variant in the state
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:
tegwick 2026-08-09 00:54:34 +02:00
parent 704b99975b
commit 48a7bce04d
12 changed files with 538 additions and 67 deletions

View file

@ -1089,7 +1089,10 @@ pub fn document_with_log(
// not, so a player who ran the default and was told the layout had
// changed reported "no changes" — correctly, because the baseline
// is deliberately unchanged and nothing on screen said so.
variant = esc(view.variant.id()),
// CB-WP-0048: the page names every LIVE MODULE, not one package
// id. A configuration is a point in aspect space and can carry
// two modules at once; `h2` was never able to say that.
variant = esc(&config_label(&view.config)),
who = match view.viewer {
Some(p) => format!("{} (their hand only)", seat_name(p)),
None => "a spectator (no hands)".to_string(),
@ -1572,6 +1575,19 @@ fn stress_scope_rule_text() -> Option<String> {
games_ground::edition::stress_scope_rule()
}
/// Which modules are live, in words (CB-WP-0048, ADR-0022).
///
/// The baseline says so by name rather than showing an empty list: "no
/// modules" and "we could not read the catalog" must not look the same
/// on a page a player uses to tell one rules set from another.
fn config_label(c: &games_ground::config::Configuration) -> String {
let live = c.active_modules();
if live.is_empty() {
return c.baseline.clone();
}
live.join(" + ")
}
/// The Mode card's printed title, not the Rust variant's name.
///
/// Falls back to the id, which is at least a thing the edition says —

View file

@ -192,7 +192,10 @@ mod coverage {
// that could only be satisfied by geometry would have to be
// OMITTED, and the page would be illegible to `text_of` and to a
// screen reader alike.
("variant", "ground-darvo-r0"),
// The page shows the LIVE modules, or the baseline id when none
// are — a player is asking "which rules am I playing", not "what
// is every aspect set to". The fixture is the baseline.
("config.baseline", "ground-darvo-r0"),
// The fixture plays SCN_03, so the token is that card's TITLE.
// Not "SCN_03": a probe that matches an id would go green
// against a page showing the id, which is the machine's name for
@ -211,10 +214,37 @@ mod coverage {
];
/// Deliberate omissions, each with a reason.
const OMITTED: &[(&str, &str)] = &[(
"players.*.hand",
"null for a non-viewer seat; the absence is rendered as a count",
)];
const OMITTED: &[(&str, &str)] = &[
(
"players.*.hand",
"null for a non-viewer seat; the absence is rendered as a count",
),
// An aspect sitting at its default IS the printed game, and
// naming all four on every page is the wall of rules a player
// stops reading — the same reasoning that keeps the GROUND-mode
// explanation to the moment a mode is being chosen.
//
// **A LIVE module is a different matter and is not omitted**:
// `the_table_names_every_live_module` asserts it by name, so
// this omission covers only the case where there is nothing to
// say. The machine-facing `cb-play inspect` prints all four.
(
"config.modules.problem_stress",
"shown only when it differs from the printed rules; see the_table_names_every_live_module",
),
(
"config.modules.attack_relief",
"shown only when it differs from the printed rules; see the_table_names_every_live_module",
),
(
"config.modules.end_condition",
"shown only when it differs from the printed rules; see the_table_names_every_live_module",
),
(
"config.modules.problem_deal",
"shown only when it differs from the printed rules; see the_table_names_every_live_module",
),
];
fn fixture() -> GroundView {
crate::testfix::view(Some(PlayerId(0)))
@ -780,6 +810,52 @@ mod gamelog {
}
}
/// **The table names every live module** (CB-WP-0048 T04).
///
/// CB-WP-0044's finding, generalised: a rules change the page cannot
/// name is reported as "no changes". A configuration can now carry
/// two modules at once, so naming one package id would be the wrong
/// shape — `h2` could never have said
/// `problem_stress.scoped + attack_relief.self_soothe_ge4`.
#[test]
fn the_table_names_every_live_module() {
let combo = games_ground::config::Configuration::select("scoped_plus_attack_soothe")
.expect("the catalog ships this profile");
let mut v = crate::testfix::view(Some(PlayerId(0)));
v.config = combo.clone();
let text = crate::text_of(&crate::doc::document(
&v,
&[],
"/c",
Some(PlayerId(0)),
false,
));
for m in combo.active_modules() {
assert!(
text.contains(&m),
"the page does not name the live module {m}"
);
}
// And the baseline says so by NAME rather than showing nothing:
// "no modules" and "we could not read the catalog" must not look
// the same on the line a player uses to tell rules sets apart.
let mut base = crate::testfix::view(Some(PlayerId(0)));
base.config = games_ground::config::Configuration::default();
let plain = crate::text_of(&crate::doc::document(
&base,
&[],
"/c",
Some(PlayerId(0)),
false,
));
assert!(plain.contains("ground-darvo-r0"), "the baseline is unnamed");
assert!(
!plain.contains("problem_stress."),
"the baseline page names a module that is not live"
);
}
/// **A scope says what it does** (CB-WP-0046).
///
/// CB-WP-0045 shipped the placement and recorded the gap with the
@ -790,7 +866,7 @@ mod gamelog {
#[test]
fn a_scoped_table_says_what_a_scope_does() {
let mut v = crate::testfix::view(Some(PlayerId(0)));
v.variant = games_ground::Variant::H2ScopedProblemStress;
v.config = games_ground::Variant::H2ScopedProblemStress.configuration();
// One card that is not everyone's — the condition for the rule
// to be worth stating at all.
if let Some(m) = v.problem_markers.values_mut().next() {

View file

@ -83,7 +83,7 @@ pub fn view(viewer: Option<PlayerId>) -> GroundView {
(Pair::new(p2, p3), Relation::Bond),
]),
problems,
variant: games_ground::Variant::Baseline,
config: games_ground::config::Configuration::default(),
focus: BTreeMap::from([(p1, p3)]),
selections: BTreeMap::from([
(