Some checks failed
ci / check (push) Failing after 4s
vendor tool that covers what the gate checks
They ruled on all seven items the same day. Two were actionable here.
F28 RULED: points. Modes.csv MODE_COOP clarified upstream to say
"penalties apply to points, not card count"; mastery is now
total - blame - denied. A recorded scenario went red on it --
gr-e02-shared-ground pinned 0 (2 claimed CARDS - 1 - 1) and now expects
2 (4 POINTS - 1 - 1). The number moved because the rule was decided, not
because the engine drifted, and the scenario records both rulings; its
schema has no field for a second one, so both live in ruled_note with
`ruled` carrying the LATEST date.
F29 RULED not-intended and APPLIED upstream: SCN_02's suits re-tuned the
same day. The characterisation test is how we found out -- it pinned the
duplication, went red on the re-tune, and that red WAS the notification.
It now asserts every pair distinct, the stronger statement the
duplication had made unavailable. SCN_02 re-measures at 73 at 2p, not
67: its own board now.
F26/F30 ruled and recorded. F30's ruling incidentally confirms our
reading -- they name priority-2's suit as the first lever, which is the
difference we identified without having measured causation.
vendor-editions grew twice, both times because it covered less than the
gate it exists to satisfy:
- It refused to touch ground-darvo-r0/ on the reasoning that the
baseline is "a separate record". That was wrong within the hour:
ground-game clarified Modes.csv and `make vendor` reported a clean
sync while edition-check went red. A sync tool that covers less than
its check reports success into a red gate.
- Its two-block rewrite DETECTED which fence held which set and
preserved the arrangement -- faithfully preserving a swap an earlier
write had introduced, leaving each fence under a heading describing
the other. edition-check reads every sha256 line flat and passed
throughout: a document can be self-consistently wrong and green.
Order is now asserted, with a control that goes red on a swap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1521 lines
59 KiB
Rust
1521 lines
59 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};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::collections::BTreeMap;
|
||
|
||
/// 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");
|
||
/// H2's Problems table (CB-WP-0042). r0's with one column added.
|
||
const H2_PROBLEMS_CSV: &str =
|
||
include_str!("../../../editions/experiments/h2-scoped-problem-stress/Problems.csv");
|
||
/// H2's player-facing rulebook. **It was here all along** (CB-WP-0046).
|
||
///
|
||
/// CB-WP-0045 recorded that nothing explains what a *scope does* and
|
||
/// judged it a `ground-game` question, on the reasoning that scoping is
|
||
/// clay-borg's Variant and so has no printed sentence. That was wrong:
|
||
/// the package ships `Rules_Text.csv`, and row 20 is the rule in the
|
||
/// edition's own words. **We had never read the file** — the third time
|
||
/// this pass that vendored text could not reach a player (F18).
|
||
const H2_RULES_TEXT_CSV: &str =
|
||
include_str!("../../../editions/experiments/h2-scoped-problem-stress/Rules_Text.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,
|
||
/// 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
|
||
/// 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(),
|
||
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")?
|
||
.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)
|
||
}
|
||
|
||
/// Who an unclaimed Problem's End-of-Round Stress falls on (H2-SCOPE).
|
||
///
|
||
/// **Read from the edition, never derived from the priority.** The delta
|
||
/// states a priority→scope mapping and `Problems.csv` carries the
|
||
/// column; F25 exists because we hardcoded numbers the edition already
|
||
/// held, and this is the same shape.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum StressScope {
|
||
/// All seats.
|
||
Global,
|
||
/// The Problem's owner alone.
|
||
Personal,
|
||
/// The owner's Bond network — Bond edges only, never Rivalry.
|
||
Bond,
|
||
}
|
||
|
||
impl std::str::FromStr for StressScope {
|
||
type Err = String;
|
||
fn from_str(s: &str) -> Result<Self, String> {
|
||
match s.trim() {
|
||
"global" => Ok(StressScope::Global),
|
||
"personal" => Ok(StressScope::Personal),
|
||
"bond" => Ok(StressScope::Bond),
|
||
other => Err(format!(
|
||
"unknown stress_scope {other:?} (global, personal, bond)"
|
||
)),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// H2's scope per hidden priority, for one scenario.
|
||
///
|
||
/// Keyed by `hidden_priority` because that is what survives the deal —
|
||
/// `EditionProblem` carries the priority and not the `problem_id`.
|
||
pub fn stress_scopes(scenario_id: &str) -> Result<BTreeMap<u8, StressScope>, String> {
|
||
let t = Table::parse(H2_PROBLEMS_CSV, "h2/Problems.csv")?;
|
||
let mut out = BTreeMap::new();
|
||
for row in &t.rows {
|
||
if t.get(row, "scenario_id")? != scenario_id {
|
||
continue;
|
||
}
|
||
let priority: u8 = t
|
||
.get(row, "hidden_priority")?
|
||
.parse()
|
||
.map_err(|_| "hidden_priority is not a number".to_string())?;
|
||
out.insert(priority, t.get(row, "stress_scope")?.parse()?);
|
||
}
|
||
if out.is_empty() {
|
||
return Err(format!("h2/Problems.csv has no rows for {scenario_id}"));
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// One passage of the variant's rulebook (CB-WP-0046).
|
||
pub struct RulesPassage {
|
||
pub order: u32,
|
||
pub section: String,
|
||
pub heading: String,
|
||
pub body: String,
|
||
}
|
||
|
||
/// H2's rulebook, in `Rules_Text.csv` order.
|
||
///
|
||
/// Returned whole rather than as a lookup by heading: a caller that wants
|
||
/// the scope rule asks for its section, and the ordering is the
|
||
/// edition's, not one we impose.
|
||
pub fn h2_rules_text() -> Result<Vec<RulesPassage>, String> {
|
||
let t = Table::parse(H2_RULES_TEXT_CSV, "h2/Rules_Text.csv")?;
|
||
let mut out = Vec::new();
|
||
for row in &t.rows {
|
||
out.push(RulesPassage {
|
||
order: t
|
||
.get(row, "order")?
|
||
.trim()
|
||
.parse()
|
||
.map_err(|_| "order is not a number".to_string())?,
|
||
section: t.get(row, "section")?.to_string(),
|
||
heading: t.get(row, "heading")?.to_string(),
|
||
body: t.get(row, "body")?.to_string(),
|
||
});
|
||
}
|
||
if out.is_empty() {
|
||
return Err("h2/Rules_Text.csv has no rows".into());
|
||
}
|
||
out.sort_by_key(|p| p.order);
|
||
Ok(out)
|
||
}
|
||
|
||
/// What a `stress_scope` DOES, in the edition's words (CB-WP-0046).
|
||
///
|
||
/// The passage headed *Stress scope* in the `Problems` section. Matched
|
||
/// on the heading rather than pinned to row 20, because a row number is
|
||
/// a property of today's file and the heading is a property of the rule.
|
||
/// **`None` if the edition stops carrying it** — an absent explanation is
|
||
/// reported as absent, never replaced by ours (ADR-0018).
|
||
pub fn stress_scope_rule() -> Option<String> {
|
||
h2_rules_text()
|
||
.ok()?
|
||
.into_iter()
|
||
.find(|p| {
|
||
p.section.eq_ignore_ascii_case("Problems")
|
||
&& p.heading.to_lowercase().contains("stress scope")
|
||
})
|
||
.map(|p| p.body)
|
||
}
|
||
|
||
/// 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}");
|
||
}
|
||
|
||
/// **H2 changes only the column it says it changes** (CB-WP-0042 T01).
|
||
///
|
||
/// `rules_delta.yaml`'s `unchanged:` list claims
|
||
/// `deal_and_thresholds`, and H2 ships its own `Problems.csv`. If a
|
||
/// value, suit, visibility or priority moved in it, **every baseline
|
||
/// comparison in every H2 measurement would be against a different
|
||
/// board** — and the variant would be testing two things at once.
|
||
#[test]
|
||
fn h2_adds_a_column_and_alters_nothing_else() {
|
||
let base = Table::parse(CSV, "Problems.csv").expect("r0");
|
||
let h2 = Table::parse(H2_PROBLEMS_CSV, "h2/Problems.csv").expect("h2");
|
||
|
||
let key = |t: &Table, r: &Vec<String>| {
|
||
(
|
||
t.get(r, "problem_id").expect("id").to_string(),
|
||
t.get(r, "scenario_id").expect("scn").to_string(),
|
||
t.get(r, "visibility").expect("vis").to_string(),
|
||
t.get(r, "hidden_priority").expect("pri").to_string(),
|
||
t.get(r, "point_value").expect("val").to_string(),
|
||
t.get(r, "required_solution").expect("sol").to_string(),
|
||
)
|
||
};
|
||
let a: Vec<_> = base.rows.iter().map(|r| key(&base, r)).collect();
|
||
let b: Vec<_> = h2.rows.iter().map(|r| key(&h2, r)).collect();
|
||
assert_eq!(
|
||
a, b,
|
||
"H2's Problems.csv differs from r0 beyond `stress_scope` — the deal \
|
||
moved, so H2 would be testing a rules change and a board change at once"
|
||
);
|
||
|
||
// And it really does add the column, or there is nothing to read.
|
||
assert!(
|
||
h2.cols.iter().any(|c| c == "stress_scope"),
|
||
"H2's Problems.csv has no stress_scope column"
|
||
);
|
||
assert!(
|
||
!base.cols.iter().any(|c| c == "stress_scope"),
|
||
"r0 already carries stress_scope — H2 is not the variant that adds it"
|
||
);
|
||
}
|
||
|
||
/// **The scopes come from the edition, not from the priority**
|
||
/// (CB-WP-0042 T01).
|
||
///
|
||
/// The delta states the mapping — 0 global, 1 personal, 2 personal,
|
||
/// 3 bond, 4 personal — and the file carries it. **Deriving it from
|
||
/// the priority in Rust would be F25 again**: a number hardcoded that
|
||
/// the edition already holds.
|
||
#[test]
|
||
fn the_stress_scopes_are_read_from_the_edition() {
|
||
let scopes = stress_scopes("SCN_01").expect("SCN_01 scopes");
|
||
assert_eq!(
|
||
scopes.get(&0),
|
||
Some(&StressScope::Global),
|
||
"Surface is global"
|
||
);
|
||
assert_eq!(scopes.get(&1), Some(&StressScope::Personal));
|
||
assert_eq!(scopes.get(&2), Some(&StressScope::Personal));
|
||
assert_eq!(
|
||
scopes.get(&3),
|
||
Some(&StressScope::Bond),
|
||
"priority 3 is the bond card — the seat-band dial, absent at 2p"
|
||
);
|
||
assert_eq!(scopes.get(&4), Some(&StressScope::Personal));
|
||
|
||
// Every scenario the edition ships, not just the one we deal.
|
||
for id in ["SCN_01", "SCN_02", "SCN_03", "SCN_04"] {
|
||
assert!(
|
||
stress_scopes(id).is_ok(),
|
||
"{id} has no scopes, so a later pass that deals it would have none"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **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.
|
||
/// **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
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **The four scenarios are four boards** (F29).
|
||
///
|
||
/// They were not. SCN_01 and SCN_02 had identical suit-and-value
|
||
/// profiles at every priority — they played identically and differed
|
||
/// only in prose, so "four scenarios" bought three boards.
|
||
///
|
||
/// Reported as F29; ground-game ruled it **unintended** and re-tuned
|
||
/// SCN_02's suits the same day. **This test is how we found out**: it
|
||
/// was a characterisation pinning the duplication, it went red on the
|
||
/// re-tune, and that red was the notification, not a defect.
|
||
///
|
||
/// It now asserts the property that should hold from here — every
|
||
/// pair distinct — which is the stronger statement the duplication
|
||
/// had made unavailable.
|
||
#[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
|
||
};
|
||
let ids = ["SCN_01", "SCN_02", "SCN_03", "SCN_04"];
|
||
for (i, a) in ids.iter().enumerate() {
|
||
for b in &ids[i + 1..] {
|
||
assert_ne!(
|
||
profile(a),
|
||
profile(b),
|
||
"{a} and {b} are the same board — four scenarios must be \
|
||
four boards, or a panel treating them as independent \
|
||
samples counts one of them twice"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|