//! 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, 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, rows: Vec>, /// 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>> { static R: OnceLock>>> = 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 { reads() .lock() .expect("probe") .get(file) .cloned() .unwrap_or_default() } } impl Table { fn parse(csv: &str, what: &str) -> Result { let mut lines = csv.lines(); let header = lines.next().ok_or_else(|| format!("{what} is empty"))?; let cols: Vec = 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 { #[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, 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, 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 { 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, String> { card_texts(ACTIONS_CSV, "Actions.csv", "action_id", "tagline") } /// The Solution cards. `microcopy` is this deck's tagline column. pub fn solutions() -> Result, 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, 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 { 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 { 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, String> { let mut lines = CSV.lines(); let header = lines.next().ok_or("edition data is empty")?; let cols: Vec = 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 { #[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 { 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, String> { let k = hidden_depth(players)?; let all = problems_of(scenario_id)?; let dealt: Vec = 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, 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, /// 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, 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 { 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, 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, 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 { 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 { let t = Table::parse(PLAYER_MATS_CSV, "Player_Mats.csv")?; let mut seen: Option = 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, 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, 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 { [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 = 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| { ( 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> = 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::>(), ["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 = 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 = 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 = 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> = (0..players) .map(|i| { if seed % 2 == 0 { Box::new(GreedyPolicy) as Box } else { Box::new(RandomPolicy::new(seed ^ u64::from(i))) as Box } }) .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"); } }