CB-WP-0047: all four boards, and every mode named on the page
Some checks failed
ci / check (push) Failing after 3s
Some checks failed
ci / check (push) Failing after 3s
The modes were already implemented; nothing had ever COMPARED them. The scenarios were not implemented at all: edition::deal has taken a scenario_id since it was written and the only caller passed the literal "SCN_01", so 15 of 20 Problem cards had never been dealt by anything. The seam was the whole mechanism and it sat unused, with nothing red because nothing asked. Scenario is now state (serde default SCN_01, so all 26 recordings replay unchanged), selected by preset `scn-03-4p` with `standard-Np` still meaning SCN_01, and by --scenario/SCENARIO= accepting ids, numbers or titles, validated against the edition rather than a pattern. The threshold now comes off the Scenario card, closing F25's hardcoded 5/7/9. The first version of that control was worthless and mutation said so: all four scenarios print 5/7/9, so reverting to the bands left it green. Split threshold_from() so it can be handed a card that disagrees. The header read `scoring CommonProblem` where the Mode card is titled COMMON PROBLEM, PERSONAL EDGE -- the defect CB-WP-0034 deleted from the move buttons, still standing on the line that says what winning means. The coverage probe was matching that Debug output and went red when it was fixed: third instance (CB-WP-0024, CB-WP-0034). Page now carries the premise, the mode's rules text, and the tiebreak. scenario-panel plays 4x3x3. Findings: SCN_01 and SCN_02 are the same board (identical cells, pinned by a characterisation test); SCN_04 is the hard board at 2p (52% vs 67/73%, the only deck needing two Repair); and group success is EXACTLY equal across all three modes in all 36 cells, because greedy never reads state.mode -- filed F27, the two competitive modes are scoring lenses over cooperative play. F28: SHARED GROUND's mastery subtracts penalties from the claimed COUNT where the mode card's shared score is claimed VALUE. Raised, not fixed; scoring is ground-game's to rule on. Also fixes design.py reporting a backticked path as no reproduction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
1d3f1bfe60
commit
d30938b259
19 changed files with 1351 additions and 109 deletions
11
Makefile
11
Makefile
|
|
@ -35,6 +35,11 @@ PLAYERS ?= 3
|
|||
# a session that could not say which variant it was is what produced
|
||||
# the report "I did a session and noted no changes".
|
||||
VARIANT ?= ground-darvo-r0
|
||||
# CB-WP-0047: the other three boards and the other two modes were
|
||||
# unreachable from the way anyone actually plays. Defaults are what was
|
||||
# always played, so `make ground` is unchanged.
|
||||
SCENARIO ?= 1
|
||||
MODE ?= shared
|
||||
PORT ?= 0
|
||||
|
||||
# Every cargo recipe runs at the repo root; the shell does not persist cd.
|
||||
|
|
@ -72,13 +77,14 @@ help:
|
|||
## type in the notes panel is bound to the position you typed it at.
|
||||
## `make ground PLAYERS=2 SLUG=darvo-confusion`
|
||||
## Read the notes back afterwards with `make trials`.
|
||||
## play a session (VARIANT=ground-darvo-r0|h1|h2)
|
||||
## play a session (VARIANT=ground-darvo-r0|h1|h2, SCENARIO=1|2|3|4, MODE=shared|common|coalitions)
|
||||
ground:
|
||||
@echo " rules: $(VARIANT)"
|
||||
@echo " rules: $(VARIANT) scenario: $(SCENARIO) scoring: $(MODE)"
|
||||
@mkdir -p $(REPO)/trials
|
||||
@echo " trial: trials/$(TRIAL_NAME).md (notes) + .yaml (the game)"
|
||||
$(IN_REPO) $(CARGO) run -q -p cb-play -- \
|
||||
--players $(PLAYERS) --serve $(PORT) --variant $(VARIANT) \
|
||||
--scenario $(SCENARIO) --mode $(MODE) \
|
||||
--record trials/$(TRIAL_NAME).yaml \
|
||||
--trial trials/$(TRIAL_NAME).md $(ARGS)
|
||||
|
||||
|
|
@ -245,6 +251,7 @@ panels:
|
|||
@cargo run --release -q -p games-ground --example regulation
|
||||
@cargo run --release -q -p games-ground --example perfect-recall
|
||||
@cargo run --release -q -p games-ground --example h2-panel
|
||||
@cargo run --release -q -p games-ground --example scenario-panel
|
||||
|
||||
# CB-WP-0022 T05: the design-finding register, reported over
|
||||
# specs/GroundRules.md. Shows the QUEUE by default; the log of closed
|
||||
|
|
|
|||
|
|
@ -1069,13 +1069,22 @@ pub fn document_with_log(
|
|||
s,
|
||||
"<h1>GROUND \u{2014} round {round}, step {step:?}</h1>\
|
||||
<div><span class=\"k\">lead</span> {lead} \
|
||||
<span class=\"k\">scoring</span> {mode:?} \
|
||||
<span class=\"k\">scoring</span> {mode} \
|
||||
<span class=\"k\">rules</span> {variant} \
|
||||
<span class=\"k\">scenario</span> {scenario} \
|
||||
<span class=\"k\">viewing as</span> {who}</div>",
|
||||
round = view.round,
|
||||
step = view.step,
|
||||
lead = seat_name(view.lead),
|
||||
mode = view.mode,
|
||||
// CB-WP-0047: this was `{mode:?}` — `CommonProblem` where the
|
||||
// Mode card is titled "COMMON PROBLEM, PERSONAL EDGE". The exact
|
||||
// defect CB-WP-0034 fixed on the move buttons, still standing in
|
||||
// the header, on the one line that says what winning means.
|
||||
mode = esc(&mode_title(view.mode)),
|
||||
// CB-WP-0047: and WHICH of the four boards this is. Four
|
||||
// scenarios became selectable in this pass; before it, a page
|
||||
// that never named one was at least never wrong.
|
||||
scenario = esc(&scenario_title(&view.scenario)),
|
||||
// CB-WP-0044: the page must say which rules it is playing. It did
|
||||
// not, so a player who ran the default and was told the layout had
|
||||
// changed reported "no changes" — correctly, because the baseline
|
||||
|
|
@ -1306,6 +1315,46 @@ fn body(s: &mut String, view: &GroundView) {
|
|||
s.push_str("<div class=\"card\">no problems in play</div>");
|
||||
}
|
||||
s.push_str(&table_svg(view));
|
||||
// CB-WP-0047: WHAT THIS GAME IS ABOUT, and WHAT WINNING MEANS.
|
||||
//
|
||||
// The premise was vendored and unread while one scenario was
|
||||
// hardcoded — with a single board there was nothing to tell apart.
|
||||
// Selecting among four makes an unnamed board a real defect.
|
||||
//
|
||||
// The Mode card is the sharper one: it is the only statement of the
|
||||
// win condition, three of them differ completely, and the page had
|
||||
// never shown any of them. A player in BONDED COALITIONS was not
|
||||
// told that Bonds decide the sides at the end.
|
||||
if let Some(premise) = scenario_premise(&view.scenario) {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<details class=\"card\"><summary>{} \u{2014} what happened\
|
||||
</summary>{}</details>",
|
||||
esc(&scenario_title(&view.scenario)),
|
||||
esc(&premise)
|
||||
);
|
||||
}
|
||||
if let Some((rules, tiebreak)) = mode_rules_text(view.mode) {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<details class=\"card\"><summary>{} \u{2014} how this game is won\
|
||||
</summary>{}",
|
||||
esc(&mode_title(view.mode)),
|
||||
esc(&rules)
|
||||
);
|
||||
// The tiebreak is a rule the player can PLAY FOR — "lower Stress,
|
||||
// then more Bonds" changes what a losing seat should do in round
|
||||
// five — and it lives in its own column, so it renders as its own
|
||||
// sentence rather than being silently dropped.
|
||||
if !tiebreak.trim().is_empty() && !tiebreak.eq_ignore_ascii_case("Not applicable.") {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<br><span class=\"k\">tiebreak</span> {}",
|
||||
esc(&tiebreak)
|
||||
);
|
||||
}
|
||||
s.push_str("</details>");
|
||||
}
|
||||
// CB-WP-0046: the placement is legible, the RULE it encodes was not.
|
||||
//
|
||||
// Shown only when a non-global scope is actually on the table — under
|
||||
|
|
@ -1523,6 +1572,59 @@ fn stress_scope_rule_text() -> Option<String> {
|
|||
games_ground::edition::stress_scope_rule()
|
||||
}
|
||||
|
||||
/// 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 —
|
||||
/// never to `Debug`, which is a thing only the compiler says.
|
||||
fn mode_title(mode: games_ground::ScoringMode) -> String {
|
||||
use games_ground::ScoringMode as M;
|
||||
let want = match mode {
|
||||
M::SharedGround => "MODE_COOP",
|
||||
M::CommonProblem => "MODE_SEMI",
|
||||
M::BondedCoalitions => "MODE_COALITION",
|
||||
};
|
||||
games_ground::edition::modes()
|
||||
.ok()
|
||||
.and_then(|ms| {
|
||||
ms.into_iter()
|
||||
.find(|(c, _)| c.id == want)
|
||||
.map(|(c, _)| c.title)
|
||||
})
|
||||
.unwrap_or_else(|| want.to_string())
|
||||
}
|
||||
|
||||
/// What the Mode card says about how this game is won (CB-WP-0047).
|
||||
fn mode_rules_text(mode: games_ground::ScoringMode) -> Option<(String, String)> {
|
||||
use games_ground::ScoringMode as M;
|
||||
let want = match mode {
|
||||
M::SharedGround => "MODE_COOP",
|
||||
M::CommonProblem => "MODE_SEMI",
|
||||
M::BondedCoalitions => "MODE_COALITION",
|
||||
};
|
||||
games_ground::edition::modes().ok().and_then(|ms| {
|
||||
ms.into_iter()
|
||||
.find(|(c, _)| c.id == want)
|
||||
.map(|(c, tie)| (c.rules_text, tie))
|
||||
})
|
||||
}
|
||||
|
||||
/// The Scenario card's printed title.
|
||||
fn scenario_title(id: &str) -> String {
|
||||
games_ground::edition::scenarios()
|
||||
.ok()
|
||||
.and_then(|ss| ss.into_iter().find(|s| s.id == id).map(|s| s.title))
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
/// The Scenario card's premise — what the table is arguing about.
|
||||
fn scenario_premise(id: &str) -> Option<String> {
|
||||
games_ground::edition::scenarios()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.find(|s| s.id == id)
|
||||
.map(|s| s.premise)
|
||||
}
|
||||
|
||||
/// A GROUND sub-choice, in words, naming whoever it touches.
|
||||
fn ground_choice_label(c: &games_ground::GroundChoice) -> String {
|
||||
use games_ground::GroundChoice as G;
|
||||
|
|
|
|||
|
|
@ -135,7 +135,12 @@ mod coverage {
|
|||
("round", "round 3"),
|
||||
("lead", "lead P2"),
|
||||
("step", "step Select"),
|
||||
("mode", "scoring BondedCoalitions"),
|
||||
// Was "scoring BondedCoalitions" — the Rust variant's name.
|
||||
// **The probe was certifying the defect**, and went red the
|
||||
// moment the defect was fixed. Third time this exact shape has
|
||||
// been found (CB-WP-0024, CB-WP-0034): a probe that names a
|
||||
// rendering holds that rendering in place, so probes name FACTS.
|
||||
("mode", "BONDED COALITIONS"),
|
||||
("viewer", "viewing as P1"),
|
||||
("solution_deck_len", "draw pile: 17 remaining"),
|
||||
("solution_discard.*.suit", "discard Repair Change"),
|
||||
|
|
@ -188,6 +193,11 @@ mod coverage {
|
|||
// OMITTED, and the page would be illegible to `text_of` and to a
|
||||
// screen reader alike.
|
||||
("variant", "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
|
||||
// the board and not the player's (CB-WP-0034's finding).
|
||||
("scenario", "Decision Without Consent"),
|
||||
("problem_markers.*.owner", "P1\u{2019}s alone"),
|
||||
("problem_markers.*.scope", "P2\u{2019}s Bond network"),
|
||||
("outcome.total", "total 9"),
|
||||
|
|
@ -697,6 +707,79 @@ mod gamelog {
|
|||
}
|
||||
}
|
||||
|
||||
/// **The page names the Mode by its printed title** (CB-WP-0047).
|
||||
///
|
||||
/// The header read `scoring CommonProblem` — the Rust variant's
|
||||
/// name, where the Mode card is titled *"COMMON PROBLEM, PERSONAL
|
||||
/// EDGE"*. CB-WP-0034 deleted this exact defect from the move
|
||||
/// buttons and it was still standing on the one line that says what
|
||||
/// winning means.
|
||||
#[test]
|
||||
fn every_mode_is_named_and_explained_in_the_editions_words() {
|
||||
use games_ground::ScoringMode as M;
|
||||
for mode in [M::SharedGround, M::CommonProblem, M::BondedCoalitions] {
|
||||
let mut v = crate::testfix::view(Some(PlayerId(0)));
|
||||
v.mode = mode;
|
||||
let text = crate::text_of(&crate::doc::document(
|
||||
&v,
|
||||
&[],
|
||||
"/c",
|
||||
Some(PlayerId(0)),
|
||||
false,
|
||||
));
|
||||
assert!(
|
||||
!text.contains(&format!("{mode:?}")),
|
||||
"{mode:?}: the page shows the Rust variant's name"
|
||||
);
|
||||
let (card, _) = games_ground::edition::modes()
|
||||
.expect("Modes.csv")
|
||||
.into_iter()
|
||||
.find(|(c, _)| {
|
||||
c.id == match mode {
|
||||
M::SharedGround => "MODE_COOP",
|
||||
M::CommonProblem => "MODE_SEMI",
|
||||
M::BondedCoalitions => "MODE_COALITION",
|
||||
}
|
||||
})
|
||||
.expect("the mode card");
|
||||
assert!(
|
||||
text.contains(&card.title),
|
||||
"{mode:?}: the Mode card's title {:?} is not on the page",
|
||||
card.title
|
||||
);
|
||||
// **And what it MEANS.** Three modes define winning three
|
||||
// different ways and the page had never stated any of them.
|
||||
let head: String = card.rules_text.chars().take(50).collect();
|
||||
assert!(
|
||||
text.contains(&head),
|
||||
"{mode:?}: nothing on the page says how this game is won"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The page names which of the four boards this is** (CB-WP-0047).
|
||||
#[test]
|
||||
fn every_scenario_is_named_with_its_premise() {
|
||||
for s in games_ground::edition::scenarios().expect("Scenarios.csv") {
|
||||
let mut v = crate::testfix::view(Some(PlayerId(0)));
|
||||
v.scenario = s.id.clone();
|
||||
let text = crate::text_of(&crate::doc::document(
|
||||
&v,
|
||||
&[],
|
||||
"/c",
|
||||
Some(PlayerId(0)),
|
||||
false,
|
||||
));
|
||||
assert!(text.contains(&s.title), "{}: the board is unnamed", s.id);
|
||||
let head: String = s.premise.chars().take(50).collect();
|
||||
assert!(
|
||||
text.contains(&head),
|
||||
"{}: the page does not say what the table is arguing about",
|
||||
s.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **A scope says what it does** (CB-WP-0046).
|
||||
///
|
||||
/// CB-WP-0045 shipped the placement and recorded the gap with the
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ pub fn view(viewer: Option<PlayerId>) -> GroundView {
|
|||
lead: p2,
|
||||
step: RoundStep::Select,
|
||||
mode: ScoringMode::BondedCoalitions,
|
||||
scenario: "SCN_03".into(),
|
||||
players,
|
||||
relations: BTreeMap::from([
|
||||
(Pair::new(p1, p2), Relation::Rivalry),
|
||||
|
|
|
|||
|
|
@ -1,98 +1,252 @@
|
|||
# Selectable GROUND edition / rules packages.
|
||||
# Schema docs: CATALOG.md
|
||||
# clay-borg: pin by variant_id → path (+ optional git_pin).
|
||||
# GROUND edition catalog — schema 2: composable modules
|
||||
# Docs: CATALOG.md
|
||||
# clay-borg: select baseline + 0..N modules (≤1 per axis), or a named profile.
|
||||
|
||||
schema_version: 1
|
||||
schema_version: 2
|
||||
updated: "2026-08-08"
|
||||
default_variant: ground-darvo-r0
|
||||
# H2 added; H1 remains selectable as reject-as-baseline control.
|
||||
default_baseline: ground-darvo-r0
|
||||
default_profile: baseline
|
||||
|
||||
packages:
|
||||
- variant_id: ground-darvo-r0
|
||||
# ---------------------------------------------------------------------------
|
||||
# Axes — orthogonal dimensions. At most one non-default module per axis.
|
||||
# ---------------------------------------------------------------------------
|
||||
axes:
|
||||
- id: problem_stress
|
||||
title: Problem → Stress routing
|
||||
default_module: problem_stress.none
|
||||
summary: >
|
||||
Whether and how unclaimed Problems raise Stress at Round End.
|
||||
|
||||
- id: attack_relief
|
||||
title: ATTACK self-soothing
|
||||
default_module: attack_relief.none
|
||||
summary: >
|
||||
Whether resolving ATTACK can reduce the attacker's Stress.
|
||||
|
||||
- id: end_condition
|
||||
title: How the game ends
|
||||
default_module: end_condition.fixed_rounds_5
|
||||
summary: >
|
||||
Fixed round clock vs clear-board / collapse / hybrid ends.
|
||||
|
||||
- id: problem_deal
|
||||
title: How Problems enter play
|
||||
default_module: problem_deal.fixed_setup
|
||||
summary: >
|
||||
Fixed setup deal only vs mid-game influx (pressure deck, etc.).
|
||||
|
||||
# Future axes (not yet registered): setup_difficulty, sequence_pacing,
|
||||
# ground_as_sequence, status_stress, competence_track.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Baseline content package (CSV edition data)
|
||||
# ---------------------------------------------------------------------------
|
||||
baselines:
|
||||
- baseline_id: ground-darvo-r0
|
||||
path: editions/ground-darvo-r0
|
||||
kind: baseline
|
||||
base: null
|
||||
selectable: true
|
||||
status: baseline
|
||||
dataset_id: GROUND-DARVO-CORE-0.1
|
||||
title: "GROUND DARVO Edition — core r0"
|
||||
summary: >
|
||||
Playtest baseline. Surface + hidden 1..k deal; available 6/9/12;
|
||||
thresholds 5/7/9. Stress ledger has no problem pressure; ATTACK
|
||||
does not self-soothe.
|
||||
hypothesis_ref: null
|
||||
workplan_ref: null
|
||||
rules_delta: null
|
||||
changed_files: []
|
||||
Print/playtest content. Modes, deal 6/9/12, thresholds 5/7/9.
|
||||
Default modules on all axes = r0 printed behaviour.
|
||||
utility_estimate: >
|
||||
Ship-default. RPT-0003 + CB-EV-0031: under non-attacking competent
|
||||
policies, peak Stress held never exceeds start 2 — ATTACK is the
|
||||
sole inbound pressure. Keep pinned until a successor is accepted.
|
||||
Ship-default content. Stress has no problem pressure until a
|
||||
problem_stress module is selected.
|
||||
decision: none
|
||||
git_pin: null
|
||||
clay_borg_notes: >
|
||||
Vendor CSVs as today (Problems, Actions, Solutions, Modes, Tokens).
|
||||
Kernel implements printed r0 rules.
|
||||
|
||||
- variant_id: h1-problem-stress
|
||||
path: editions/experiments/h1-problem-stress
|
||||
kind: experiment
|
||||
base: ground-darvo-r0
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modules — independent variations (one directory each)
|
||||
# ---------------------------------------------------------------------------
|
||||
modules:
|
||||
# --- problem_stress ---
|
||||
- module_id: problem_stress.none
|
||||
axis: problem_stress
|
||||
path: editions/modules/problem_stress/none
|
||||
is_default: true
|
||||
selectable: true
|
||||
status: baseline-default
|
||||
rules_delta: null
|
||||
summary: Unclaimed Problems do not raise Stress (r0).
|
||||
decision: none
|
||||
|
||||
- module_id: problem_stress.flat_any_open
|
||||
axis: problem_stress
|
||||
path: editions/modules/problem_stress/flat_any_open
|
||||
is_default: false
|
||||
selectable: true
|
||||
status: measured
|
||||
dataset_id: GROUND-DARVO-EXP-H1-0.1
|
||||
title: "H1 — problem pressure + high-stress ATTACK self-soothe"
|
||||
summary: >
|
||||
Two deltas only: (A) +1 Stress each player at Round End if any
|
||||
Problem remains unclaimed; (B) uncancelled ACT_ATTACK by a seat
|
||||
at Stress ≥4 gives that seat −1 Stress. Thresholds/deal unchanged.
|
||||
hypothesis_ref: history/260807-attack-darvo-stress-design.md
|
||||
workplan_ref: workplans/GROUND-WP-0006-h1-problem-stress-experiment.md
|
||||
rules_delta: editions/modules/problem_stress/flat_any_open/rules_delta.yaml
|
||||
legacy_experiment_ids: [h1-problem-stress]
|
||||
measurement_ref: reports/260808-clay-borg-h1-measured.md
|
||||
rules_delta: editions/experiments/h1-problem-stress/rules_delta.yaml
|
||||
changed_files:
|
||||
- Actions.csv
|
||||
- Rules_Text.csv
|
||||
- metadata.json
|
||||
utility_estimate: >
|
||||
Measured 2026-08-08 (CB-EV-0030/0031; instrument caveat applies).
|
||||
Direction right, magnitude wrong: H1-A is a solve-rate tax that
|
||||
competent seats absorb with GROUND (Stress ~3); H1-B never fires
|
||||
for them. DARVO arms for unregulated seats; group wins collapse
|
||||
to 0 at 3p+ under greedy SHARED GROUND. Do not promote as-is.
|
||||
decision: reject-as-baseline
|
||||
# Package stays selectable for regression compare; not a ship pin.
|
||||
git_pin: null
|
||||
clay_borg_notes: >
|
||||
Kernel implements H1-A/H1-B. Keep for A/B against successors (H2…).
|
||||
|
||||
- variant_id: h2-scoped-problem-stress
|
||||
path: editions/experiments/h2-scoped-problem-stress
|
||||
kind: experiment
|
||||
base: ground-darvo-r0
|
||||
selectable: true
|
||||
status: experimental
|
||||
dataset_id: GROUND-DARVO-EXP-H2-0.1
|
||||
title: "H2 — scoped problem stress (personal / bond / global)"
|
||||
summary: >
|
||||
Unclaimed Problems apply +1 End Stress only to stress_scope:
|
||||
personal=owner, bond=owner's Bond network, global=all. Surface
|
||||
global; priority 3 is bond (in play at 3p+). Anyone may SOLVE any
|
||||
card. Not stacked on H1; no ATTACK self-soothe.
|
||||
hypothesis_ref: history/260808-h2-scoped-problem-stress.md
|
||||
workplan_ref: workplans/GROUND-WP-0007-h2-scoped-problem-stress.md
|
||||
measurement_ref: null
|
||||
rules_delta: editions/experiments/h2-scoped-problem-stress/rules_delta.yaml
|
||||
changed_files:
|
||||
- Problems.csv
|
||||
- Rules_Text.csv
|
||||
- metadata.json
|
||||
+1 Stress to every seat if any Problem unclaimed (former H1-A).
|
||||
utility_estimate: >
|
||||
Unmeasured. Expected: stress variance up; group wins at 3–4p much
|
||||
better than H1; bond cards create joint SOLVE incentive in networks.
|
||||
decision: none
|
||||
git_pin: null
|
||||
Reject as sole pressure: greedy 3–4p wins → 0. Keep for A/B control.
|
||||
decision: reject-as-baseline
|
||||
clay_borg_notes: Former H1-A; implement without H1-B unless attack_relief also selected.
|
||||
|
||||
- module_id: problem_stress.scoped
|
||||
axis: problem_stress
|
||||
path: editions/modules/problem_stress/scoped
|
||||
is_default: false
|
||||
selectable: true
|
||||
status: measured
|
||||
rules_delta: editions/modules/problem_stress/scoped/rules_delta.yaml
|
||||
data_overlays: [Problems.csv, Rules_Text.csv]
|
||||
legacy_experiment_ids: [h2-scoped-problem-stress]
|
||||
measurement_ref: reports/260808-clay-borg-h2-measured.md
|
||||
hypothesis_ref: history/260808-h2-scoped-problem-stress.md
|
||||
summary: >
|
||||
+1 Stress per unclaimed Problem to stress_scope (personal/bond/global).
|
||||
utility_estimate: >
|
||||
Scoping works (RPT-0005). Keep; candidate promote with other axes later.
|
||||
decision: keep-as-experiment
|
||||
clay_borg_notes: >
|
||||
Implement rules_delta H2-SCOPE, H2-OWN, H2-A, H2-SOLVE on base r0.
|
||||
Do not also apply H1 deltas. Problems.csv adds stress_scope column.
|
||||
Report bond-card SOLVE rates and stress variance vs r0 and h1.
|
||||
Prefer path editions/modules/problem_stress/scoped. Use with_variant()/owners.
|
||||
legacy_experiment_id h2-scoped-problem-stress remains an alias profile.
|
||||
|
||||
# --- attack_relief ---
|
||||
- module_id: attack_relief.none
|
||||
axis: attack_relief
|
||||
path: editions/modules/attack_relief/none
|
||||
is_default: true
|
||||
selectable: true
|
||||
status: baseline-default
|
||||
rules_delta: null
|
||||
summary: ATTACK does not self-soothe (r0).
|
||||
decision: none
|
||||
|
||||
- module_id: attack_relief.self_soothe_ge4
|
||||
axis: attack_relief
|
||||
path: editions/modules/attack_relief/self_soothe_ge4
|
||||
is_default: false
|
||||
selectable: true
|
||||
status: measured-as-combo
|
||||
rules_delta: editions/modules/attack_relief/self_soothe_ge4/rules_delta.yaml
|
||||
legacy_experiment_ids: [h1-problem-stress]
|
||||
measurement_ref: reports/260808-clay-borg-h1-measured.md
|
||||
summary: Uncancelled ATTACK at Stress ≥4 → attacker −1 Stress (former H1-B).
|
||||
utility_estimate: >
|
||||
Alone unmeasured. With flat problem stress, dead for competent play.
|
||||
Re-measure with problem_stress.scoped via profile scoped_plus_attack_soothe.
|
||||
decision: none
|
||||
clay_borg_notes: Independent of problem_stress; compose explicitly.
|
||||
|
||||
# --- end_condition ---
|
||||
- module_id: end_condition.fixed_rounds_5
|
||||
axis: end_condition
|
||||
path: editions/modules/end_condition/fixed_rounds_5
|
||||
is_default: true
|
||||
selectable: true
|
||||
status: baseline-default
|
||||
rules_delta: null
|
||||
summary: Always 5 rounds then threshold scoring (r0).
|
||||
decision: none
|
||||
|
||||
- module_id: end_condition.hybrid_clear_collapse
|
||||
axis: end_condition
|
||||
path: editions/modules/end_condition/hybrid_clear_collapse
|
||||
is_default: false
|
||||
selectable: true
|
||||
status: proposed
|
||||
rules_delta: editions/modules/end_condition/hybrid_clear_collapse/rules_delta.yaml
|
||||
hypothesis_ref: history/260808-deal-end-sequences-design.md
|
||||
summary: >
|
||||
End on board clear, group collapse, or round ceiling (draft rules).
|
||||
utility_estimate: Unimplemented — draft only.
|
||||
decision: none
|
||||
clay_borg_notes: Kernel pending; do not claim measured until implemented.
|
||||
|
||||
# --- problem_deal ---
|
||||
- module_id: problem_deal.fixed_setup
|
||||
axis: problem_deal
|
||||
path: editions/modules/problem_deal/fixed_setup
|
||||
is_default: true
|
||||
selectable: true
|
||||
status: baseline-default
|
||||
rules_delta: null
|
||||
summary: Surface + hidden 1..k at setup only (r0).
|
||||
decision: none
|
||||
|
||||
- module_id: problem_deal.pressure_deck
|
||||
axis: problem_deal
|
||||
path: editions/modules/problem_deal/pressure_deck
|
||||
is_default: false
|
||||
selectable: true
|
||||
status: proposed
|
||||
rules_delta: editions/modules/problem_deal/pressure_deck/rules_delta.yaml
|
||||
hypothesis_ref: history/260808-deal-end-sequences-design.md
|
||||
summary: >
|
||||
Small start set + mid-game draws from Pressure deck (draft).
|
||||
utility_estimate: Unimplemented — draft only.
|
||||
decision: none
|
||||
clay_borg_notes: >
|
||||
Kernel pending. Recommended compose with problem_stress.scoped.
|
||||
v0 draft uses 0-point drawn cards.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profiles — named compositions (convenience; not a second rules source)
|
||||
# Selection = baseline + modules list. Defaults fill missing axes.
|
||||
# ---------------------------------------------------------------------------
|
||||
profiles:
|
||||
- profile_id: baseline
|
||||
title: Pure r0
|
||||
modules: []
|
||||
summary: All axis defaults — printed ground-darvo-r0 behaviour.
|
||||
|
||||
- profile_id: h1
|
||||
title: Legacy H1 (flat problem stress + attack soothe)
|
||||
modules:
|
||||
- problem_stress.flat_any_open
|
||||
- attack_relief.self_soothe_ge4
|
||||
legacy_experiment_id: h1-problem-stress
|
||||
summary: Equivalent to old monolithic experiment h1-problem-stress.
|
||||
decision: reject-as-baseline
|
||||
|
||||
- profile_id: h2
|
||||
title: Legacy H2 (scoped problem stress only)
|
||||
modules:
|
||||
- problem_stress.scoped
|
||||
legacy_experiment_id: h2-scoped-problem-stress
|
||||
summary: Equivalent to old monolithic experiment h2-scoped-problem-stress.
|
||||
decision: keep-as-experiment
|
||||
|
||||
- profile_id: scoped_plus_attack_soothe
|
||||
title: Scoped stress + ATTACK self-soothe
|
||||
modules:
|
||||
- problem_stress.scoped
|
||||
- attack_relief.self_soothe_ge4
|
||||
summary: First intentional multi-axis combo after modular catalog.
|
||||
status: unmeasured
|
||||
|
||||
- profile_id: scoped_plus_hybrid_end
|
||||
title: Scoped stress + hybrid end (when end module ships)
|
||||
modules:
|
||||
- problem_stress.scoped
|
||||
- end_condition.hybrid_clear_collapse
|
||||
summary: Requires end_condition.hybrid_clear_collapse kernel support.
|
||||
status: proposed
|
||||
|
||||
- profile_id: scoped_plus_pressure_deck
|
||||
title: Scoped stress + pressure deck (when deal module ships)
|
||||
modules:
|
||||
- problem_stress.scoped
|
||||
- problem_deal.pressure_deck
|
||||
summary: Requires problem_deal.pressure_deck kernel support.
|
||||
status: proposed
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy experiment paths (still on disk; prefer modules + profiles)
|
||||
# ---------------------------------------------------------------------------
|
||||
legacy_experiments:
|
||||
- experiment_id: h1-problem-stress
|
||||
path: editions/experiments/h1-problem-stress
|
||||
equivalent_profile: h1
|
||||
note: Prefer profile h1 or modules problem_stress.flat_any_open + attack_relief.self_soothe_ge4
|
||||
|
||||
- experiment_id: h2-scoped-problem-stress
|
||||
path: editions/experiments/h2-scoped-problem-stress
|
||||
equivalent_profile: h2
|
||||
note: Prefer profile h2 or module problem_stress.scoped
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
| | |
|
||||
|---|---|
|
||||
| **variant_id** | `h1-problem-stress` |
|
||||
| **variant_id** | `h1-problem-stress` (**legacy** — prefer profile `h1` = `problem_stress.flat_any_open` + `attack_relief.self_soothe_ge4`) |
|
||||
| **modules** | flat problem stress + attack self-soothe (see `editions/modules/`) |
|
||||
| **base** | `ground-darvo-r0` |
|
||||
| **status** | measured — reject as baseline (2026-08-08); keep for A/B |
|
||||
| **measurement** | [`../../../reports/260808-clay-borg-h1-measured.md`](../../../reports/260808-clay-borg-h1-measured.md) |
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
| | |
|
||||
|---|---|
|
||||
| **variant_id** | `h2-scoped-problem-stress` |
|
||||
| **variant_id** | `h2-scoped-problem-stress` (**legacy** — prefer module `problem_stress.scoped` / profile `h2`) |
|
||||
| **module** | [`../../modules/problem_stress/scoped/`](../../modules/problem_stress/scoped/) |
|
||||
| **base** | `ground-darvo-r0` (not stacked on H1) |
|
||||
| **status** | experimental |
|
||||
| **status** | measured — keep-as-experiment |
|
||||
| **catalog** | [`../../catalog.yaml`](../../catalog.yaml) |
|
||||
| **design note** | [`../../../history/260808-h2-scoped-problem-stress.md`](../../../history/260808-h2-scoped-problem-stress.md) |
|
||||
| **workplan** | [`../../../workplans/GROUND-WP-0007-h2-scoped-problem-stress.md`](../../../workplans/GROUND-WP-0007-h2-scoped-problem-stress.md) |
|
||||
|
|
|
|||
|
|
@ -58,16 +58,16 @@ the adversarial review ([CB-REV-0001](../../reviews/CB-REV-0001-h1.md))
|
|||
reported it unverified and it was a real gap.
|
||||
|
||||
```
|
||||
sha256 c469f984c3147861815e8fbd67cf1254de73368fad1654f4177f740c0c280499 ../catalog.yaml
|
||||
sha256 cd0c0db9eef5e9f94df6c1b26837c5e800d9eb8c79ee113a537767aa88934c76 ../catalog.yaml
|
||||
sha256 4c81bae21d2ecb70c7424fa17445246a9b551b10e258d448634816c564e28f09 ../experiments/h2-scoped-problem-stress/Problems.csv
|
||||
sha256 8ed8deb7ad142de1bda70dc11add339f742e8c12ac1f02d5f7c7ec8f17f77b1d ../experiments/h2-scoped-problem-stress/Rules_Text.csv
|
||||
sha256 b2db91edc6798efca1ccf0b048bb0d7cb324e808e01d636ebca813527bcd0db2 ../experiments/h2-scoped-problem-stress/VARIANT.md
|
||||
sha256 b0ffea41820ef35960d8c75ff0a734370a29a738e49fda74851fe5c2daafe53e ../experiments/h2-scoped-problem-stress/VARIANT.md
|
||||
sha256 abf994f585fdfa9b2822614a7961e98141cded6a91c446916ff7fd48642de0a7 ../experiments/h2-scoped-problem-stress/metadata.json
|
||||
sha256 8dc569b2ae62f88f7f64e282bc6bba3dbcc3ffcad276591baafbdd24cb6c16b7 ../experiments/h2-scoped-problem-stress/rules_delta.yaml
|
||||
sha256 f58e81f84ea2b0d16e39932261eb3f3d9890345cdf37ad6f0b3abc00636840be ../experiments/h1-problem-stress/rules_delta.yaml
|
||||
sha256 7b1cc0149122b855e827bc930576ed165bf7dd8d62707e845a9e514ce3521f8e ../experiments/h1-problem-stress/Actions.csv
|
||||
sha256 62785f5e7e245c60171624d15de2f40187a44fec54f93c7d9705cf52584b1078 ../experiments/h1-problem-stress/Rules_Text.csv
|
||||
sha256 b2714210c142f1d6d5bed8f9a795019e51829c627f9599ac0ebfa04ef60a37a5 ../experiments/h1-problem-stress/VARIANT.md
|
||||
sha256 49897a68056643a8cfccff32c9e4a9811018b4e4689b90a85124a487f2318369 ../experiments/h1-problem-stress/VARIANT.md
|
||||
sha256 443199db94601cc889557e5e86823f374dbfdf875962fc84f95c01865605101c ../experiments/h1-problem-stress/metadata.json
|
||||
```
|
||||
|
||||
|
|
|
|||
156
games/ground/examples/scenario-panel.rs
Normal file
156
games/ground/examples/scenario-panel.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
//! Every board, in every mode, at every seat band (CB-WP-0047).
|
||||
//!
|
||||
//! The engine dealt `SCN_01` and only `SCN_01` for the whole life of this
|
||||
//! repo: `deal` took a scenario id, `setup` passed a literal, and **15 of
|
||||
//! the 20 Problem cards had never been dealt by anything**. The three
|
||||
//! scoring modes were reachable from the driver but no measurement had
|
||||
//! ever compared them.
|
||||
//!
|
||||
//! This plays all of it. It is a **characterisation panel**, not a
|
||||
//! hypothesis test: nothing here predicts an outcome, and the numbers
|
||||
//! exist so that a later change to a deck or a mode is visible as a
|
||||
//! change rather than discovered by a player.
|
||||
//!
|
||||
//! **SCN_01 and SCN_02 are the same board.** Identical suits and values
|
||||
//! at every priority; they differ only in prose. They are both played
|
||||
//! anyway — a panel that dropped one would hide the day they diverge —
|
||||
//! and the column is expected to match, which is itself a control.
|
||||
|
||||
use cb_game_runtime::{ScenarioGame, Setup};
|
||||
use games_ground::bot::{play, GreedyPolicy, Policy};
|
||||
use games_ground::{GroundState, ScoringMode};
|
||||
|
||||
/// Games per cell. Named once so the banner and the assertion cannot
|
||||
/// disagree — a previous panel printed "200 games per cell" over
|
||||
/// 196-game columns (CB-REV-0002 #1).
|
||||
const GAMES: u32 = 100;
|
||||
|
||||
struct Cell {
|
||||
games: u32,
|
||||
/// Games that reached an outcome over the full five rounds. A game
|
||||
/// that RAN is not a game that was PLAYED (CB-REV-0003 #2).
|
||||
played: u32,
|
||||
group_success: u32,
|
||||
/// Seats named a winner. Under SHARED GROUND that is everyone or
|
||||
/// nobody; under the other two it is the point of the mode.
|
||||
winners: u32,
|
||||
/// Claimed value summed over games, so the threshold has something
|
||||
/// to be compared against.
|
||||
total: u32,
|
||||
threshold: u32,
|
||||
setup_fails: u32,
|
||||
}
|
||||
|
||||
fn sweep(scenario: &str, mode: ScoringMode, players: u8) -> Cell {
|
||||
let mut c = Cell {
|
||||
games: 0,
|
||||
played: 0,
|
||||
group_success: 0,
|
||||
winners: 0,
|
||||
total: 0,
|
||||
threshold: 0,
|
||||
setup_fails: 0,
|
||||
};
|
||||
let preset = if scenario == "SCN_01" {
|
||||
format!("standard-{players}p")
|
||||
} else {
|
||||
format!("scn-{}-{players}p", scenario.rsplit('_').next().unwrap())
|
||||
};
|
||||
for seed in 0..GAMES as u64 {
|
||||
let Ok(mut st) = GroundState::setup(
|
||||
&Setup {
|
||||
players,
|
||||
preset: preset.clone(),
|
||||
patch: Default::default(),
|
||||
},
|
||||
seed,
|
||||
) else {
|
||||
// Counted, not skipped (CB-REV-0001 #11).
|
||||
c.setup_fails += 1;
|
||||
continue;
|
||||
};
|
||||
st.mode = mode;
|
||||
// The board actually dealt, so the panel cannot report a
|
||||
// scenario it did not play.
|
||||
assert_eq!(st.scenario, scenario, "{preset} dealt {}", st.scenario);
|
||||
let mut ps: Vec<Box<dyn Policy>> = (0..players)
|
||||
.map(|_| Box::new(GreedyPolicy) as Box<dyn Policy>)
|
||||
.collect();
|
||||
let g = match play(st, &mut ps) {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
eprintln!(" !! {scenario} {mode:?} {players}p seed {seed}: {e:?}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
c.games += 1;
|
||||
if g.state.outcome.is_some() && g.rounds == 5 {
|
||||
c.played += 1;
|
||||
}
|
||||
if let Some(o) = &g.state.outcome {
|
||||
if o.group_success {
|
||||
c.group_success += 1;
|
||||
}
|
||||
c.winners += o.winners.len() as u32;
|
||||
c.total += o.total;
|
||||
c.threshold = o.threshold;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
c.games, GAMES,
|
||||
"{scenario} {mode:?} {players}p: only {} of {GAMES} games ran \
|
||||
({} setups refused) — every number in the cell is over a sample \
|
||||
nobody chose",
|
||||
c.games, c.setup_fails
|
||||
);
|
||||
assert_eq!(
|
||||
c.played, GAMES,
|
||||
"{scenario} {mode:?} {players}p: {} of {} games reached an outcome \
|
||||
over five rounds",
|
||||
c.played, c.games
|
||||
);
|
||||
c
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("CB-WP-0047 — the four Scenarios, the three Modes\n");
|
||||
println!("Greedy throughout; {GAMES} games per cell; seeds 0..{GAMES}.");
|
||||
println!("`won` is group success; `win/g` is winning SEATS per game,");
|
||||
println!("which is what separates the modes — SHARED GROUND names all");
|
||||
println!("or none, the other two name a subset.\n");
|
||||
|
||||
let scenarios = games_ground::edition::scenarios().expect("Scenarios.csv");
|
||||
// Every mode the kernel has. Adding a fourth without extending the
|
||||
// panel is the omission this list exists to make loud.
|
||||
let modes = [
|
||||
("SHARED GROUND ", ScoringMode::SharedGround),
|
||||
("COMMON PROBLEM ", ScoringMode::CommonProblem),
|
||||
("BONDED COALITIONS ", ScoringMode::BondedCoalitions),
|
||||
];
|
||||
|
||||
for players in [2u8, 4, 6] {
|
||||
println!("{players} players");
|
||||
println!(
|
||||
" {:<32} {:<18} {:>5} {:>7} {:>6} {:>9}",
|
||||
"scenario", "mode", "won", "win/g", "pts", "threshold"
|
||||
);
|
||||
for s in &scenarios {
|
||||
for (label, mode) in &modes {
|
||||
let c = sweep(&s.id, *mode, players);
|
||||
println!(
|
||||
" {:<32} {label} {:>5} {:>7.2} {:>6.1} {:>9}",
|
||||
format!("{} {}", s.id, s.title),
|
||||
c.group_success,
|
||||
f64::from(c.winners) / f64::from(c.games),
|
||||
f64::from(c.total) / f64::from(c.games),
|
||||
c.threshold,
|
||||
);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
println!("SCN_01 and SCN_02 are the same board — identical suits and");
|
||||
println!("values at every priority. Their rows are expected to MATCH,");
|
||||
println!("and a divergence means one of the two decks was edited.");
|
||||
}
|
||||
|
|
@ -474,6 +474,11 @@ pub fn darvo_stages() -> Result<Vec<DarvoStageText>, String> {
|
|||
pub struct ScenarioText {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
/// What the game is ABOUT, in the edition's words (CB-WP-0047).
|
||||
///
|
||||
/// Vendored and unread until the four scenarios became selectable:
|
||||
/// with one scenario hardcoded there was nothing to tell apart.
|
||||
pub premise: String,
|
||||
pub surface_problem_id: String,
|
||||
pub hidden_problem_ids: Vec<String>,
|
||||
/// GR-E01 by seat band, as the EDITION states it. The engine has its
|
||||
|
|
@ -494,6 +499,7 @@ pub fn scenarios() -> Result<Vec<ScenarioText>, String> {
|
|||
out.push(ScenarioText {
|
||||
id: t.get(row, "scenario_id")?.to_string(),
|
||||
title: t.get(row, "title")?.to_string(),
|
||||
premise: t.get(row, "premise")?.to_string(),
|
||||
surface_problem_id: t.get(row, "surface_problem_id")?.to_string(),
|
||||
hidden_problem_ids: t
|
||||
.get(row, "hidden_problem_ids")?
|
||||
|
|
@ -1007,6 +1013,91 @@ mod card_text_tests {
|
|||
///
|
||||
/// Same shape as F24's solution deck. `inert`, role `default`: green
|
||||
/// because they agree, red the moment either side moves.
|
||||
/// **All four scenarios deal, and they are not the same board**
|
||||
/// (CB-WP-0047).
|
||||
///
|
||||
/// `setup` passed the literal `"SCN_01"`, so 15 of the 20 Problem
|
||||
/// cards had never been dealt by anything. This deals every scenario
|
||||
/// at every seat band and holds the deal to the Scenario card.
|
||||
#[test]
|
||||
fn every_scenario_deals_the_board_its_card_states() {
|
||||
for s in scenarios().expect("Scenarios.csv") {
|
||||
for (players, k) in [(2u8, 2usize), (4, 3), (6, 4)] {
|
||||
let dealt =
|
||||
deal(&s.id, players).unwrap_or_else(|e| panic!("{} at {players}p: {e}", s.id));
|
||||
assert_eq!(
|
||||
dealt.len(),
|
||||
k + 1,
|
||||
"{} at {players}p: Surface + {k} hidden is {} cards",
|
||||
s.id,
|
||||
k + 1
|
||||
);
|
||||
assert_eq!(
|
||||
dealt.iter().filter(|p| p.surface).count(),
|
||||
1,
|
||||
"{}: exactly one Surface Problem is dealt face up",
|
||||
s.id
|
||||
);
|
||||
// The card states the available total; the deal must be
|
||||
// able to reach the threshold or the board is unwinnable.
|
||||
let available: u32 = dealt.iter().map(|p| u32::from(p.value)).sum();
|
||||
let threshold = match players {
|
||||
0..=2 => s.thresholds.0,
|
||||
3..=4 => s.thresholds.1,
|
||||
_ => s.thresholds.2,
|
||||
};
|
||||
assert!(
|
||||
available >= threshold,
|
||||
"{} at {players}p: {available} points available against a \
|
||||
threshold of {threshold} — the group cannot win in principle",
|
||||
s.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **Two of the four scenarios are the same board** (CB-WP-0047).
|
||||
///
|
||||
/// SCN_01 and SCN_02 have identical suit-and-value profiles at every
|
||||
/// priority, so they play identically and differ only in prose. Not
|
||||
/// a defect — a designed reskin is a legitimate choice — but it is a
|
||||
/// fact about what "four scenarios" buys, and measuring them as four
|
||||
/// independent boards would be measuring two of them twice.
|
||||
///
|
||||
/// This is a **characterisation** test: it pins what is true today so
|
||||
/// that a change to either deck is a decision rather than a drift.
|
||||
#[test]
|
||||
fn which_scenarios_are_mechanically_distinct() {
|
||||
let profile = |id: &str| -> Vec<(u8, String, u8)> {
|
||||
let mut v: Vec<_> = problems_of(id)
|
||||
.expect(id)
|
||||
.into_iter()
|
||||
.map(|p| (p.priority, format!("{:?}", p.suit), p.value))
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
assert_eq!(
|
||||
profile("SCN_01"),
|
||||
profile("SCN_02"),
|
||||
"SCN_01 and SCN_02 diverged — they were identical boards; \
|
||||
if this is intended, the panel now measures four real boards"
|
||||
);
|
||||
for pair in [
|
||||
("SCN_01", "SCN_03"),
|
||||
("SCN_01", "SCN_04"),
|
||||
("SCN_03", "SCN_04"),
|
||||
] {
|
||||
assert_ne!(
|
||||
profile(pair.0),
|
||||
profile(pair.1),
|
||||
"{} and {} became the same board",
|
||||
pair.0,
|
||||
pair.1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_engine_agrees_with_the_editions_own_numbers() {
|
||||
for s in scenarios().expect("Scenarios.csv") {
|
||||
|
|
|
|||
|
|
@ -200,6 +200,21 @@ pub struct GroundState {
|
|||
/// variants existed still loads, as baseline — which is what it was.
|
||||
#[serde(default)]
|
||||
pub variant: Variant,
|
||||
/// Which of the edition's four Scenarios is on the table
|
||||
/// (CB-WP-0047).
|
||||
///
|
||||
/// **The engine dealt `SCN_01` and only `SCN_01`** for the whole life
|
||||
/// of this repo — the other three decks were vendored, gated, and
|
||||
/// never played. Fifteen of the twenty Problem cards had never
|
||||
/// reached a table.
|
||||
///
|
||||
/// In the state for the same reason `variant` is: the threshold and
|
||||
/// the whole board follow from it, so a recording that did not carry
|
||||
/// it could not be replayed. `#[serde(default)]` returns `SCN_01`,
|
||||
/// which is what every recording written before this field existed
|
||||
/// actually played.
|
||||
#[serde(default = "default_scenario")]
|
||||
pub scenario: String,
|
||||
/// GR-R09: set once the game has ended and scoring has run.
|
||||
pub outcome: Option<Outcome>,
|
||||
/// GR-S04/U4: retained so a deck reshuffle stays a pure function of
|
||||
|
|
@ -207,6 +222,53 @@ pub struct GroundState {
|
|||
pub seed: u64,
|
||||
}
|
||||
|
||||
/// GR-E01 by seat band, as the engine used to hardcode it.
|
||||
///
|
||||
/// **Kept as the fallback, and only as the fallback.** All four
|
||||
/// scenarios print 5/7/9, so this and the edition agree today — which is
|
||||
/// exactly why a test comparing the two numbers proves nothing. See
|
||||
/// `threshold_from`.
|
||||
fn seat_band_threshold(seats: usize) -> u32 {
|
||||
match seats {
|
||||
0..=2 => 5,
|
||||
3..=4 => 7,
|
||||
_ => 9,
|
||||
}
|
||||
}
|
||||
|
||||
/// The threshold a Scenario card states, or the seat band if the edition
|
||||
/// does not have that card (CB-WP-0047).
|
||||
///
|
||||
/// **Split out so it can be tested against a card that disagrees.** The
|
||||
/// first version read the edition inline, and mutating it back to the
|
||||
/// hardcoded bands left every test green — because all four scenarios
|
||||
/// print 5/7/9 and the two paths are observationally identical on every
|
||||
/// input the edition can supply. A control that cannot separate the
|
||||
/// thing it is about from its fallback is not a control; taking a list
|
||||
/// as an argument lets one be written.
|
||||
#[cfg(feature = "scenarios")]
|
||||
fn threshold_from(list: &[crate::edition::ScenarioText], scenario: &str, seats: usize) -> u32 {
|
||||
match list.iter().find(|s| s.id == scenario) {
|
||||
Some(s) => {
|
||||
let (two, three_four, five_six) = s.thresholds;
|
||||
match seats {
|
||||
0..=2 => two,
|
||||
3..=4 => three_four,
|
||||
_ => five_six,
|
||||
}
|
||||
}
|
||||
None => seat_band_threshold(seats),
|
||||
}
|
||||
}
|
||||
|
||||
/// The scenario every recording written before CB-WP-0047 played.
|
||||
///
|
||||
/// **Not "the first scenario" — the one that was actually dealt.** The
|
||||
/// distinction matters if the edition ever reorders `Scenarios.csv`.
|
||||
fn default_scenario() -> String {
|
||||
"SCN_01".to_string()
|
||||
}
|
||||
|
||||
/// GR-E02..E04: the three scoring modes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ScoringMode {
|
||||
|
|
@ -1731,13 +1793,31 @@ impl GroundState {
|
|||
events
|
||||
}
|
||||
|
||||
/// GR-E01: the Scenario threshold by player count (dataset 0.1).
|
||||
/// GR-E01: the Scenario threshold, **off the Scenario card**.
|
||||
///
|
||||
/// This was `match seats { 0..=2 => 5, 3..=4 => 7, _ => 9 }` — the
|
||||
/// engine's own copy of a number four cards already print, which is
|
||||
/// exactly what F25 was raised about. It was right only because all
|
||||
/// four scenarios happen to agree, and a fifth scenario with a
|
||||
/// different threshold would have been scored against the old one
|
||||
/// with nothing to say so.
|
||||
///
|
||||
/// The bands survive as the fallback for a state whose scenario the
|
||||
/// edition cannot supply. **That path is unreachable through
|
||||
/// `setup`**, which refuses an unknown scenario before dealing, and
|
||||
/// a test holds it unreachable — a fallback nothing can reach is the
|
||||
/// only kind that cannot silently answer for the real one.
|
||||
#[cfg(feature = "scenarios")]
|
||||
fn threshold(&self) -> u32 {
|
||||
match self.players.len() {
|
||||
0..=2 => 5,
|
||||
3..=4 => 7,
|
||||
_ => 9,
|
||||
}
|
||||
let list = crate::edition::scenarios().unwrap_or_default();
|
||||
threshold_from(&list, &self.scenario, self.players.len())
|
||||
}
|
||||
|
||||
/// Without the `scenarios` feature there is no edition to read, so
|
||||
/// the seat bands are the whole answer rather than a fallback.
|
||||
#[cfg(not(feature = "scenarios"))]
|
||||
fn threshold(&self) -> u32 {
|
||||
seat_band_threshold(self.players.len())
|
||||
}
|
||||
|
||||
/// GR-E01..E04: final scoring for the configured mode.
|
||||
|
|
@ -2055,6 +2135,61 @@ impl GroundState {
|
|||
// GR-S04's deck now lives in `edition::solution_deck` (ADR-0011),
|
||||
// beside the Problem data it is dealt against.
|
||||
|
||||
/// Which Scenario a `Setup` preset names, and for how many seats
|
||||
/// (CB-WP-0047).
|
||||
///
|
||||
/// | preset | scenario |
|
||||
/// |---|---|
|
||||
/// | `standard-4p` | `SCN_01` |
|
||||
/// | `scn-03-4p` | `SCN_03` |
|
||||
///
|
||||
/// **`standard-Np` keeps meaning exactly what it meant**, which is not a
|
||||
/// convenience: twenty-six recorded scenarios name it, and a grammar
|
||||
/// that redefined it would have moved every one of their boards while
|
||||
/// their hashes still claimed to pin them (ADR-0019's discipline, and
|
||||
/// the reason `serde(default)` on the field returns `SCN_01`).
|
||||
///
|
||||
/// The seat count is checked here rather than after the deal, because
|
||||
/// `deal` refuses an unknown scenario and a preset naming the wrong seat
|
||||
/// count would otherwise be reported as a scenario problem.
|
||||
#[cfg(feature = "scenarios")]
|
||||
fn parse_preset(preset: &str, seats: u8) -> Result<String, String> {
|
||||
let suffix = format!("-{seats}p");
|
||||
let Some(head) = preset.strip_suffix(&suffix) else {
|
||||
return Err(format!(
|
||||
"preset {preset:?} does not match {seats} players \
|
||||
(expected {:?} or e.g. {:?})",
|
||||
format!("standard{suffix}"),
|
||||
format!("scn-02{suffix}"),
|
||||
));
|
||||
};
|
||||
let id = match head {
|
||||
"standard" => default_scenario(),
|
||||
other => {
|
||||
let n = other.strip_prefix("scn-").ok_or_else(|| {
|
||||
format!("preset {preset:?}: expected \"standard\" or \"scn-0N\", got {other:?}")
|
||||
})?;
|
||||
format!("SCN_{n}")
|
||||
}
|
||||
};
|
||||
// **Checked against the edition, not against a pattern.** `SCN_09`
|
||||
// matches the shape and is not a scenario; dealing it would fail
|
||||
// later with a message about Problems rather than about the preset.
|
||||
let known = crate::edition::scenarios()?;
|
||||
if !known.iter().any(|s| s.id == id) {
|
||||
return Err(format!(
|
||||
"preset {preset:?} names {id}, which the edition does not have \
|
||||
(it has {})",
|
||||
known
|
||||
.iter()
|
||||
.map(|s| s.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[cfg(feature = "scenarios")]
|
||||
impl ScenarioGame for GroundState {
|
||||
/// GR-S01..S04. The `standard-Np` presets differ only in seat count;
|
||||
|
|
@ -2063,17 +2198,13 @@ impl ScenarioGame for GroundState {
|
|||
/// modelled.
|
||||
fn setup(setup: &Setup, seed: u64) -> Result<Self, String> {
|
||||
let seats = setup.players;
|
||||
let expected = format!("standard-{seats}p");
|
||||
if setup.preset != expected {
|
||||
return Err(format!(
|
||||
"preset {:?} does not match {seats} players (expected {expected:?})",
|
||||
setup.preset
|
||||
));
|
||||
}
|
||||
let scenario = parse_preset(&setup.preset, seats)?;
|
||||
// GR-S01 as ruled 2026-08-04: Surface always, plus hidden
|
||||
// priorities 1..=k. The values and suits come from the edition
|
||||
// (ADR-0011); the engine used to invent both.
|
||||
let dealt = crate::edition::deal("SCN_01", seats)?;
|
||||
// (ADR-0011); the engine used to invent both. **And now the
|
||||
// scenario does too** — this argument was the literal `"SCN_01"`
|
||||
// for the whole life of the repo (CB-WP-0047).
|
||||
let dealt = crate::edition::deal(&scenario, seats)?;
|
||||
let mut rng = ChaChaRng::from_seed(Seed(seed));
|
||||
|
||||
// GR-S04: shuffle first, then deal, so the deal is seed-derived.
|
||||
|
|
@ -2139,6 +2270,7 @@ impl ScenarioGame for GroundState {
|
|||
support_responses: BTreeMap::new(),
|
||||
darvo_targets: BTreeMap::new(),
|
||||
mode: ScoringMode::SharedGround,
|
||||
scenario,
|
||||
// Baseline. The driver overwrites this after setup and
|
||||
// before the hash is taken, which is the route `mode` uses
|
||||
// (`table.rs`) — so a recorded session replays under the
|
||||
|
|
@ -3343,6 +3475,7 @@ mod tests {
|
|||
darvo_targets: BTreeMap::new(),
|
||||
mode: ScoringMode::SharedGround,
|
||||
variant: Variant::Baseline,
|
||||
scenario: default_scenario(),
|
||||
outcome: None,
|
||||
seed: 0,
|
||||
}
|
||||
|
|
@ -3380,6 +3513,204 @@ mod tests {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
/// **The mastery rating counts cards; the Mode card counts points**
|
||||
/// (F28, CB-WP-0047).
|
||||
///
|
||||
/// MODE_COOP reads: *"All claimed Problem cards form one shared
|
||||
/// score. … For a mastery rating, subtract 1 for each Blame token
|
||||
/// still in play and 1 for each Denied Problem."*
|
||||
///
|
||||
/// The shared score is the claimed **value** — `total`, which is what
|
||||
/// the threshold is compared against two lines earlier. `mastery`
|
||||
/// subtracts the same penalties from the claimed **count**. Both
|
||||
/// readings fit the sentence; they disagree on every game where a
|
||||
/// 3-point Problem is claimed, which is most of them.
|
||||
///
|
||||
/// **A characterisation test, not a correction.** Scoring is
|
||||
/// `ground-game`'s to rule on (ADR-0015). This pins what the engine
|
||||
/// does today and shows the size of the disagreement, so the ruling
|
||||
/// has a number in front of it.
|
||||
#[test]
|
||||
fn the_mastery_rating_and_the_shared_score_count_different_things() {
|
||||
let mut s = setup_3p(11);
|
||||
s.mode = ScoringMode::SharedGround;
|
||||
// Two claimed Problems worth 2 and 3 — five points, two cards.
|
||||
let seats: Vec<PlayerId> = s.players.keys().copied().collect();
|
||||
s.problems.insert(
|
||||
1,
|
||||
ProblemState {
|
||||
suit: Suit::Repair,
|
||||
value: 2,
|
||||
face_up: true,
|
||||
denied: false,
|
||||
claimed_by: Some(seats[0]),
|
||||
protected_this_round: false,
|
||||
owner: None,
|
||||
scope: None,
|
||||
},
|
||||
);
|
||||
s.problems.insert(
|
||||
2,
|
||||
ProblemState {
|
||||
suit: Suit::Change,
|
||||
value: 3,
|
||||
face_up: true,
|
||||
denied: false,
|
||||
claimed_by: Some(seats[1]),
|
||||
protected_this_round: false,
|
||||
owner: None,
|
||||
scope: None,
|
||||
},
|
||||
);
|
||||
let o = s.score();
|
||||
assert_eq!(o.total, 5, "the shared score is claimed VALUE");
|
||||
assert_eq!(
|
||||
o.mastery,
|
||||
Some(2),
|
||||
"mastery is the claimed COUNT, with no penalties in play"
|
||||
);
|
||||
// The disagreement, stated as a number rather than as a worry.
|
||||
assert_ne!(
|
||||
i32::try_from(o.total).unwrap(),
|
||||
o.mastery.unwrap(),
|
||||
"the two readings agree on this board, so F28 has no bite here \
|
||||
and the example needs replacing"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every scenario is reachable through `setup`** (CB-WP-0047).
|
||||
///
|
||||
/// `setup` passed the literal `"SCN_01"`. `deal` had taken a
|
||||
/// scenario id since the day it was written and no caller ever
|
||||
/// passed a different one — the parameter was the whole seam and it
|
||||
/// sat unused, which is why three decks went unplayed without any
|
||||
/// gate noticing.
|
||||
#[test]
|
||||
fn every_scenario_can_be_set_up_and_carries_its_own_board() {
|
||||
use cb_game_runtime::{ScenarioGame, Setup};
|
||||
let mut boards = std::collections::BTreeSet::new();
|
||||
for s in crate::edition::scenarios().expect("Scenarios.csv") {
|
||||
let preset = if s.id == "SCN_01" {
|
||||
"standard-4p".to_string()
|
||||
} else {
|
||||
format!("scn-{}-4p", s.id.rsplit('_').next().unwrap())
|
||||
};
|
||||
let state = GroundState::setup(
|
||||
&Setup {
|
||||
players: 4,
|
||||
preset: preset.clone(),
|
||||
patch: Default::default(),
|
||||
},
|
||||
7,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{preset}: {e}"));
|
||||
assert_eq!(state.scenario, s.id, "{preset} dealt the wrong scenario");
|
||||
boards.insert(
|
||||
state
|
||||
.problems
|
||||
.values()
|
||||
.map(|p| (format!("{:?}", p.suit), p.value))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
// SCN_01 and SCN_02 are the same board by design, so four
|
||||
// scenarios yield THREE distinct boards. Asserting 4 here would
|
||||
// be asserting a fact about the edition that is not true.
|
||||
assert_eq!(
|
||||
boards.len(),
|
||||
3,
|
||||
"the four scenarios yield {} distinct boards; SCN_01 and SCN_02 \
|
||||
were identical and the other two differ",
|
||||
boards.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// The preset grammar, including what it must keep meaning.
|
||||
#[test]
|
||||
fn the_preset_names_a_scenario_and_a_seat_count() {
|
||||
assert_eq!(parse_preset("standard-4p", 4).unwrap(), "SCN_01");
|
||||
assert_eq!(parse_preset("scn-03-6p", 6).unwrap(), "SCN_03");
|
||||
// Twenty-six recordings name `standard-Np`; if this ever stopped
|
||||
// meaning SCN_01 every one of them would replay a different board
|
||||
// while its hash still claimed to pin the old one.
|
||||
assert_eq!(parse_preset("standard-2p", 2).unwrap(), "SCN_01");
|
||||
// A preset that looks like an id but names no card is refused
|
||||
// HERE, so the error is about the preset and not about Problems.
|
||||
assert!(parse_preset("scn-09-4p", 4).is_err());
|
||||
assert!(parse_preset("standard-4p", 3).is_err());
|
||||
assert!(parse_preset("nonsense-4p", 4).is_err());
|
||||
}
|
||||
|
||||
/// **The threshold comes off the Scenario card** (CB-WP-0047, F25).
|
||||
#[test]
|
||||
fn the_threshold_is_the_editions_and_the_fallback_is_unreachable() {
|
||||
use cb_game_runtime::{ScenarioGame, Setup};
|
||||
for s in crate::edition::scenarios().expect("Scenarios.csv") {
|
||||
for (players, want) in [
|
||||
(2u8, s.thresholds.0),
|
||||
(4, s.thresholds.1),
|
||||
(6, s.thresholds.2),
|
||||
] {
|
||||
let preset = if s.id == "SCN_01" {
|
||||
format!("standard-{players}p")
|
||||
} else {
|
||||
format!("scn-{}-{players}p", s.id.rsplit('_').next().unwrap())
|
||||
};
|
||||
let state = GroundState::setup(
|
||||
&Setup {
|
||||
players,
|
||||
preset,
|
||||
patch: Default::default(),
|
||||
},
|
||||
3,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
state.threshold(),
|
||||
want,
|
||||
"{} at {players}p: the engine scored against its own number",
|
||||
s.id
|
||||
);
|
||||
}
|
||||
}
|
||||
// **The real control.** Every shipped scenario prints 5/7/9, so
|
||||
// the loop above passes whether the number came off the card or
|
||||
// off the old hardcoded bands — verified by mutation: reverting
|
||||
// to the bands left it green. This asks a card that DISAGREES.
|
||||
let odd = crate::edition::ScenarioText {
|
||||
id: "SCN_XX".into(),
|
||||
title: "a card that disagrees".into(),
|
||||
premise: String::new(),
|
||||
surface_problem_id: String::new(),
|
||||
hidden_problem_ids: vec![],
|
||||
thresholds: (4, 6, 8),
|
||||
starting_stress: String::new(),
|
||||
round_track: String::new(),
|
||||
};
|
||||
for (seats, want) in [(2usize, 4), (4, 6), (6, 8)] {
|
||||
assert_eq!(
|
||||
threshold_from(std::slice::from_ref(&odd), "SCN_XX", seats),
|
||||
want,
|
||||
"the threshold did not come off the Scenario card"
|
||||
);
|
||||
}
|
||||
// And the fallback is what answers for a card the edition lacks.
|
||||
assert_eq!(threshold_from(&[], "SCN_XX", 4), 7);
|
||||
|
||||
// That fallback is unreachable through `setup`, which refuses an
|
||||
// unknown scenario before dealing. A fallback nothing can reach
|
||||
// is the only kind that cannot silently answer for the real one.
|
||||
assert!(GroundState::setup(
|
||||
&Setup {
|
||||
players: 4,
|
||||
preset: "scn-09-4p".into(),
|
||||
patch: Default::default(),
|
||||
},
|
||||
3
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
/// GR-S02/S04: every seat starts at Stress 2 with two dealt cards,
|
||||
/// and the deck loses exactly what was dealt.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ pub struct GroundView {
|
|||
/// change as "no changes" after playing the default.
|
||||
#[serde(default)]
|
||||
pub variant: crate::Variant,
|
||||
/// Which Scenario is on the table (CB-WP-0047).
|
||||
///
|
||||
/// The player was told the premise by nothing: the same four boards
|
||||
/// would have arrived unannounced. Same reasoning as `variant` —
|
||||
/// CB-WP-0044 shipped a rules change the page could not name and the
|
||||
/// maintainer reported it as "no changes".
|
||||
#[serde(default = "crate::default_scenario")]
|
||||
pub scenario: String,
|
||||
pub players: BTreeMap<PlayerId, PlayerView>,
|
||||
pub relations: BTreeMap<Pair, Relation>,
|
||||
pub problems: BTreeMap<u32, ProblemView>,
|
||||
|
|
@ -178,6 +186,7 @@ impl Project for GroundState {
|
|||
step: self.step,
|
||||
mode: self.mode,
|
||||
variant: self.variant,
|
||||
scenario: self.scenario.clone(),
|
||||
players: self
|
||||
.players
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -54,9 +54,36 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by
|
|||
| F23 | inconsistent | applied | decisions/ADR-0017-chaos-window-2-verdict.md | counterexample | 2026-08-07 | clay-borg |
|
||||
| F22 | underdetermined | withdrawn | games_ground::edition::supply_tests::play_never_exceeds_the_components_the_box_holds | counterexample | 2026-08-07 | clay-borg |
|
||||
| F26 | inert | raised | `crates/cb-render-html/src/lib.rs::a_scoped_table_says_what_a_scope_does` | counterexample | 2026-08-08 | ground-game |
|
||||
| F27 | unplayed | raised | `games/ground/examples/scenario-panel.rs` | counterexample | 2026-08-08 | clay-borg |
|
||||
| F28 | underdetermined | raised | `games_ground::tests::the_mastery_rating_and_the_shared_score_count_different_things` | counterexample | 2026-08-08 | ground-game |
|
||||
|
||||
<!-- design-register:end -->
|
||||
|
||||
- **F27 — the two competitive modes are scoring lenses over cooperative
|
||||
play.** `scenario-panel` finds group success *exactly* equal across
|
||||
SHARED GROUND, COMMON PROBLEM and BONDED COALITIONS in all 36 cells.
|
||||
Correct arithmetic: `GreedyPolicy` maximises the group outcome and
|
||||
never reads `state.mode`, so the same games are played and only the
|
||||
winner set is carved differently. **Whether a mode changes how GROUND
|
||||
is played is therefore untested**, and cannot be tested by any panel
|
||||
built from the current policies — it needs one that plays for personal
|
||||
score. `unplayed` rather than `inert`: the modes score correctly, they
|
||||
have simply never faced a seat that wanted to win alone.
|
||||
|
||||
- **F28 — SHARED GROUND's mastery rating counts cards where the mode
|
||||
card counts points.** *"All claimed Problem cards form one shared score.
|
||||
… For a mastery rating, subtract 1 for each Blame token still in play
|
||||
and 1 for each Denied Problem."* The shared score is claimed **value**
|
||||
(`total`); `mastery` subtracts the same penalties from the claimed
|
||||
**count**. Both readings fit the sentence and they differ on every game
|
||||
where a 3-point Problem is claimed. Ours to report, theirs to rule.
|
||||
|
||||
**Sensitivity:** vary only the point value of the claimed Problems and
|
||||
the gap moves with `sum(value) - count`. The two readings agree exactly
|
||||
when every claimed Problem is worth 1 — no Problem in the edition is —
|
||||
or when nothing is claimed, which is a loss. Everything else is held
|
||||
fixed: same board, same penalties, same threshold.
|
||||
|
||||
- **F26 — a package that adds a FILE is invisible, where a package that
|
||||
adds a column is not.** `h2-scoped-problem-stress` ships
|
||||
`Rules_Text.csv` — twenty-two passages of player-facing rules, including
|
||||
|
|
|
|||
|
|
@ -1288,6 +1288,7 @@ mod tests {
|
|||
serve: Some(0),
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: crate::table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
},
|
||||
|
|
@ -1479,6 +1480,7 @@ mod tests {
|
|||
serve: Some(0),
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: crate::table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -140,12 +140,18 @@ fn render_player(id: PlayerId, p: &PlayerView, is_viewer: bool) -> String {
|
|||
pub fn render(view: &GroundView) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"\nround {} step {:?} lead {} mode {:?} rules {} deck {} discard [{}]\n",
|
||||
"\nround {} step {:?} lead {} mode {:?} rules {} scenario {} \
|
||||
deck {} discard [{}]\n",
|
||||
view.round,
|
||||
view.step,
|
||||
seat_name(view.lead),
|
||||
view.mode,
|
||||
view.variant.id(),
|
||||
// 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
|
||||
// read by a player and shows the printed title.
|
||||
view.scenario,
|
||||
view.solution_deck_len,
|
||||
cards(&view.solution_discard),
|
||||
));
|
||||
|
|
@ -497,6 +503,7 @@ mod tests {
|
|||
// 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_"),
|
||||
("viewer", "(you)"),
|
||||
("solution_deck_len", "deck 11"),
|
||||
("solution_discard.*.suit", "discard [Repair, Change]"),
|
||||
|
|
@ -614,6 +621,7 @@ mod tests {
|
|||
lead: p2,
|
||||
step: RoundStep::Resolve,
|
||||
mode: ScoringMode::BondedCoalitions,
|
||||
scenario: "SCN_01".into(),
|
||||
players,
|
||||
relations: BTreeMap::from([
|
||||
(Pair::new(p1, p2), Relation::Bond),
|
||||
|
|
@ -770,6 +778,7 @@ mod tests {
|
|||
serve: None,
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: crate::table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -62,6 +62,61 @@ enum Mode {
|
|||
},
|
||||
}
|
||||
|
||||
/// A scenario as the player names it, as the edition names it.
|
||||
///
|
||||
/// Accepts `2`, `02`, `scn-02`, `SCN_02` and the title's first word, so
|
||||
/// that `--scenario confidence` works — the ids are the edition's
|
||||
/// vocabulary and *"Broken Confidence"* is the player's.
|
||||
///
|
||||
/// **Validated against the edition, never against a pattern.** `SCN_09`
|
||||
/// looks exactly like an id; the error names what the edition has.
|
||||
fn normalise_scenario(v: &str) -> Result<String, String> {
|
||||
let known = games_ground::edition::scenarios()?;
|
||||
let want = v.trim().to_ascii_lowercase();
|
||||
let digits: String = want.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
let by_id = |s: &games_ground::edition::ScenarioText| {
|
||||
s.id.to_ascii_lowercase() == want
|
||||
|| (!digits.is_empty()
|
||||
&& s.id
|
||||
.rsplit('_')
|
||||
.next()
|
||||
.is_some_and(|n| n.parse::<u32>().ok() == digits.parse::<u32>().ok()))
|
||||
};
|
||||
if let Some(s) = known.iter().find(|s| by_id(s)) {
|
||||
return Ok(s.id.clone());
|
||||
}
|
||||
// By title, so the four cards can be named by what is printed on them.
|
||||
if let Some(s) = known
|
||||
.iter()
|
||||
.find(|s| s.title.to_ascii_lowercase().contains(&want) && !want.is_empty())
|
||||
{
|
||||
return Ok(s.id.clone());
|
||||
}
|
||||
Err(format!(
|
||||
"unknown --scenario {v:?} (the edition has {})",
|
||||
known
|
||||
.iter()
|
||||
.map(|s| format!("{} {:?}", s.id, s.title))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
/// `scn-02-4p`, or `standard-4p` for the scenario every recording plays.
|
||||
///
|
||||
/// **`SCN_01` keeps the old preset string.** Twenty-six recorded
|
||||
/// scenarios name `standard-Np`; emitting `scn-01-Np` for the same board
|
||||
/// would have made every one of them unreplayable to no purpose.
|
||||
pub fn scenario_preset(scenario: &str, players: u8) -> String {
|
||||
if scenario == "SCN_01" {
|
||||
return format!("standard-{players}p");
|
||||
}
|
||||
format!(
|
||||
"scn-{}-{players}p",
|
||||
scenario.rsplit('_').next().unwrap_or(scenario)
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_args(argv: &[String]) -> Result<Mode, String> {
|
||||
let mut config = Config::default();
|
||||
let mut source: Option<std::path::PathBuf> = None;
|
||||
|
|
@ -141,6 +196,16 @@ fn parse_args(argv: &[String]) -> Result<Mode, String> {
|
|||
// cheap now where a retrofit would not be.
|
||||
// CB-WP-0038: ground-game names these in
|
||||
// `editions/catalog.yaml`; `--variant` takes that id.
|
||||
// CB-WP-0047: WHICH SCENARIO. `deal` has taken a scenario id
|
||||
// since it was written; the call site passed the literal
|
||||
// "SCN_01", so three of four decks were unreachable from the
|
||||
// driver — the same shape as `--mode` before F14.
|
||||
"--scenario" => {
|
||||
play_flags.push(flag.into());
|
||||
let v = value(i, argv, flag)?;
|
||||
config.scenario = normalise_scenario(&v)?;
|
||||
i += 2;
|
||||
}
|
||||
"--variant" => {
|
||||
play_flags.push(flag.into());
|
||||
config.variant = value(i, argv, flag)?.parse()?;
|
||||
|
|
@ -314,6 +379,7 @@ mod tests {
|
|||
serve: None,
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
};
|
||||
|
|
@ -361,6 +427,7 @@ mod tests {
|
|||
serve: None,
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
};
|
||||
|
|
@ -430,6 +497,7 @@ mod tests {
|
|||
serve: None,
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
};
|
||||
|
|
@ -496,6 +564,7 @@ mod tests {
|
|||
serve: None,
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
};
|
||||
|
|
@ -560,6 +629,7 @@ mod tests {
|
|||
serve: None,
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: table::Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ pub struct Config {
|
|||
/// patch, so two of the three shipped modes were unreachable from the
|
||||
/// only way anyone actually plays.
|
||||
pub mode: games_ground::ScoringMode,
|
||||
/// Which of the edition's four Scenarios to deal (CB-WP-0047).
|
||||
///
|
||||
/// **The same shape as `mode` and the same history.** `setup` dealt
|
||||
/// the literal `"SCN_01"`, so three of the four vendored decks — 15
|
||||
/// of the 20 Problem cards — had never reached a table through the
|
||||
/// only way anyone actually plays. Stored as the preset's scenario
|
||||
/// id so the kernel does the validating.
|
||||
pub scenario: String,
|
||||
/// How much ornamentation is performed (CB-WP-0036,
|
||||
/// [`specs/Ornamentation.md`]).
|
||||
///
|
||||
|
|
@ -103,6 +111,7 @@ impl Default for Config {
|
|||
serve: None,
|
||||
trial: None,
|
||||
mode: games_ground::ScoringMode::SharedGround,
|
||||
scenario: "SCN_01".into(),
|
||||
pace: Pace::Speed,
|
||||
variant: games_ground::Variant::Baseline,
|
||||
}
|
||||
|
|
@ -399,7 +408,7 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
|||
) -> Result<(Summary, crate::hotseat::EndChoice), String> {
|
||||
let setup = Setup {
|
||||
players: config.players,
|
||||
preset: format!("standard-{}p", config.players),
|
||||
preset: crate::scenario_preset(&config.scenario, config.players),
|
||||
patch: Default::default(),
|
||||
};
|
||||
let mut initial = <GroundState as cb_game_runtime::ScenarioGame>::setup(&setup, config.seed)?;
|
||||
|
|
|
|||
|
|
@ -53,7 +53,16 @@ def reproduced(row, root=ROOT):
|
|||
"""GameDesign §1.1: the artifact must resolve. A named test is admitted
|
||||
by its `crate::module::name` shape; anything else must be a real path
|
||||
on disk, and that is checked by stat, not by prefix."""
|
||||
p = row["repro"]
|
||||
# **Backticks are markdown, not part of the path** (CB-WP-0047).
|
||||
#
|
||||
# The register writes code spans, and every NAMED TEST survived that
|
||||
# because `::` short-circuits before the stat. A backticked FILE path
|
||||
# did not: os.path.exists("`games/.../x.rs`") is False, so a
|
||||
# reproduction sitting on disk was reported as absent and its finding
|
||||
# counted as debt against a target of zero. Same shape as ADR-0018:
|
||||
# the computation was right and the string was not what it looked
|
||||
# like. Found by F27, whose panel existed and did not count.
|
||||
p = row["repro"].strip().strip("`").strip()
|
||||
if p in ("", "—", "-"):
|
||||
return False
|
||||
if "::" in p: # a named test
|
||||
|
|
@ -169,6 +178,17 @@ def self_test():
|
|||
check("a named test counts", reproduced({"repro": "games_ground::view::tests::a_spectator_sees_no_hands"}))
|
||||
check("an em-dash does not count", not reproduced({"repro": "—"}))
|
||||
|
||||
# CB-WP-0047: the register writes CODE SPANS, so this is fed markdown.
|
||||
check("a BACKTICKED path that exists counts",
|
||||
reproduced({"repro": "`scenarios/ground/gr-p05-solve-legality.yaml`"}),
|
||||
"a reproduction on disk was reported as absent, and the finding "
|
||||
"counted as debt against a target of zero")
|
||||
check("a backticked path that does NOT exist still does not count",
|
||||
not reproduced({"repro": "`scenarios/ground/nope.yaml`"}),
|
||||
"stripping the span must not turn the check off")
|
||||
check("a backticked em-dash does not count",
|
||||
not reproduced({"repro": "`—`"}))
|
||||
|
||||
# The distinction the backfill discovered: a green DEFAULT is expected,
|
||||
# a green COUNTEREXAMPLE is the alarm. Without this the report cried
|
||||
# wolf over U2, whose scenario is green precisely because the
|
||||
|
|
|
|||
169
workplans/CB-WP-0047-all-four-boards-and-all-three-modes.md
Normal file
169
workplans/CB-WP-0047-all-four-boards-and-all-three-modes.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
---
|
||||
id: CB-WP-0047
|
||||
kind: product
|
||||
title: "All four boards and all three modes"
|
||||
status: done
|
||||
---
|
||||
|
||||
# Purpose
|
||||
|
||||
```
|
||||
structural tier M (changes the state and therefore the recording, and
|
||||
moves a reported number -- the threshold -- from the
|
||||
engine to the edition)
|
||||
declared tier M
|
||||
```
|
||||
|
||||
> *"lets have a look at the cards and the rules to those cards again and
|
||||
> implement all the scenarios and modes"*
|
||||
|
||||
## What was actually missing
|
||||
|
||||
The two halves turned out to be very different sizes.
|
||||
|
||||
**The modes were already implemented.** `ScoringMode`'s three arms all
|
||||
score, `--mode` reaches them, F14 fixed the driver. What was missing was
|
||||
that **nothing had ever compared them**, and the page never named them —
|
||||
see below.
|
||||
|
||||
**The scenarios were not implemented at all.** `edition::deal` has taken a
|
||||
`scenario_id` since the day it was written, and the only caller passed the
|
||||
literal `"SCN_01"`:
|
||||
|
||||
```rust
|
||||
let dealt = crate::edition::deal("SCN_01", seats)?;
|
||||
```
|
||||
|
||||
**The seam was the whole mechanism and it sat unused.** 15 of the 20
|
||||
Problem cards had never been dealt by anything — not a gate, not a panel,
|
||||
not a player. Nothing was red, because nothing asked.
|
||||
|
||||
## Task: deal every board, name every mode
|
||||
|
||||
```task
|
||||
id: CB-WP-0047-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
### The scenario is state
|
||||
|
||||
`GroundState.scenario`, `#[serde(default = "default_scenario")]` → `SCN_01`.
|
||||
In the state for the same reason `variant` is (CB-WP-0038): the board and
|
||||
the threshold follow from it, so a recording that did not carry it could
|
||||
not be replayed.
|
||||
|
||||
**`standard-Np` keeps meaning `SCN_01`.** Twenty-six recorded scenarios
|
||||
name that preset; a grammar that redefined it would have moved every one
|
||||
of their boards while their hashes still claimed to pin them. `scn-03-4p`
|
||||
names the rest. `--scenario` accepts `3`, `scn-03`, `SCN_03` or
|
||||
`confidence` — ids are the edition's vocabulary, *"Broken Confidence"* is
|
||||
the player's — and is validated against the edition, never a pattern, so
|
||||
`SCN_09` is refused by name.
|
||||
|
||||
### The threshold now comes off the Scenario card
|
||||
|
||||
`threshold()` was `match seats { 0..=2 => 5, 3..=4 => 7, _ => 9 }` — the
|
||||
engine's own copy of a number four cards already print, which is what F25
|
||||
was raised about.
|
||||
|
||||
**The first version of this control was worthless and mutation said so.**
|
||||
Reverting to the hardcoded bands left every test green, because all four
|
||||
scenarios print 5/7/9 and the two paths are observationally identical on
|
||||
every input the edition can supply. A control that cannot separate the
|
||||
thing it is about from its fallback is not a control.
|
||||
|
||||
Fixed by splitting `threshold_from(list, scenario, seats)`, which can be
|
||||
handed a card that **disagrees** (4/6/8). That test goes red under the
|
||||
mutation; the loop over real scenarios never could.
|
||||
|
||||
### The page names the mode, the board, and what winning means
|
||||
|
||||
The header read `scoring CommonProblem` — the Rust variant's name, where
|
||||
the Mode card is titled *"COMMON PROBLEM, PERSONAL EDGE"*. **CB-WP-0034
|
||||
deleted this exact defect from the move buttons** and it was still
|
||||
standing on the one line that says what winning means.
|
||||
|
||||
**And the coverage probe was holding it in place.** `("mode", "scoring
|
||||
BondedCoalitions")` — a probe matching `Debug` output, which went red the
|
||||
moment the defect was fixed. That is the **third** time this shape has
|
||||
been found (CB-WP-0024, CB-WP-0034, here), and it is now specific enough
|
||||
to state as a rule: *a probe that names a rendering pins that rendering;
|
||||
probes name facts.*
|
||||
|
||||
The page now carries the Scenario's premise (*what happened*) and the Mode
|
||||
card's rules text (*how this game is won*) — including the **tiebreak**,
|
||||
which is a rule a player can play for: "lower Stress, then more Bonds"
|
||||
changes what a losing seat should do in round five, and it lived in a
|
||||
column nothing read.
|
||||
|
||||
### Mutations
|
||||
|
||||
| mutation | what went red |
|
||||
|---|---|
|
||||
| `setup` deals `"SCN_01"` regardless of preset | *"the four scenarios yield 1 distinct board"* |
|
||||
| `threshold_from` ignores the card | *"the threshold did not come off the Scenario card"* |
|
||||
| header back to `{mode:?}` | *"the page shows the Rust variant's name"* |
|
||||
|
||||
**Done 2026-08-08.** `make all` green. Verified live on
|
||||
`make ground SCENARIO=4 MODE=coalitions`.
|
||||
|
||||
## What the panel found
|
||||
|
||||
`make panels` gained `scenario-panel`: 4 scenarios × 3 modes × 3 seat
|
||||
bands, greedy throughout, 100 games per cell.
|
||||
|
||||
**1. Two of the four scenarios are the same board.** SCN_01 and SCN_02
|
||||
have identical suits and values at every priority; every cell matches
|
||||
exactly. Not a defect — a designed reskin is a legitimate choice — but
|
||||
"four scenarios" buys **three** boards, and a panel that measured them as
|
||||
four independent boards would be measuring one of them twice. Pinned by a
|
||||
characterisation test so a future divergence is a decision, not a drift.
|
||||
|
||||
**2. SCN_04 is the hard board at 2 players** — 52% group success against
|
||||
67% and 73%. It is the only deck that needs **two Repair** solutions in
|
||||
the 2p deal, and the 2p Solution draw cannot always supply them. The seat
|
||||
band the thresholds treat as uniform is not uniform across boards.
|
||||
|
||||
**3. The three modes produce identical play.** Group success is *exactly*
|
||||
equal across all three modes in every cell. That is correct arithmetic and
|
||||
a real finding: `GreedyPolicy` maximises the group outcome and never
|
||||
consults `state.mode`, so **the two competitive modes are scoring lenses
|
||||
over cooperative play**. `win/g` differs only by how the winner set is
|
||||
carved from the same games. Whether COMMON PROBLEM and BONDED COALITIONS
|
||||
change how the game is *played* is untested and cannot be tested by this
|
||||
panel — it needs a policy that plays for personal score. Filed as F27.
|
||||
|
||||
**4. Every board is a formality at 6 players** — 100% group success in all
|
||||
twelve cells. Consistent with F17's shape and with H2's motivation; noted,
|
||||
not acted on.
|
||||
|
||||
## Not done here
|
||||
|
||||
- **No policy plays for personal score**, so F27 stands open and the two
|
||||
competitive modes remain unexercised as *incentives*. This is the
|
||||
largest remaining gap and it is the natural next pass.
|
||||
- **SHARED GROUND's mastery rating counts CLAIMED CARDS, not points.**
|
||||
The Mode card says *"All claimed Problem cards form one shared score …
|
||||
subtract 1 for each Blame token still in play and 1 for each Denied
|
||||
Problem"* — and the shared score is the claimed **value**, while
|
||||
`mastery` subtracts from the claimed **count**. Underdetermined rather
|
||||
than plainly wrong; raised for `ground-game` rather than changed here,
|
||||
because scoring is theirs to rule on.
|
||||
- **The trial log stamps the variant (CB-WP-0046) but not the scenario or
|
||||
the mode.** A note from SCN_04 coalitions is indistinguishable from a
|
||||
baseline SHARED GROUND note, which is the same defect one pass later in
|
||||
two new axes. The marker already carries attributes, so this is small.
|
||||
- **`design.py` reported an existing reproduction as absent.** It stats
|
||||
`row["repro"]` directly, and the register writes **code spans** — so a
|
||||
backticked file path was `os.path.exists("`games/…/x.rs`")` = False.
|
||||
Named tests survived because `::` short-circuits before the stat, which
|
||||
is why it had never shown. F27's panel existed and counted as debt
|
||||
against a target of zero. ADR-0018's shape again: right computation,
|
||||
wrong string. Fixed with three checks, including that stripping the
|
||||
span must not turn the check off.
|
||||
|
||||
- **`catalog.yaml` and both `VARIANT.md`s were re-vendored in this pass**: `ground-game` moved H2
|
||||
from `experimental` to `measured`, `decision: keep-as-experiment`,
|
||||
citing our CB-EV-0032. That is their ruling on the previous pass, not
|
||||
work done here.
|
||||
Loading…
Add table
Add a link
Reference in a new issue