Some checks failed
ci / check (push) Failing after 5s
Reading Player_Mats.csv and Glossary.csv — which O4 had forbidden declaring about until they were read — found a rule. Player_Mats' choice_rule column IS GR-R03: at Stress 4-5 choose ATTACK or GROUND unless you spend a ready Freedom token. The engine's stress_gated and allowed_under_stress_gate match it exactly, and now have a tripwire. So Ornamentation.md gains §1.3: the unit is a column, not a file. Classifying Player_Mats as ornamentation would have thrown a rule away with the colour swatches. A mixed artifact is normal — a player mat is a rule printed on a decorated card in the box too. O4 is rewritten and re-grounded from provisional to rule accordingly. O5, O6, O7 declared with falsifiers. O7's is not theoretical: back_design_id is unread in four vendored files, and hidden information is what card backs are for. CB-EV-0029 answers T04's question per rule rather than as a count — yes, every checkable clause — and states the reading that result does NOT support: it shows GroundRules.md was a faithful secondary source for the clauses with behaviour to check, not that the engine is correct. 10 of 19 files vendored. Nine still unread, and Rules_Text.csv is the one that matters: every clause checked here was checked against our own transcription of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1238 lines
48 KiB
Rust
1238 lines
48 KiB
Rust
//! The edition dataset, vendored and read by hand (ADR-0011).
|
||
//!
|
||
//! `Problems.csv` is authoritative (ground-game, GROUND-WP-0002 T01). The
|
||
//! engine used to invent Problem values and suits; it reads them now.
|
||
//!
|
||
//! **Why not the `csv` crate.** It fits — 17,651 marginal lines against
|
||
//! AM-4b's 19,742 of headroom — and is refused anyway, because that is
|
||
//! 89% of everything the budget has left to read 20 rows, and the next
|
||
//! dependency would have 2,091 lines to live in. If this data ever grows
|
||
//! nested quoting, embedded newlines, or multiple dialects, that decision
|
||
//! is wrong and `csv` is the answer (ADR-0011 D1).
|
||
|
||
use crate::{SolutionCard, Suit};
|
||
|
||
/// One Problem as the edition prints it.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct EditionProblem {
|
||
/// `hidden_priority`: 0 is the Surface Problem.
|
||
pub priority: u8,
|
||
pub value: u8,
|
||
pub suit: Suit,
|
||
/// `visibility == "Surface"` — dealt face up (GR-S01).
|
||
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");
|
||
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");
|
||
const PLAYER_MATS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Player_Mats.csv");
|
||
const GLOSSARY_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Glossary.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>>,
|
||
/// Which file this is, for the coverage probe (CB-WP-0037 T01).
|
||
/// Test-only, like the probe it feeds: the shipped path has no use
|
||
/// for it and `-D warnings` is right to say so.
|
||
#[cfg(test)]
|
||
what: String,
|
||
}
|
||
|
||
/// Which columns of which file the engine actually asks for.
|
||
///
|
||
/// **Recorded at the accessor, not counted from the source** (F18's
|
||
/// reproduction). A list of column names written beside the code would be
|
||
/// a second copy of a fact the `get` calls already carry, and this repo
|
||
/// has 62 untagged literals as evidence of where that goes. Grepping the
|
||
/// source instead would over-count, because six column names are shared
|
||
/// between vendored files -- and over-counting coverage understates the
|
||
/// gap, which is the wrong direction for a measurement whose whole job is
|
||
/// to size it.
|
||
///
|
||
/// Test-only: it exists to measure, and a global mutable has no business
|
||
/// in the shipped path.
|
||
#[cfg(test)]
|
||
pub mod probe {
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use std::sync::{Mutex, OnceLock};
|
||
|
||
fn reads() -> &'static Mutex<BTreeMap<String, BTreeSet<String>>> {
|
||
static R: OnceLock<Mutex<BTreeMap<String, BTreeSet<String>>>> = OnceLock::new();
|
||
R.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||
}
|
||
|
||
pub fn record(file: &str, column: &str) {
|
||
reads()
|
||
.lock()
|
||
.expect("probe")
|
||
.entry(file.to_string())
|
||
.or_default()
|
||
.insert(column.to_string());
|
||
}
|
||
|
||
pub fn columns_read(file: &str) -> BTreeSet<String> {
|
||
reads()
|
||
.lock()
|
||
.expect("probe")
|
||
.get(file)
|
||
.cloned()
|
||
.unwrap_or_default()
|
||
}
|
||
}
|
||
|
||
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,
|
||
#[cfg(test)]
|
||
what: what.to_string(),
|
||
})
|
||
}
|
||
|
||
fn at(&self, name: &str) -> Result<usize, String> {
|
||
#[cfg(test)]
|
||
crate::edition::probe::record(&self.what, name);
|
||
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)
|
||
}
|
||
|
||
/// One component, as the edition prints it (ADR-0016 D1).
|
||
///
|
||
/// **A token is a view, not a type** (D2): nothing in the aggregate
|
||
/// changes shape. This supplies the renderer with the game's own labels
|
||
/// and the counts a supply check needs.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct TokenSpec {
|
||
pub id: String,
|
||
pub name: String,
|
||
/// How many the box holds. **Not a rule** — no numbered rule mentions
|
||
/// a supply, and the engine does not enforce one (D3).
|
||
pub quantity: u32,
|
||
/// `Double` means one physical disc that flips — Freedom (READY /
|
||
/// spent) and Focus/Blame. Two states of one component, not two.
|
||
pub double_sided: bool,
|
||
/// What is printed on it: READY, DENIED, PROTECTION, FOCUS…
|
||
pub front_text: String,
|
||
/// The rule, in the edition's words.
|
||
pub use_text: String,
|
||
}
|
||
|
||
/// Every token the edition ships.
|
||
pub fn tokens() -> Result<Vec<TokenSpec>, String> {
|
||
let t = Table::parse(TOKENS_CSV, "Tokens.csv")?;
|
||
let mut out = Vec::new();
|
||
for row in &t.rows {
|
||
out.push(TokenSpec {
|
||
id: t.get(row, "token_id")?.to_string(),
|
||
name: t.get(row, "name")?.to_string(),
|
||
quantity: t
|
||
.get(row, "quantity")?
|
||
.parse()
|
||
.map_err(|_| "quantity is not a number".to_string())?,
|
||
double_sided: t.get(row, "sides")? == "Double",
|
||
front_text: t.get(row, "front_text")?.to_string(),
|
||
use_text: t.get(row, "use")?.to_string(),
|
||
});
|
||
}
|
||
if out.is_empty() {
|
||
return Err("Tokens.csv has no rows".into());
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// How many of `id` the box holds. `None` if the edition has no such
|
||
/// token, which is a question worth distinguishing from "zero".
|
||
pub fn supply(id: &str) -> Option<u32> {
|
||
tokens()
|
||
.ok()?
|
||
.into_iter()
|
||
.find(|t| t.id == id)
|
||
.map(|t| t.quantity)
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// `problem_text` contains commas, which is the only reason this is not a
|
||
/// `split(',')`. Doubled quotes inside a quoted field are not handled and
|
||
/// do not occur; if they ever do, this returns the wrong field count and
|
||
/// `problems_of` fails loudly rather than mis-parsing.
|
||
fn fields(line: &str) -> Vec<String> {
|
||
let mut out = Vec::new();
|
||
let mut cur = String::new();
|
||
let mut quoted = false;
|
||
for c in line.chars() {
|
||
match c {
|
||
'"' => quoted = !quoted,
|
||
',' if !quoted => out.push(std::mem::take(&mut cur)),
|
||
c => cur.push(c),
|
||
}
|
||
}
|
||
out.push(cur);
|
||
out
|
||
}
|
||
|
||
fn suit_of(s: &str) -> Option<Suit> {
|
||
match s.trim() {
|
||
"Clarify" => Some(Suit::Clarify),
|
||
"Repair" => Some(Suit::Repair),
|
||
"Boundary" => Some(Suit::Boundary),
|
||
"Change" => Some(Suit::Change),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Every Problem of one scenario, ordered by `hidden_priority`.
|
||
///
|
||
/// Returns `Err` rather than an empty list when the data does not parse:
|
||
/// a loader that silently reads nothing would hand `setup` a game with no
|
||
/// Problems and look like a rules bug.
|
||
pub fn problems_of(scenario_id: &str) -> Result<Vec<EditionProblem>, String> {
|
||
let mut lines = CSV.lines();
|
||
let header = lines.next().ok_or("edition data is empty")?;
|
||
let cols: Vec<String> = fields(header)
|
||
.into_iter()
|
||
.map(|c| c.trim_start_matches('\u{feff}').trim().to_string())
|
||
.collect();
|
||
// CB-WP-0037 T01: recorded here too. `problems_of` predates `Table`
|
||
// and resolves its own indices, so a probe that only watched
|
||
// `Table::at` reported `visibility`, `required_solution` and
|
||
// `point_value` as unread when the engine reads all three. Correct
|
||
// about the accessor, wrong about the engine -- the family ADR-0018
|
||
// is named for, committed inside the artifact built to measure it.
|
||
let at = |name: &str| -> Result<usize, String> {
|
||
#[cfg(test)]
|
||
probe::record("Problems.csv", name);
|
||
cols.iter()
|
||
.position(|c| c == name)
|
||
.ok_or_else(|| format!("edition data has no column {name:?}"))
|
||
};
|
||
let (c_scn, c_pri, c_val, c_sol, c_vis) = (
|
||
at("scenario_id")?,
|
||
at("hidden_priority")?,
|
||
at("point_value")?,
|
||
at("required_solution")?,
|
||
at("visibility")?,
|
||
);
|
||
|
||
let mut out = Vec::new();
|
||
for line in lines.filter(|l| !l.trim().is_empty()) {
|
||
let f = fields(line);
|
||
if f.len() != cols.len() {
|
||
return Err(format!(
|
||
"edition row has {} fields, header has {}: {line}",
|
||
f.len(),
|
||
cols.len()
|
||
));
|
||
}
|
||
if f[c_scn].trim() != scenario_id {
|
||
continue;
|
||
}
|
||
out.push(EditionProblem {
|
||
priority: f[c_pri]
|
||
.trim()
|
||
.parse()
|
||
.map_err(|_| format!("hidden_priority {:?} is not a number", f[c_pri]))?,
|
||
value: f[c_val]
|
||
.trim()
|
||
.parse()
|
||
.map_err(|_| format!("point_value {:?} is not a number", f[c_val]))?,
|
||
suit: suit_of(&f[c_sol])
|
||
.ok_or_else(|| format!("required_solution {:?} is not a suit", f[c_sol]))?,
|
||
surface: f[c_vis].trim() == "Surface",
|
||
});
|
||
}
|
||
if out.is_empty() {
|
||
return Err(format!("edition data has no Problems for {scenario_id}"));
|
||
}
|
||
out.sort_by_key(|p| p.priority);
|
||
Ok(out)
|
||
}
|
||
|
||
/// GR-S01 as ruled by ground-game 2026-08-04: **Surface always, plus
|
||
/// hidden priorities `1..=k`**, with k by seat band. Surface is never one
|
||
/// of the hidden slots.
|
||
///
|
||
/// Available points are therefore 6 / 9 / 12 with this edition's values —
|
||
/// the numbers ground-game ruled the thresholds 5 / 7 / 9 against.
|
||
pub fn hidden_depth(players: u8) -> Result<u8, String> {
|
||
match players {
|
||
2 => Ok(2),
|
||
3..=4 => Ok(3),
|
||
5..=6 => Ok(4),
|
||
other => Err(format!("GR-S01: unsupported player count {other}")),
|
||
}
|
||
}
|
||
|
||
/// The Problems dealt at `players` seats, Surface first.
|
||
pub fn deal(scenario_id: &str, players: u8) -> Result<Vec<EditionProblem>, String> {
|
||
let k = hidden_depth(players)?;
|
||
let all = problems_of(scenario_id)?;
|
||
let dealt: Vec<EditionProblem> = all
|
||
.into_iter()
|
||
.filter(|p| p.surface || (p.priority >= 1 && p.priority <= k))
|
||
.collect();
|
||
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)
|
||
}
|
||
|
||
/// The stress gate, printed on every player mat (CB-WP-0037 T03).
|
||
///
|
||
/// **A mat is mostly ornamentation with one rule on it.** Symbol, colour
|
||
/// and title decorate; `choice_rule` is GR-R03. All six seats print the
|
||
/// same rule, so this returns it once and checks they agree.
|
||
pub fn choice_rule() -> Result<String, String> {
|
||
let t = Table::parse(PLAYER_MATS_CSV, "Player_Mats.csv")?;
|
||
let mut seen: Option<String> = None;
|
||
for row in &t.rows {
|
||
let rule = t.get(row, "choice_rule")?.to_string();
|
||
match &seen {
|
||
None => seen = Some(rule),
|
||
Some(first) if *first == rule => {}
|
||
Some(first) => {
|
||
return Err(format!(
|
||
"player mats disagree on the stress gate: {first:?} vs {rule:?}"
|
||
))
|
||
}
|
||
}
|
||
}
|
||
seen.ok_or_else(|| "Player_Mats.csv has no rows".to_string())
|
||
}
|
||
|
||
/// The game's own words for its own terms (CB-WP-0037 T03).
|
||
pub fn glossary() -> Result<Vec<(String, String)>, String> {
|
||
let t = Table::parse(GLOSSARY_CSV, "Glossary.csv")?;
|
||
let mut out = Vec::new();
|
||
for row in &t.rows {
|
||
out.push((
|
||
t.get(row, "term")?.to_string(),
|
||
t.get(row, "definition")?.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]
|
||
.into_iter()
|
||
.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.
|
||
/// **F18's reproduction** (CB-WP-0037 T01).
|
||
///
|
||
/// The finding says the engine cannot show what the cards do because
|
||
/// it does not read the data. That was asserted from a hand count and
|
||
/// never measured, so the row sat open with no artifact — the only
|
||
/// one in the register lacking one.
|
||
///
|
||
/// This measures it: **per vendored file, columns present against
|
||
/// columns the engine asks for**, recorded at the accessor so the
|
||
/// number cannot drift from the code.
|
||
///
|
||
/// **Per file, never a ratio.** *"20 of 47"* is the sum shape
|
||
/// GameDesign §1.2 exists to refuse; which columns, in which file, is
|
||
/// what a reader can act on.
|
||
///
|
||
/// **It can go red in both directions.** Read a new column and the
|
||
/// unread list shrinks; vendor a file and a new row appears. A
|
||
/// coverage check that only ever prints the same number is not
|
||
/// measuring coverage (ADR-0006 D3).
|
||
#[test]
|
||
fn the_engine_reads_only_part_of_what_it_vendored() {
|
||
// Touch every public reader, so the probe sees a real pass.
|
||
let _ = problem_texts("SCN_01");
|
||
let _ = problems_of("SCN_01");
|
||
let _ = actions();
|
||
let _ = solutions();
|
||
let _ = modes();
|
||
let _ = tokens();
|
||
let _ = darvo_stages();
|
||
let _ = relations();
|
||
let _ = scenarios();
|
||
let _ = choice_rule();
|
||
let _ = glossary();
|
||
|
||
let files = [
|
||
("Problems.csv", CSV),
|
||
("Actions.csv", ACTIONS_CSV),
|
||
("Solutions.csv", SOLUTIONS_CSV),
|
||
("Modes.csv", MODES_CSV),
|
||
("Tokens.csv", TOKENS_CSV),
|
||
("DARVO.csv", DARVO_CSV),
|
||
("Relations.csv", RELATIONS_CSV),
|
||
("Scenarios.csv", SCENARIOS_CSV),
|
||
("Player_Mats.csv", PLAYER_MATS_CSV),
|
||
("Glossary.csv", GLOSSARY_CSV),
|
||
];
|
||
let mut report = String::new();
|
||
let mut total_unread = 0usize;
|
||
for (name, csv) in files {
|
||
let header: Vec<String> = fields(csv.lines().next().expect("header"))
|
||
.into_iter()
|
||
.map(|c| c.trim_start_matches('\u{feff}').trim().to_string())
|
||
.collect();
|
||
let read = probe::columns_read(name);
|
||
let unread: Vec<&String> = header.iter().filter(|c| !read.contains(*c)).collect();
|
||
total_unread += unread.len();
|
||
report.push_str(&format!(
|
||
"{name}: {} of {} columns read; unread: {unread:?}\n",
|
||
header.len() - unread.len(),
|
||
header.len(),
|
||
));
|
||
assert!(
|
||
!read.is_empty(),
|
||
"{name} is vendored and no column of it is read at all:\n{report}"
|
||
);
|
||
}
|
||
// The probe must actually have seen something, or every count
|
||
// above is zero for a reason that has nothing to do with the gap.
|
||
assert!(
|
||
total_unread > 0,
|
||
"every vendored column is read — F18's premise no longer holds, \
|
||
so the finding needs closing rather than this test passing:\n{report}"
|
||
);
|
||
println!("{report}");
|
||
}
|
||
|
||
/// **The stress gate is printed on the mats** (CB-WP-0037 T03).
|
||
///
|
||
/// `Player_Mats.csv` looked like pure ornamentation — a symbol, a
|
||
/// colour, a title per seat — and one of its columns is GR-R03:
|
||
/// *"At Stress 0-3 choose any action. At Stress 4-5 choose ATTACK or
|
||
/// GROUND unless you spend a ready Freedom token."*
|
||
///
|
||
/// **This is why the classification unit is a column, not a file.**
|
||
/// Declaring the mats ornamental would have discarded a rule the
|
||
/// engine implements, alongside the colour swatches it sits next to.
|
||
///
|
||
/// A tripwire, like T02's: the engine's `stress_gated` and
|
||
/// `allowed_under_stress_gate` were written from `GroundRules.md`,
|
||
/// and this pins the edition sentence they answer to.
|
||
#[test]
|
||
fn the_mats_still_print_the_stress_gate_the_engine_enforces() {
|
||
let rule = choice_rule().expect("Player_Mats.csv");
|
||
// `stress_gated`: stress >= 4, unless Freedom lifted the gate.
|
||
assert!(
|
||
rule.contains("At Stress 4\u{2013}5") || rule.contains("At Stress 4-5"),
|
||
"the gate's threshold moved; `stress_gated` uses >= 4: {rule:?}"
|
||
);
|
||
// `allowed_under_stress_gate`: ATTACK and GROUND, and only those.
|
||
assert!(
|
||
rule.contains("ATTACK or GROUND"),
|
||
"the gate's admitted actions changed: {rule:?}"
|
||
);
|
||
assert!(
|
||
rule.contains("ready Freedom token"),
|
||
"the escape hatch changed; the engine spends Freedom: {rule:?}"
|
||
);
|
||
|
||
// GR-L01's two slots are restated in the glossary, and
|
||
// `has_free_slot` is written against that number.
|
||
let g = glossary().expect("Glossary.csv");
|
||
let slot = g
|
||
.iter()
|
||
.find(|(term, _)| term == "Relation slot")
|
||
.map(|(_, d)| d.clone())
|
||
.expect("Relation slot is defined");
|
||
assert!(
|
||
slot.contains("two"),
|
||
"the glossary no longer says two relation slots: {slot:?}"
|
||
);
|
||
}
|
||
|
||
/// **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):
|
||
///
|
||
/// > rewrite `gr-e01-threshold-unreachable-2p` / invert `gd0001` as
|
||
/// > an **import check**: sum(point_value dealt) >= threshold for
|
||
/// > every seat band.
|
||
///
|
||
/// **Only the 2p half was done.** `gr-e01-threshold-reachable-2p`
|
||
/// covers the tightest band, and the ruling said *every* band. The
|
||
/// bands are where the deal depth changes (`k` = 2 / 3 / 4), so a
|
||
/// check at one of them tests one value of the variable that moves —
|
||
/// which is the shape GameDesign §1.4 exists to refuse.
|
||
///
|
||
/// **Reported as a row-level table, never a sum** (§1.2, and
|
||
/// ground-game's engine ask 3): Surface separated from each hidden
|
||
/// priority, because *"12 in the file"* is the wrong premise this
|
||
/// clause was written for.
|
||
#[test]
|
||
fn every_seat_band_can_reach_its_threshold() {
|
||
// GR-E01 dataset 0.1, mirroring `GroundState::threshold`.
|
||
let threshold = |players: u8| -> u32 {
|
||
match players {
|
||
0..=2 => 5,
|
||
3..=4 => 7,
|
||
_ => 9,
|
||
}
|
||
};
|
||
let mut table = String::from("\nseats | k | Surface | hidden | available | threshold\n");
|
||
for players in [2u8, 3, 4, 5, 6] {
|
||
let k = hidden_depth(players).expect("seat band");
|
||
let dealt = deal("SCN_01", players).expect("deal");
|
||
let surface: u32 = dealt
|
||
.iter()
|
||
.filter(|p| p.surface)
|
||
.map(|p| u32::from(p.value))
|
||
.sum();
|
||
let hidden: Vec<String> = dealt
|
||
.iter()
|
||
.filter(|p| !p.surface)
|
||
.map(|p| format!("{}:{}", p.priority, p.value))
|
||
.collect();
|
||
let hidden_total: u32 = dealt
|
||
.iter()
|
||
.filter(|p| !p.surface)
|
||
.map(|p| u32::from(p.value))
|
||
.sum();
|
||
let available = surface + hidden_total;
|
||
table.push_str(&format!(
|
||
"{players:>5} | {k} | {surface:>7} | {} | {available:>9} | {}\n",
|
||
hidden.join(" "),
|
||
threshold(players),
|
||
));
|
||
assert!(
|
||
available >= threshold(players),
|
||
"{players}p: available {available} < threshold {}; \
|
||
GR-E01 is unreachable at this band{table}",
|
||
threshold(players),
|
||
);
|
||
// Surface is never one of the hidden slots — the exact
|
||
// undercount that produced the withdrawn 4/6/9.
|
||
assert_eq!(
|
||
hidden.len(),
|
||
usize::from(k),
|
||
"{players}p: expected {k} hidden Problems beside Surface{table}"
|
||
);
|
||
}
|
||
println!("{table}");
|
||
}
|
||
|
||
/// **F24** (CB-WP-0037 T01). The draw pile is a Rust literal.
|
||
///
|
||
/// `solution_deck()` builds 6 of each suit from an array and never
|
||
/// opens `Solutions.csv`, whose `suit` and `quantity` columns say the
|
||
/// same thing. **It currently agrees** — 24 rows, 6 per suit — so
|
||
/// nothing is wrong today, and that is exactly the problem: the
|
||
/// engine is right by maintenance coincidence, not by reading, and
|
||
/// the day `ground-game` changes a quantity nothing here notices.
|
||
///
|
||
/// This test is the guard until the literal is deleted (T02): it goes
|
||
/// red if either side moves.
|
||
#[test]
|
||
fn the_hardcoded_deck_still_matches_the_edition() {
|
||
use std::collections::BTreeMap;
|
||
let t = Table::parse(SOLUTIONS_CSV, "Solutions.csv").expect("solutions");
|
||
let mut from_file: BTreeMap<String, u32> = BTreeMap::new();
|
||
for row in &t.rows {
|
||
let suit = t.get(row, "suit").expect("suit").to_string();
|
||
let n: u32 = t
|
||
.get(row, "quantity")
|
||
.expect("quantity")
|
||
.parse()
|
||
.expect("quantity is a number");
|
||
*from_file.entry(suit).or_default() += n;
|
||
}
|
||
let mut from_code: BTreeMap<String, u32> = BTreeMap::new();
|
||
for c in solution_deck() {
|
||
*from_code.entry(format!("{:?}", c.suit)).or_default() += 1;
|
||
}
|
||
assert_eq!(
|
||
from_file, from_code,
|
||
"the hardcoded solution deck and the edition disagree — the \
|
||
literal is the copy, so the edition is right and F24 has \
|
||
become a live defect rather than a latent 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)"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(all(test, feature = "scenarios"))]
|
||
mod supply_tests {
|
||
use super::*;
|
||
|
||
/// **CB-WP-0029 T03: does play ever exceed what the box holds?**
|
||
///
|
||
/// Measured over 750 games (2–6 seats, greedy and random): it does
|
||
/// not. This runs a smaller sweep as a standing control, so a future
|
||
/// change that starts minting components fails here instead of being
|
||
/// noticed by a player.
|
||
///
|
||
/// **A violation is a FINDING, not a bug to fix by adding a bound**
|
||
/// (ADR-0016 D3). No numbered rule mentions a supply; the engine
|
||
/// enforcing one would be inventing a rule, which is CB-WP-0023's
|
||
/// error inverted. If this goes red, the question goes to
|
||
/// `ground-game`: does the box bound the game, or do the rules?
|
||
#[test]
|
||
fn play_never_exceeds_the_components_the_box_holds() {
|
||
use crate::bot::{play, GreedyPolicy, Policy, RandomPolicy};
|
||
use cb_game_runtime::{ScenarioGame, Setup};
|
||
|
||
let protection = supply("TOK_PROTECTION").expect("the edition ships Protection");
|
||
let denied = supply("TOK_DENIED").expect("the edition ships Denied");
|
||
let link = supply("TOK_LINK").expect("the edition ships link tokens");
|
||
|
||
for players in [2u8, 4, 6] {
|
||
for seed in 0..25u64 {
|
||
let Ok(state) = crate::GroundState::setup(
|
||
&Setup {
|
||
players,
|
||
preset: format!("standard-{players}p"),
|
||
patch: Default::default(),
|
||
},
|
||
seed,
|
||
) else {
|
||
continue;
|
||
};
|
||
let mut ps: Vec<Box<dyn Policy>> = (0..players)
|
||
.map(|i| {
|
||
if seed % 2 == 0 {
|
||
Box::new(GreedyPolicy) as Box<dyn Policy>
|
||
} else {
|
||
Box::new(RandomPolicy::new(seed ^ u64::from(i))) as Box<dyn Policy>
|
||
}
|
||
})
|
||
.collect();
|
||
let Ok(g) = play(state, &mut ps) else {
|
||
continue;
|
||
};
|
||
let s = &g.state;
|
||
|
||
let on_table: u32 = s.players.values().map(|p| u32::from(p.protection)).sum();
|
||
assert!(
|
||
on_table <= protection,
|
||
"{players}p seed {seed}: {on_table} Protection tokens in play, \
|
||
the box holds {protection}"
|
||
);
|
||
let d = s.problems.values().filter(|q| q.denied).count() as u32;
|
||
assert!(
|
||
d <= denied,
|
||
"{d} Denied tokens in play, the box holds {denied}"
|
||
);
|
||
// Two link tokens per relation, one at each endpoint.
|
||
let l = (s.relations.len() * 2) as u32;
|
||
assert!(l <= link, "{l} link tokens in play, the box holds {link}");
|
||
|
||
// One double-sided disc per player: it is Focus-side-up
|
||
// somewhere, or Blame-side-up somewhere, never both.
|
||
//
|
||
// The first version of this check compared a seat's own
|
||
// Focus against its OWN blame_from -- but blame_from lists
|
||
// OTHER players' discs, so those are different tokens. It
|
||
// reported conflicts that did not exist.
|
||
for owner in s.players.keys() {
|
||
let as_focus = s.focus.contains_key(owner);
|
||
let as_blame = s.players.values().any(|q| q.blame_from.contains(owner));
|
||
assert!(
|
||
!(as_focus && as_blame),
|
||
"{owner:?}'s single Focus/Blame disc is placed twice"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The supply numbers are the edition's, not ours.
|
||
#[test]
|
||
fn the_supply_comes_from_the_edition() {
|
||
let t = tokens().expect("Tokens.csv parses");
|
||
assert_eq!(t.len(), 9, "the edition ships nine token types");
|
||
assert_eq!(supply("TOK_LINK"), Some(12), "two per player at six seats");
|
||
assert!(
|
||
TOKENS_CSV.contains(&tokens().expect("t")[0].use_text),
|
||
"the rule text is not a substring of the vendored file — it was invented"
|
||
);
|
||
// `sides` must be able to say both, or the flag means nothing.
|
||
assert!(
|
||
t.iter().any(|x| x.double_sided),
|
||
"Freedom and Focus/Blame flip"
|
||
);
|
||
assert!(t.iter().any(|x| !x.double_sided), "most tokens do not");
|
||
}
|
||
}
|