CB-WP-0037 T02: the engine's rules against the edition's
Some checks failed
ci / check (push) Failing after 3s

Vendored DARVO.csv, Relations.csv and Scenarios.csv — the three unread
files that carry mechanism. The engine has implemented the DARVO sequence
and relation formation/breaking from GroundRules.md since the beginning,
and had never read the game owner's statement of them.

Every checkable clause agrees, and agreement is recorded rather than
noted: a survey that finds nothing and leaves no trace cannot be told from
one never run. The two hardest clauses to notice were already right —
Focus placed "even if the Attack was cancelled", and Focus removed when
the sequence ends before REVERSE.

The tests are tripwires, not derivations. The match was made by a person
reading prose, and that reading goes stale in silence when the prose
changes; each behaviour pins the phrase it was read from, so a reworded
edition goes red and asks for a human. Mutation-proven by rewording the
cancelled-Attack clause.

F25 raised, and it is the real yield: Scenarios.csv carries
threshold_2_players/3_4/5_6, starting_stress and round_track, and the
engine hardcodes all three — a match returning 5/7/9, stress: 2 at setup,
five rounds. They agree on all four scenarios. These are the most
contested numbers in the project; the whole 4/6/9 vs 5/7/9 episode turned
on them, and the engine has been right by maintenance coincidence rather
than by reading the file that owns them.

Also pinned: Problems.csv and Scenarios.csv both state the deal and the
engine reads only the first. They agree; nothing was checking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-08 00:21:40 +02:00
parent d98580fb4c
commit 53109ec5e5
7 changed files with 423 additions and 1 deletions

View file

@ -72,6 +72,9 @@ const ACTIONS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Action
const SOLUTIONS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Solutions.csv");
const MODES_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Modes.csv");
const TOKENS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Tokens.csv");
const DARVO_CSV: &str = include_str!("../../../editions/ground-darvo-r0/DARVO.csv");
const RELATIONS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Relations.csv");
const SCENARIOS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Scenarios.csv");
/// A vendored CSV, parsed into rows addressable by column name.
///
@ -410,6 +413,119 @@ pub fn deal(scenario_id: &str, players: u8) -> Result<Vec<EditionProblem>, Strin
Ok(dealt)
}
/// One DARVO stage as the edition states it (CB-WP-0037 T02).
///
/// **The engine already implements this sequence**, from `GroundRules.md`
/// and not from here. Vendoring it does not change behaviour; it makes
/// the behaviour checkable against the source that owns it.
#[derive(Debug, Clone)]
pub struct DarvoStageText {
pub order: u8,
pub stage: String,
pub mandatory_effect: String,
pub target_memory: String,
pub advance: String,
}
/// The DARVO sequence, in stage order.
pub fn darvo_stages() -> Result<Vec<DarvoStageText>, String> {
let t = Table::parse(DARVO_CSV, "DARVO.csv")?;
let mut out = Vec::new();
for row in &t.rows {
out.push(DarvoStageText {
order: t
.get(row, "stage_order")?
.parse()
.map_err(|_| "stage_order is not a number".to_string())?,
stage: t.get(row, "stage")?.to_string(),
mandatory_effect: t.get(row, "mandatory_effect")?.to_string(),
target_memory: t.get(row, "target_memory")?.to_string(),
advance: t.get(row, "advance")?.to_string(),
});
}
out.sort_by_key(|s| s.order);
Ok(out)
}
/// A scenario as the edition states it (CB-WP-0037 T02).
///
/// **This file states the deal a second time.** `Problems.csv` carries
/// `visibility` and `hidden_priority`; this carries the same board as
/// explicit id lists. The engine deals from the former, so the two are a
/// pair that can drift.
#[derive(Debug, Clone)]
pub struct ScenarioText {
pub id: String,
pub title: 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
/// own copy in `GroundState::threshold` (F25).
pub thresholds: (u32, u32, u32),
/// Prose, e.g. "All players start at Stress 2."
pub starting_stress: String,
/// Prose, e.g. "Rounds 1-5 printed along the bottom edge."
pub round_track: String,
}
/// The four scenarios. **The engine deals `SCN_01` and only `SCN_01`**
/// (ADR-0015 D5) — this is what says so out loud.
pub fn scenarios() -> Result<Vec<ScenarioText>, String> {
let t = Table::parse(SCENARIOS_CSV, "Scenarios.csv")?;
let mut out = Vec::new();
for row in &t.rows {
out.push(ScenarioText {
id: t.get(row, "scenario_id")?.to_string(),
title: t.get(row, "title")?.to_string(),
surface_problem_id: t.get(row, "surface_problem_id")?.to_string(),
hidden_problem_ids: t
.get(row, "hidden_problem_ids")?
.split('|')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
thresholds: (
t.get(row, "threshold_2_players")?
.parse()
.map_err(|_| "threshold_2_players is not a number".to_string())?,
t.get(row, "threshold_3_4_players")?
.parse()
.map_err(|_| "threshold_3_4_players is not a number".to_string())?,
t.get(row, "threshold_5_6_players")?
.parse()
.map_err(|_| "threshold_5_6_players is not a number".to_string())?,
),
starting_stress: t.get(row, "starting_stress")?.to_string(),
round_track: t.get(row, "round_track")?.to_string(),
});
}
Ok(out)
}
/// One relation side as the edition states it (CB-WP-0037 T02).
#[derive(Debug, Clone)]
pub struct RelationText {
pub side: String,
pub rules_text: String,
pub formation: String,
pub breaking: String,
}
/// Bond and Rivalry, with how each forms and breaks.
pub fn relations() -> Result<Vec<RelationText>, String> {
let t = Table::parse(RELATIONS_CSV, "Relations.csv")?;
let mut out = Vec::new();
for row in &t.rows {
out.push(RelationText {
side: t.get(row, "relation_side")?.to_string(),
rules_text: t.get(row, "rules_text")?.to_string(),
formation: t.get(row, "formation")?.to_string(),
breaking: t.get(row, "breaking")?.to_string(),
});
}
Ok(out)
}
/// The core Solution deck, 6 per suit in canonical order (GR-S04).
pub fn solution_deck() -> Vec<SolutionCard> {
[Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change]
@ -548,6 +664,9 @@ mod card_text_tests {
let _ = solutions();
let _ = modes();
let _ = tokens();
let _ = darvo_stages();
let _ = relations();
let _ = scenarios();
let files = [
("Problems.csv", CSV),
@ -555,6 +674,9 @@ mod card_text_tests {
("Solutions.csv", SOLUTIONS_CSV),
("Modes.csv", MODES_CSV),
("Tokens.csv", TOKENS_CSV),
("DARVO.csv", DARVO_CSV),
("Relations.csv", RELATIONS_CSV),
("Scenarios.csv", SCENARIOS_CSV),
];
let mut report = String::new();
let mut total_unread = 0usize;
@ -586,6 +708,222 @@ mod card_text_tests {
println!("{report}");
}
/// **F25** — the engine hardcodes numbers the edition states.
///
/// `GroundState::threshold` is a `match` returning 5/7/9;
/// `Scenarios.csv` carries `threshold_2_players`,
/// `threshold_3_4_players` and `threshold_5_6_players`. Setup writes
/// `stress: 2`; the edition says *"All players start at Stress 2."*
/// The engine runs five rounds; the edition prints a 1-5 track.
///
/// **They agree, on every scenario** — and that is the point, not the
/// reassurance. These are the most contested numbers in the project:
/// the whole 4/6/9 versus 5/7/9 episode turned on them, and the
/// engine has been right about them by maintenance coincidence rather
/// than by reading the file that owns them.
///
/// Same shape as F24's solution deck. `inert`, role `default`: green
/// because they agree, red the moment either side moves.
#[test]
fn the_engine_agrees_with_the_editions_own_numbers() {
for s in scenarios().expect("Scenarios.csv") {
// Mirrors `GroundState::threshold`'s bands exactly.
assert_eq!(
s.thresholds,
(5, 7, 9),
"{}: the edition's thresholds moved away from the engine's hardcoded 5/7/9",
s.id
);
assert!(
s.starting_stress.contains("Stress 2"),
"{}: setup writes stress: 2 because of this line, which now reads {:?}",
s.id,
s.starting_stress
);
assert!(
s.round_track.contains("1\u{2013}5") || s.round_track.contains("1-5"),
"{}: the engine plays five rounds because of this line, which now reads {:?}",
s.id,
s.round_track
);
}
}
/// **Two files state the same deal, and the engine reads one.**
///
/// `Problems.csv` carries `visibility` and `hidden_priority`;
/// `Scenarios.csv` carries `surface_problem_id` and
/// `hidden_problem_ids` for the same four scenarios. They agree
/// today, for every scenario — **and nothing was checking**, so an
/// edit to one and not the other would have had the engine dealing a
/// board the edition contradicts, silently.
///
/// Ours to check, not to rule on: if they ever disagree this is a
/// question for `ground-game`, and the test says which file said what.
#[test]
fn the_two_files_that_state_the_deal_agree() {
// Read Problems.csv here rather than through `EditionProblem`,
// which carries no id: adding one would touch a struct that
// feeds setup, and this test has no business changing state.
let pt = Table::parse(CSV, "Problems.csv").expect("Problems.csv");
for s in scenarios().expect("Scenarios.csv") {
let rows: Vec<&Vec<String>> = pt
.rows
.iter()
.filter(|r| pt.get(r, "scenario_id").expect("scenario_id") == s.id)
.collect();
assert!(!rows.is_empty(), "{}: no Problems at all", s.id);
let surface: Vec<&str> = rows
.iter()
.filter(|r| pt.get(r, "visibility").expect("visibility") == "Surface")
.map(|r| pt.get(r, "problem_id").expect("problem_id"))
.collect();
assert_eq!(
surface,
vec![s.surface_problem_id.as_str()],
"{}: Problems.csv and Scenarios.csv disagree on the Surface Problem",
s.id
);
let mut hidden: Vec<&str> = rows
.iter()
.filter(|r| pt.get(r, "visibility").expect("visibility") != "Surface")
.map(|r| pt.get(r, "problem_id").expect("problem_id"))
.collect();
hidden.sort_unstable();
let mut stated: Vec<&str> = s.hidden_problem_ids.iter().map(|x| x.as_str()).collect();
stated.sort_unstable();
assert_eq!(
hidden, stated,
"{}: Problems.csv and Scenarios.csv disagree on the hidden Problems",
s.id
);
}
}
/// **CB-WP-0037 T02 — the engine's rules against the edition's.**
///
/// The engine implements DARVO and relation behaviour from
/// `GroundRules.md`. `DARVO.csv` and `Relations.csv` are where the
/// game's owner states the same rules, and until 2026-08-08 nothing
/// here had ever read them.
///
/// **Every checkable clause agrees**, including the two easiest to
/// drop: Focus is placed *"even if the Attack was cancelled"*, and
/// Focus is removed when the sequence ends before REVERSE.
///
/// **Agreement is recorded, not just noted.** A survey that finds
/// nothing and leaves no trace cannot be told from one never run
/// (CB-EV-0027 §3).
///
/// **These are tripwires, not derivations.** The behaviour was
/// matched by a person reading prose, and that reading can go stale
/// in silence when the prose changes. Pinning the phrase each
/// behaviour was read from turns *"I checked it once"* into *"it is
/// still what I checked"* — and when ground-game rewords a rule this
/// goes red and asks for a human, which is the correct outcome, not
/// an automatic re-derivation.
#[test]
fn the_edition_still_says_what_the_engine_implements() {
let stages = darvo_stages().expect("DARVO.csv");
assert_eq!(
stages.iter().map(|s| s.stage.as_str()).collect::<Vec<_>>(),
["DENY", "ATTACK", "REVERSE"],
"the DARVO sequence changed shape"
);
let stage = |name: &str| {
stages
.iter()
.find(|s| s.stage == name)
.unwrap_or_else(|| panic!("no {name} stage"))
.clone()
};
// GR-D03 / lib.rs DarvoStage::Deny.
let deny = stage("DENY");
assert!(deny
.mandatory_effect
.contains("Turn it face down and place a Denied token"));
assert!(deny
.advance
.contains("Advance to ATTACK unless the sequence is ended"));
// GR-D04 / lib.rs DarvoStage::Attack. Both clauses the engine
// implements and a careless reading would lose.
let attack = stage("ATTACK");
assert!(
attack
.mandatory_effect
.contains("even if the Attack was cancelled"),
"the engine places Focus after a cancelled Attack because the edition says to"
);
assert!(attack
.target_memory
.contains("Focus target becomes the target of REVERSE"));
assert!(
attack
.advance
.contains("remove Focus if the sequence ends before REVERSE"),
"`focus.remove` on DarvoEnded is this clause; if it is gone, so is the reason"
);
// GR-D05 / lib.rs DarvoStage::Reverse: +1 to the target, a
// Protection to the owner, then 2 to the owner.
let reverse = stage("REVERSE");
assert!(reverse
.mandatory_effect
.contains("+1 Stress, and take one Protection token"));
assert!(reverse
.mandatory_effect
.contains("reduce your own Stress by 2"));
assert!(reverse.advance.contains("End the DARVO sequence"));
let rels = relations().expect("Relations.csv");
let side = |name: &str| {
rels.iter()
.find(|r| r.side == name)
.unwrap_or_else(|| panic!("no {name} side"))
.clone()
};
// GR-A04 / GR-A07 against lib.rs's Support and attack paths.
let bond = side("Bond");
assert!(bond.rules_text.contains("\u{2212}2 Stress, ready Freedom"));
assert!(
bond.rules_text
.contains("cancel the target's current DARVO stage"),
"the bond_support branch that pushes DarvoEnded is this clause"
);
assert!(bond
.rules_text
.contains("Attack through a Bond: +2 Stress and flip this tile"));
assert!(bond
.formation
.contains("free relation slot and the target accepts"));
// GR-A05 / GR-A08.
let rivalry = side("Rivalry");
assert!(rivalry
.rules_text
.contains("Support through Rivalry: \u{2212}1 Stress"));
assert!(
rivalry
.rules_text
.contains("the target chooses to flip the tile to Bond or break it"),
"SupportResponse::FlipToBond / BreakRivalry are this clause"
);
assert!(rivalry
.rules_text
.contains("Attack through Rivalry: +2 Stress and break"));
assert!(
rivalry
.formation
.contains("creates a Rivalry automatically"),
"the engine forms it without consent because the edition says automatically"
);
}
/// **The import check ground-game's final ruling asked for**, at
/// every seat band (`RULED GROUND-WP-0004`, 2026-08-03, engine ask 2):
///