CB-WP-0037 T02: the engine's rules against the edition's
Some checks failed
ci / check (push) Failing after 3s
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:
parent
d98580fb4c
commit
53109ec5e5
7 changed files with 423 additions and 1 deletions
|
|
@ -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):
|
||||
///
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue