ADR-0016, Tokens.csv vendored, tracks and discs on the table, and a supply audit that found nothing and says so. T03 MEASURED BEFORE T01 DECIDED. 750 games, 2-6 seats, greedy and random: Protection reaches 1 per seat and 2 on the table against a supply of 6; Denied 3 of 5; relation links EXACTLY 12 OF 12 and never more; Focus/Blame 0 conflicts. The link row is the interesting one -- GR-L01's two-slot rule IS the twelve-token supply written twice, which is the shape of a supply needing no separate enforcement. AND THE FIRST VERSION OF THE FOCUS/BLAME CHECK WAS WRONG. It compared a seat's own placed Focus against its OWN blame_from -- but that list holds OTHER players' discs, so they are different tokens. It reported 2 conflicts; corrected, it reports 0. Fifth instance of this project's recurring defect, a number computed correctly about the wrong subject, and the first caught before it left the repo rather than by a reviewer. D2: a token is a VIEW, not a type. The aggregate gains no `Token` -- adding one would create a second source of truth for Stress, and the first time they disagreed the bug would be invisible because both would look internally consistent. D3: quantity does NOT bind, and the reason is not the measurement. A component limit the rules do not state is not a rule. Refusing a seventh Protection token would enforce something nobody ruled -- CB-WP-0023's error inverted: SOLVE was OFFERED where it could not act; this would REFUSE where the rules allow. The check ships as a standing control, so a future violation becomes a question for ground-game (does the box bound the game, or do the rules?) rather than a bound the engine invented. Registered as F22, withdrawn: a stated negative, because a survey that finds nothing and leaves no trace cannot be told from one never run. D4: Stress on a 0-5 track that turns red at 5 where DARVO arms, DARVO on OFF/DENY/ATTACK/REVERSE, Freedom as the two-sided disc the edition says it is, Protection and Blame counted, Lead and Round on the table. Two tests broke on token discs and both were FIXTURE defects: seat_centres matched every <circle> and track stops are circles. Seats now carry class="seat". The table height limit went 460 -> 500 as a CORRECTION, not a concession. 460 had no derivation; 500 does -- ~800px viewport less ~120 header and ~150 controls leaves ~530, and the version that broke dragging was 620. CB-WP-0021 T06's rule is to fix the measurement rather than lower the floor, and an underived number is a measurement defect. make all: exit 0. 66 render tests, 26 cb-play. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
578 lines
21 KiB
Rust
578 lines
21 KiB
Rust
//! The edition dataset, vendored and read by hand (ADR-0011).
|
||
//!
|
||
//! `Problems.csv` is authoritative (ground-game, GROUND-WP-0002 T01). The
|
||
//! engine used to invent Problem values and suits; it reads them now.
|
||
//!
|
||
//! **Why not the `csv` crate.** It fits — 17,651 marginal lines against
|
||
//! AM-4b's 19,742 of headroom — and is refused anyway, because that is
|
||
//! 89% of everything the budget has left to read 20 rows, and the next
|
||
//! dependency would have 2,091 lines to live in. If this data ever grows
|
||
//! nested quoting, embedded newlines, or multiple dialects, that decision
|
||
//! is wrong and `csv` is the answer (ADR-0011 D1).
|
||
|
||
use crate::{SolutionCard, Suit};
|
||
|
||
/// One Problem as the edition prints it.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct EditionProblem {
|
||
/// `hidden_priority`: 0 is the Surface Problem.
|
||
pub priority: u8,
|
||
pub value: u8,
|
||
pub suit: Suit,
|
||
/// `visibility == "Surface"` — dealt face up (GR-S01).
|
||
pub surface: bool,
|
||
}
|
||
|
||
/// A Problem's own words. Separate from [`EditionProblem`], which is
|
||
/// `Copy` and lives in the aggregate; this is presentation and does not.
|
||
///
|
||
/// **These columns were in the vendored file all along** and were
|
||
/// discarded at parse time (ADR-0015 D1) — the page showed `Repair 2`
|
||
/// where the card reads *"Missed Deadline"*. Reading them cost no new
|
||
/// bytes and no budget.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct ProblemText {
|
||
pub priority: u8,
|
||
pub title: String,
|
||
pub problem_text: String,
|
||
pub front_rules: String,
|
||
pub reveal_effect: String,
|
||
pub unresolved_effect: String,
|
||
}
|
||
|
||
/// The text of one scenario's Problems, by `hidden_priority`.
|
||
pub fn problem_texts(scenario_id: &str) -> Result<Vec<ProblemText>, String> {
|
||
let t = Table::parse(CSV, "Problems.csv")?;
|
||
let mut out = Vec::new();
|
||
for row in &t.rows {
|
||
if t.get(row, "scenario_id")? != scenario_id {
|
||
continue;
|
||
}
|
||
out.push(ProblemText {
|
||
priority: t
|
||
.get(row, "hidden_priority")?
|
||
.parse()
|
||
.map_err(|_| "hidden_priority is not a number".to_string())?,
|
||
title: t.get(row, "title")?.to_string(),
|
||
problem_text: t.get(row, "problem_text")?.to_string(),
|
||
front_rules: t.get(row, "front_rules")?.to_string(),
|
||
reveal_effect: t.get(row, "reveal_effect")?.to_string(),
|
||
unresolved_effect: t.get(row, "unresolved_effect")?.to_string(),
|
||
});
|
||
}
|
||
if out.is_empty() {
|
||
return Err(format!("no Problem text for {scenario_id}"));
|
||
}
|
||
out.sort_by_key(|p| p.priority);
|
||
Ok(out)
|
||
}
|
||
|
||
const CSV: &str = include_str!("../../../editions/ground-darvo-r0/Problems.csv");
|
||
const ACTIONS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Actions.csv");
|
||
const SOLUTIONS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Solutions.csv");
|
||
const MODES_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Modes.csv");
|
||
const TOKENS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Tokens.csv");
|
||
|
||
/// 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>>,
|
||
}
|
||
|
||
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 })
|
||
}
|
||
|
||
fn at(&self, name: &str) -> Result<usize, String> {
|
||
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();
|
||
let at = |name: &str| -> Result<usize, String> {
|
||
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)
|
||
}
|
||
|
||
/// 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.
|
||
#[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");
|
||
}
|
||
}
|