CB-WP-0028 T01/T02: the cards say what they do

ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.

THE MEASUREMENT IS THE DECISION, and the gap is bigger than "one file of
nineteen". Of the file we DID vendor, the engine reads 5 of 13 columns:
title, problem_text, front_rules, reveal_effect and unresolved_effect were
discarded at parse time. The cheapest part of this pass costs no new bytes
and was sitting in the repo for eight days. And SCN_01 is hardcoded at
lib.rs:1824 -- the edition ships FOUR scenarios and the engine has never
dealt three of them. Nobody had said so.

ADR-0011's revisit condition is measurably absent, so the dependency
argument does not get re-run: across Actions, Solutions, Modes and
Scenarios there are ZERO doubled quotes and ZERO embedded newlines. The
hand reader's only job is comma-in-quoted-field, which it already did.
Refusing csv on a measurement rather than on a preference.

Vendored Actions, Solutions and Modes -- the text a player reads. Not the
production artifacts (BOM, Print_Manifest, Back_Designs, Symbols). NOT
Extensions.csv, which names content the designer placed outside the core;
importing it would break the claim that this engine plays the edition as
printed. It is now known to exist, which was the real risk.

One Table reader with four callers, because a per-file copy is how a
parser acquires four subtly different bugs. The GROUND card now shows
"Regulate. Restore the frame. Decide." with its GR/OU/ND text on demand;
Problems show their own titles where a priority number used to be.

The load-bearing test asserts the text is a SUBSTRING OF THE VENDORED
FILE rather than equal to a Rust literal -- a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends.

`edition` came out from behind #[cfg(feature = "scenarios")]. It was gated
because its only consumer was; the edition is the game's own data and the
shipped runtime now reads it. Test machinery and game content are
different things and only one of them is optional.

And edition-check was written for a single-file world: it compared the
first recorded digest against Problems.csv regardless of which file that
digest described. It now checks every file both ways -- a vendored file
with no digest fails, a digest naming an absent file fails -- and asserts
ADR-0015 D3's falsifier directly rather than trusting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-06 17:22:04 +02:00
parent efe9f8f1a9
commit 4b5b601ce0
11 changed files with 767 additions and 42 deletions

View file

