CB-WP-0029 T01-T03: components you can count, and a supply that does not bind
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>
This commit is contained in:
parent
7fa0c65e6d
commit
631fb41fc2
10 changed files with 657 additions and 57 deletions
|
|
@ -71,6 +71,7 @@ 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.
|
||||
///
|
||||
|
|
@ -148,6 +149,60 @@ fn card_texts(csv: &str, what: &str, id: &str, tag: &str) -> Result<Vec<CardText
|
|||
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")
|
||||
|
|
@ -417,3 +472,107 @@ mod card_text_tests {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue