//! The edition dataset, vendored and read by hand (ADR-0011). //! //! `Problems.csv` is authoritative (ground-game, GROUND-WP-0002 T01). The //! engine used to invent Problem values and suits; it reads them now. //! //! **Why not the `csv` crate.** It fits — 17,651 marginal lines against //! AM-4b's 19,742 of headroom — and is refused anyway, because that is //! 89% of everything the budget has left to read 20 rows, and the next //! dependency would have 2,091 lines to live in. If this data ever grows //! nested quoting, embedded newlines, or multiple dialects, that decision //! is wrong and `csv` is the answer (ADR-0011 D1). use crate::{SolutionCard, Suit}; /// One Problem as the edition prints it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct EditionProblem { /// `hidden_priority`: 0 is the Surface Problem. pub priority: u8, pub value: u8, pub suit: Suit, /// `visibility == "Surface"` — dealt face up (GR-S01). pub surface: bool, } /// A Problem's own words. Separate from [`EditionProblem`], which is /// `Copy` and lives in the aggregate; this is presentation and does not. /// /// **These columns were in the vendored file all along** and were /// discarded at parse time (ADR-0015 D1) — the page showed `Repair 2` /// where the card reads *"Missed Deadline"*. Reading them cost no new /// bytes and no budget. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProblemText { pub priority: u8, pub title: String, pub problem_text: String, pub front_rules: String, pub reveal_effect: String, pub unresolved_effect: String, } /// The text of one scenario's Problems, by `hidden_priority`. pub fn problem_texts(scenario_id: &str) -> Result, 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"); /// 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>, } 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 }) } fn at(&self, name: &str) -> Result { 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(); let at = |name: &str| -> Result { 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) } /// 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. #[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"); } }