@ -23,7 +23,159 @@ pub struct EditionProblem {
pub surface: bool,
}
/// A Problem's own words. Separate from [`EditionProblem`], which is
/// `Copy` and lives in the aggregate; this is presentation and does not.
///
/// **These columns were in the vendored file all along** and were
/// discarded at parse time (ADR-0015 D1) — the page showed `Repair 2`
/// where the card reads *"Missed Deadline"*. Reading them cost no new
/// bytes and no budget.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProblemText {
pub priority: u8,
pub title: String,
pub problem_text: String,
pub front_rules: String,
pub reveal_effect: String,
pub unresolved_effect: String,
}
/// The text of one scenario's Problems, by `hidden_priority`.
pub fn problem_texts(scenario_id: &str) -> Result<Vec<ProblemText>, String> {
let t = Table::parse(CSV, "Problems.csv")?;
let mut out = Vec::new();
for row in &t.rows {
if t.get(row, "scenario_id")? != scenario_id {
continue;
}
out.push(ProblemText {
priority: t
.get(row, "hidden_priority")?
.parse()
.map_err(|_| "hidden_priority is not a number".to_string())?,
title: t.get(row, "title")?.to_string(),
problem_text: t.get(row, "problem_text")?.to_string(),
front_rules: t.get(row, "front_rules")?.to_string(),
reveal_effect: t.get(row, "reveal_effect")?.to_string(),
unresolved_effect: t.get(row, "unresolved_effect")?.to_string(),
});
}
if out.is_empty() {
return Err(format!("no Problem text for {scenario_id}"));
}
out.sort_by_key(|p| p.priority);
Ok(out)
}
const CSV: &str = include_str!("../../../editions/ground-darvo-r0/Problems.csv");
const ACTIONS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Actions.csv");
const SOLUTIONS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Solutions.csv");
const MODES_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Modes.csv");
/// A vendored CSV, parsed into rows addressable by column name.
///
/// **One reader, four callers** (ADR-0015 D3). The first version was
/// `Problems.csv`-shaped; a per-file copy is how a parser acquires four
/// subtly different bugs.
pub struct Table {
cols: Vec<String>,
rows: Vec<Vec<String>>,
}
impl Table {
fn parse(csv: &str, what: &str) -> Result<Self, String> {
let mut lines = csv.lines();
let header = lines.next().ok_or_else(|| format!("{what} is empty"))?;
let cols: Vec<String> = fields(header)
.into_iter()
.map(|c| c.trim_start_matches('\u{feff}').trim().to_string())
.collect();
let mut rows = Vec::new();
for line in lines.filter(|l| !l.trim().is_empty()) {
let f = fields(line);
if f.len() != cols.len() {
return Err(format!(
"{what} row has {} fields, header has {}: {line}",
f.len(),
cols.len()
));
}
rows.push(f);
}
Ok(Self { cols, rows })
}
fn at(&self, name: &str) -> Result<usize, String> {
self.cols
.iter()
.position(|c| c == name)
.ok_or_else(|| format!("edition data has no column {name:?}"))
}
/// A named field of one row, trimmed. `Err` names the column, because
/// a silent empty string is how missing data becomes a blank card.
fn get<'a>(&'a self, row: &'a [String], name: &str) -> Result<&'a str, String> {
Ok(row[self.at(name)?].trim())
}
}
/// What a card says about itself — the game's own words, not ours
/// (ADR-0015 D1/D2, finding F18).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CardText {
pub id: String,
pub title: String,
/// The one-line hook. What a player reads first.
pub tagline: String,
/// The full text. Shown on demand — five of these at once is a wall.
pub rules_text: String,
}
fn card_texts(csv: &str, what: &str, id: &str, tag: &str) -> Result<Vec<CardText>, String> {
let t = Table::parse(csv, what)?;
let mut out = Vec::new();
for row in &t.rows {
out.push(CardText {
id: t.get(row, id)?.to_string(),
title: t.get(row, "title")?.to_string(),
tagline: t.get(row, tag)?.to_string(),
rules_text: t.get(row, "rules_text")?.to_string(),
});
}
if out.is_empty() {
return Err(format!("{what} has no rows"));
}
Ok(out)
}
/// The five Action cards, in the edition's own words.
pub fn actions() -> Result<Vec<CardText>, String> {
card_texts(ACTIONS_CSV, "Actions.csv", "action_id", "tagline")
}
/// The Solution cards. `microcopy` is this deck's tagline column.
pub fn solutions() -> Result<Vec<CardText>, String> {
card_texts(SOLUTIONS_CSV, "Solutions.csv", "solution_id", "microcopy")
}
/// The scoring modes, with the tiebreak the game defines — which T07
/// needs and would otherwise have been invented.
pub fn modes() -> Result<Vec<(CardText, String)>, String> {
let t = Table::parse(MODES_CSV, "Modes.csv")?;
let mut out = Vec::new();
for row in &t.rows {
out.push((
CardText {
id: t.get(row, "mode_id")?.to_string(),
title: t.get(row, "title")?.to_string(),
tagline: t.get(row, "tagline")?.to_string(),
rules_text: t.get(row, "rules_text")?.to_string(),
},
t.get(row, "scoring_tiebreak")?.to_string(),
));
}
Ok(out)
}
/// Split one CSV record, honouring `"…"` quoting.
///
@ -148,3 +300,120 @@ pub fn solution_deck() -> Vec<SolutionCard> {
.flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 6))
.collect()
}
#[cfg(test)]
mod card_text_tests {
use super::*;
/// **The load-bearing control (CB-WP-0028 T02).** The words must come
/// from the edition, not from a Rust literal beside it.
///
/// Asserted by reading the vendored file directly and requiring the
/// parsed value to equal what is in it. A test that compared against a
/// hardcoded expectation would pass for a hand-copied string, which is
/// exactly the drift this pass exists to end.
#[test]
fn the_text_comes_from_the_dataset_not_from_us() {
let ground = actions()
.expect("Actions.csv parses")
.into_iter()
.find(|c| c.title == "GROUND")
.expect("the GROUND card is in the edition");
assert!(
ACTIONS_CSV.contains(&ground.tagline),
"the tagline is not a substring of the vendored file — it was invented"
);
assert!(
ACTIONS_CSV.contains(&ground.rules_text),
"the rules text is not a substring of the vendored file"
);
// And it is the card the maintainer could not understand.
assert_eq!(ground.tagline, "Regulate. Restore the frame. Decide.");
assert!(
ground.rules_text.contains("GR—Ground & Restate"),
"the GROUND card must explain its own modes: {}",
ground.rules_text
);
}
/// All five Actions, all 24 Solutions, all three Modes — a reader that
/// returned one row would pass a "the text is real" test.
#[test]
fn every_card_in_the_edition_is_read() {
assert_eq!(actions().expect("actions").len(), 5, "five Action cards");
assert_eq!(
solutions().expect("solutions").len(),
24,
"24 Solution cards (6 per suit, GR-S04)"
);
assert_eq!(modes().expect("modes").len(), 3, "three scoring modes");
}
/// A Solution shows its own name, not just its suit — the defect a
/// player reported as seeing `Clarify` on a card that says otherwise.
#[test]
fn a_solution_has_words_of_its_own() {
let s = solutions().expect("solutions");
let first = &s[0];
assert!(!first.title.is_empty() && first.title != "Clarify");
assert!(
!first.tagline.is_empty(),
"microcopy is what makes the card readable"
);
}
/// The mode's tiebreak is the game's, not ours (T07 depends on this).
#[test]
fn modes_carry_the_games_own_tiebreak() {
let m = modes().expect("modes");
let (coop, tiebreak) = m
.iter()
.find(|(c, _)| c.id == "MODE_COOP")
.expect("MODE_COOP exists");
assert_eq!(coop.title, "SHARED GROUND");
assert!(
!tiebreak.is_empty(),
"a tiebreak we would otherwise have invented"
);
}
/// ADR-0015 D1: the columns that were in the file all along.
#[test]
fn problems_carry_the_text_that_was_already_vendored() {
let texts = problem_texts("SCN_01").expect("SCN_01 text");
assert!(!texts.is_empty());
let surface = &texts[0];
assert!(
!surface.title.is_empty() && !surface.problem_text.is_empty(),
"the Surface Problem must have its own title and text"
);
assert!(
CSV.contains(&surface.title),
"the title is not from the vendored file"
);
// The text list and the dealt list must describe the same Problems.
let dealt = problems_of("SCN_01").expect("SCN_01 problems");
assert_eq!(
texts.len(),
dealt.len(),
"text and mechanics disagree about how many Problems SCN_01 has"
);
}
/// ADR-0015 D5 made this visible rather than leaving it a surprise:
/// the edition ships four scenarios and the engine deals one.
#[test]
fn the_edition_ships_more_scenarios_than_the_engine_deals() {
let mut found = 0;
for id in ["SCN_01", "SCN_02", "SCN_03", "SCN_04"] {
if problems_of(id).is_ok() {
found += 1;
}
}
assert_eq!(
found, 4,
"four scenarios are vendored; `setup` hardcodes SCN_01 (ADR-0015 D5)"
);
}
}

View file

@ -7,7 +7,13 @@
/// aggregate. That its tests do need `scenarios` is a real seam — setup
/// presets currently live behind that feature (see `bot.rs`).
pub mod bot;
#[cfg(feature = "scenarios")]
/// The vendored edition data (ADR-0011, ADR-0015).
///
/// **Not behind `scenarios`.** It was, because its only consumer —
/// `setup` — is; but the edition is the *game's own data*, and since
/// CB-WP-0028 the shipped runtime reads it too, to show a player what a
/// card says. Test machinery and game content are different things and
/// only one of them is optional.
pub mod edition;
/// K13's per-player projection (CB-WP-0008 T02) — the trait's first