Some checks failed
ci / check (push) Failing after 3s
The modes were already implemented; nothing had ever COMPARED them. The scenarios were not implemented at all: edition::deal has taken a scenario_id since it was written and the only caller passed the literal "SCN_01", so 15 of 20 Problem cards had never been dealt by anything. The seam was the whole mechanism and it sat unused, with nothing red because nothing asked. Scenario is now state (serde default SCN_01, so all 26 recordings replay unchanged), selected by preset `scn-03-4p` with `standard-Np` still meaning SCN_01, and by --scenario/SCENARIO= accepting ids, numbers or titles, validated against the edition rather than a pattern. The threshold now comes off the Scenario card, closing F25's hardcoded 5/7/9. The first version of that control was worthless and mutation said so: all four scenarios print 5/7/9, so reverting to the bands left it green. Split threshold_from() so it can be handed a card that disagrees. The header read `scoring CommonProblem` where the Mode card is titled COMMON PROBLEM, PERSONAL EDGE -- the defect CB-WP-0034 deleted from the move buttons, still standing on the line that says what winning means. The coverage probe was matching that Debug output and went red when it was fixed: third instance (CB-WP-0024, CB-WP-0034). Page now carries the premise, the mode's rules text, and the tiebreak. scenario-panel plays 4x3x3. Findings: SCN_01 and SCN_02 are the same board (identical cells, pinned by a characterisation test); SCN_04 is the hard board at 2p (52% vs 67/73%, the only deck needing two Repair); and group success is EXACTLY equal across all three modes in all 36 cells, because greedy never reads state.mode -- filed F27, the two competitive modes are scoring lenses over cooperative play. F28: SHARED GROUND's mastery subtracts penalties from the claimed COUNT where the mode card's shared score is claimed VALUE. Raised, not fixed; scoring is ground-game's to rule on. Also fixes design.py reporting a backticked path as no reproduction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1524 lines
59 KiB
Rust
1524 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
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **Two of the four scenarios are the same board** (CB-WP-0047).
|
||
///
|
||
/// SCN_01 and SCN_02 have identical suit-and-value profiles at every
|
||
/// priority, so they play identically and differ only in prose. Not
|
||
/// a defect — a designed reskin is a legitimate choice — but it is a
|
||
/// fact about what "four scenarios" buys, and measuring them as four
|
||
/// independent boards would be measuring two of them twice.
|
||
///
|
||
/// This is a **characterisation** test: it pins what is true today so
|
||
/// that a change to either deck is a decision rather than a drift.
|
||
#[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
|
||
};
|
||
assert_eq!(
|
||
profile("SCN_01"),
|
||
profile("SCN_02"),
|
||
"SCN_01 and SCN_02 diverged — they were identical boards; \
|
||
if this is intended, the panel now measures four real boards"
|
||
);
|
||
for pair in [
|
||
("SCN_01", "SCN_03"),
|
||
("SCN_01", "SCN_04"),
|
||
("SCN_03", "SCN_04"),
|
||
] {
|
||
assert_ne!(
|
||
profile(pair.0),
|
||
profile(pair.1),
|
||
"{} and {} became the same board",
|
||
pair.0,
|
||
pair.1
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|