CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
//! 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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0028 T01/T02: the cards say what they do
ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.
THE MEASUREMENT IS THE DECISION, and the gap is bigger than "one file of
nineteen". Of the file we DID vendor, the engine reads 5 of 13 columns:
title, problem_text, front_rules, reveal_effect and unresolved_effect were
discarded at parse time. The cheapest part of this pass costs no new bytes
and was sitting in the repo for eight days. And SCN_01 is hardcoded at
lib.rs:1824 -- the edition ships FOUR scenarios and the engine has never
dealt three of them. Nobody had said so.
ADR-0011's revisit condition is measurably absent, so the dependency
argument does not get re-run: across Actions, Solutions, Modes and
Scenarios there are ZERO doubled quotes and ZERO embedded newlines. The
hand reader's only job is comma-in-quoted-field, which it already did.
Refusing csv on a measurement rather than on a preference.
Vendored Actions, Solutions and Modes -- the text a player reads. Not the
production artifacts (BOM, Print_Manifest, Back_Designs, Symbols). NOT
Extensions.csv, which names content the designer placed outside the core;
importing it would break the claim that this engine plays the edition as
printed. It is now known to exist, which was the real risk.
One Table reader with four callers, because a per-file copy is how a
parser acquires four subtly different bugs. The GROUND card now shows
"Regulate. Restore the frame. Decide." with its GR/OU/ND text on demand;
Problems show their own titles where a priority number used to be.
The load-bearing test asserts the text is a SUBSTRING OF THE VENDORED
FILE rather than equal to a Rust literal -- a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends.
`edition` came out from behind #[cfg(feature = "scenarios")]. It was gated
because its only consumer was; the edition is the game's own data and the
shipped runtime now reads it. Test machinery and game content are
different things and only one of them is optional.
And edition-check was written for a single-file world: it compared the
first recorded digest against Problems.csv regardless of which file that
digest described. It now checks every file both ways -- a vendored file
with no digest fails, a digest naming an absent file fails -- and asserts
ADR-0015 D3's falsifier directly rather than trusting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:22:04 +02:00
|
|
|
|
/// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
const CSV: &str = include_str!("../../../editions/ground-darvo-r0/Problems.csv");
|
CB-WP-0028 T01/T02: the cards say what they do
ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.
THE MEASUREMENT IS THE DECISION, and the gap is bigger than "one file of
nineteen". Of the file we DID vendor, the engine reads 5 of 13 columns:
title, problem_text, front_rules, reveal_effect and unresolved_effect were
discarded at parse time. The cheapest part of this pass costs no new bytes
and was sitting in the repo for eight days. And SCN_01 is hardcoded at
lib.rs:1824 -- the edition ships FOUR scenarios and the engine has never
dealt three of them. Nobody had said so.
ADR-0011's revisit condition is measurably absent, so the dependency
argument does not get re-run: across Actions, Solutions, Modes and
Scenarios there are ZERO doubled quotes and ZERO embedded newlines. The
hand reader's only job is comma-in-quoted-field, which it already did.
Refusing csv on a measurement rather than on a preference.
Vendored Actions, Solutions and Modes -- the text a player reads. Not the
production artifacts (BOM, Print_Manifest, Back_Designs, Symbols). NOT
Extensions.csv, which names content the designer placed outside the core;
importing it would break the claim that this engine plays the edition as
printed. It is now known to exist, which was the real risk.
One Table reader with four callers, because a per-file copy is how a
parser acquires four subtly different bugs. The GROUND card now shows
"Regulate. Restore the frame. Decide." with its GR/OU/ND text on demand;
Problems show their own titles where a priority number used to be.
The load-bearing test asserts the text is a SUBSTRING OF THE VENDORED
FILE rather than equal to a Rust literal -- a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends.
`edition` came out from behind #[cfg(feature = "scenarios")]. It was gated
because its only consumer was; the edition is the game's own data and the
shipped runtime now reads it. Test machinery and game content are
different things and only one of them is optional.
And edition-check was written for a single-file world: it compared the
first recorded digest against Problems.csv regardless of which file that
digest described. It now checks every file both ways -- a vendored file
with no digest fails, a digest naming an absent file fails -- and asserts
ADR-0015 D3's falsifier directly rather than trusting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:22:04 +02:00
|
|
|
|
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");
|
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>
2026-08-07 09:54:20 +02:00
|
|
|
|
const TOKENS_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Tokens.csv");
|
CB-WP-0028 T01/T02: the cards say what they do
ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.
THE MEASUREMENT IS THE DECISION, and the gap is bigger than "one file of
nineteen". Of the file we DID vendor, the engine reads 5 of 13 columns:
title, problem_text, front_rules, reveal_effect and unresolved_effect were
discarded at parse time. The cheapest part of this pass costs no new bytes
and was sitting in the repo for eight days. And SCN_01 is hardcoded at
lib.rs:1824 -- the edition ships FOUR scenarios and the engine has never
dealt three of them. Nobody had said so.
ADR-0011's revisit condition is measurably absent, so the dependency
argument does not get re-run: across Actions, Solutions, Modes and
Scenarios there are ZERO doubled quotes and ZERO embedded newlines. The
hand reader's only job is comma-in-quoted-field, which it already did.
Refusing csv on a measurement rather than on a preference.
Vendored Actions, Solutions and Modes -- the text a player reads. Not the
production artifacts (BOM, Print_Manifest, Back_Designs, Symbols). NOT
Extensions.csv, which names content the designer placed outside the core;
importing it would break the claim that this engine plays the edition as
printed. It is now known to exist, which was the real risk.
One Table reader with four callers, because a per-file copy is how a
parser acquires four subtly different bugs. The GROUND card now shows
"Regulate. Restore the frame. Decide." with its GR/OU/ND text on demand;
Problems show their own titles where a priority number used to be.
The load-bearing test asserts the text is a SUBSTRING OF THE VENDORED
FILE rather than equal to a Rust literal -- a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends.
`edition` came out from behind #[cfg(feature = "scenarios")]. It was gated
because its only consumer was; the edition is the game's own data and the
shipped runtime now reads it. Test machinery and game content are
different things and only one of them is optional.
And edition-check was written for a single-file world: it compared the
first recorded digest against Problems.csv regardless of which file that
digest described. It now checks every file both ways -- a vendored file
with no digest fails, a digest naming an absent file fails -- and asserts
ADR-0015 D3's falsifier directly rather than trusting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:22:04 +02:00
|
|
|
|
|
|
|
|
|
|
/// 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>>,
|
CB-WP-0037 T01: F18 gets a reproduction, and F24 falls out of it
F18 was the only open finding clay-borg owns, the only register row
lacking a reproduction, and the only off-target metric. It is also
understated: it reads as display data, but among the 14 unvendored files
are DARVO.csv (mandatory_effect, advance), Relations.csv (formation,
breaking) and Scenarios.csv — rules the engine already implements from a
secondary source and has never checked against the primary one.
The reproduction records column reads AT THE ACCESSOR rather than counting
them from the source: a list beside the code would be a second copy of a
fact the get calls already carry, and grepping would over-count because
six column names are shared between vendored files.
The first version was wrong in this repo's signature way — it watched
Table::at only, so it called visibility, required_solution and point_value
unread when the engine reads all three through problems_of's own index
lookups. Correct about the accessor, wrong about the engine: the ADR-0018
family, committed inside the artifact built to measure it. Problems.csv
went 7/13 to 10/13 once the manual reader was recorded too.
F24 raised: solution_deck() is a Rust literal that never opens
Solutions.csv. It agrees today, which is the point — the engine is right
by maintenance coincidence rather than by reading. Role `default`, with a
test that goes red the moment either side moves.
open, lacking a reproduction: 1 -> 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:11:17 +02:00
|
|
|
|
/// Which file this is, for the coverage probe (CB-WP-0037 T01).
|
|
|
|
|
|
/// Test-only, like the probe it feeds: the shipped path has no use
|
|
|
|
|
|
/// for it and `-D warnings` is right to say so.
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
what: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Which columns of which file the engine actually asks for.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **Recorded at the accessor, not counted from the source** (F18's
|
|
|
|
|
|
/// reproduction). A list of column names written beside the code would be
|
|
|
|
|
|
/// a second copy of a fact the `get` calls already carry, and this repo
|
|
|
|
|
|
/// has 62 untagged literals as evidence of where that goes. Grepping the
|
|
|
|
|
|
/// source instead would over-count, because six column names are shared
|
|
|
|
|
|
/// between vendored files -- and over-counting coverage understates the
|
|
|
|
|
|
/// gap, which is the wrong direction for a measurement whose whole job is
|
|
|
|
|
|
/// to size it.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Test-only: it exists to measure, and a global mutable has no business
|
|
|
|
|
|
/// in the shipped path.
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
pub mod probe {
|
|
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
|
|
use std::sync::{Mutex, OnceLock};
|
|
|
|
|
|
|
|
|
|
|
|
fn reads() -> &'static Mutex<BTreeMap<String, BTreeSet<String>>> {
|
|
|
|
|
|
static R: OnceLock<Mutex<BTreeMap<String, BTreeSet<String>>>> = OnceLock::new();
|
|
|
|
|
|
R.get_or_init(|| Mutex::new(BTreeMap::new()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn record(file: &str, column: &str) {
|
|
|
|
|
|
reads()
|
|
|
|
|
|
.lock()
|
|
|
|
|
|
.expect("probe")
|
|
|
|
|
|
.entry(file.to_string())
|
|
|
|
|
|
.or_default()
|
|
|
|
|
|
.insert(column.to_string());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn columns_read(file: &str) -> BTreeSet<String> {
|
|
|
|
|
|
reads()
|
|
|
|
|
|
.lock()
|
|
|
|
|
|
.expect("probe")
|
|
|
|
|
|
.get(file)
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
|
}
|
CB-WP-0028 T01/T02: the cards say what they do
ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.
THE MEASUREMENT IS THE DECISION, and the gap is bigger than "one file of
nineteen". Of the file we DID vendor, the engine reads 5 of 13 columns:
title, problem_text, front_rules, reveal_effect and unresolved_effect were
discarded at parse time. The cheapest part of this pass costs no new bytes
and was sitting in the repo for eight days. And SCN_01 is hardcoded at
lib.rs:1824 -- the edition ships FOUR scenarios and the engine has never
dealt three of them. Nobody had said so.
ADR-0011's revisit condition is measurably absent, so the dependency
argument does not get re-run: across Actions, Solutions, Modes and
Scenarios there are ZERO doubled quotes and ZERO embedded newlines. The
hand reader's only job is comma-in-quoted-field, which it already did.
Refusing csv on a measurement rather than on a preference.
Vendored Actions, Solutions and Modes -- the text a player reads. Not the
production artifacts (BOM, Print_Manifest, Back_Designs, Symbols). NOT
Extensions.csv, which names content the designer placed outside the core;
importing it would break the claim that this engine plays the edition as
printed. It is now known to exist, which was the real risk.
One Table reader with four callers, because a per-file copy is how a
parser acquires four subtly different bugs. The GROUND card now shows
"Regulate. Restore the frame. Decide." with its GR/OU/ND text on demand;
Problems show their own titles where a priority number used to be.
The load-bearing test asserts the text is a SUBSTRING OF THE VENDORED
FILE rather than equal to a Rust literal -- a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends.
`edition` came out from behind #[cfg(feature = "scenarios")]. It was gated
because its only consumer was; the edition is the game's own data and the
shipped runtime now reads it. Test machinery and game content are
different things and only one of them is optional.
And edition-check was written for a single-file world: it compared the
first recorded digest against Problems.csv regardless of which file that
digest described. It now checks every file both ways -- a vendored file
with no digest fails, a digest naming an absent file fails -- and asserts
ADR-0015 D3's falsifier directly rather than trusting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:22:04 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
CB-WP-0037 T01: F18 gets a reproduction, and F24 falls out of it
F18 was the only open finding clay-borg owns, the only register row
lacking a reproduction, and the only off-target metric. It is also
understated: it reads as display data, but among the 14 unvendored files
are DARVO.csv (mandatory_effect, advance), Relations.csv (formation,
breaking) and Scenarios.csv — rules the engine already implements from a
secondary source and has never checked against the primary one.
The reproduction records column reads AT THE ACCESSOR rather than counting
them from the source: a list beside the code would be a second copy of a
fact the get calls already carry, and grepping would over-count because
six column names are shared between vendored files.
The first version was wrong in this repo's signature way — it watched
Table::at only, so it called visibility, required_solution and point_value
unread when the engine reads all three through problems_of's own index
lookups. Correct about the accessor, wrong about the engine: the ADR-0018
family, committed inside the artifact built to measure it. Problems.csv
went 7/13 to 10/13 once the manual reader was recorded too.
F24 raised: solution_deck() is a Rust literal that never opens
Solutions.csv. It agrees today, which is the point — the engine is right
by maintenance coincidence rather than by reading. Role `default`, with a
test that goes red the moment either side moves.
open, lacking a reproduction: 1 -> 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:11:17 +02:00
|
|
|
|
Ok(Self {
|
|
|
|
|
|
cols,
|
|
|
|
|
|
rows,
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
what: what.to_string(),
|
|
|
|
|
|
})
|
CB-WP-0028 T01/T02: the cards say what they do
ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.
THE MEASUREMENT IS THE DECISION, and the gap is bigger than "one file of
nineteen". Of the file we DID vendor, the engine reads 5 of 13 columns:
title, problem_text, front_rules, reveal_effect and unresolved_effect were
discarded at parse time. The cheapest part of this pass costs no new bytes
and was sitting in the repo for eight days. And SCN_01 is hardcoded at
lib.rs:1824 -- the edition ships FOUR scenarios and the engine has never
dealt three of them. Nobody had said so.
ADR-0011's revisit condition is measurably absent, so the dependency
argument does not get re-run: across Actions, Solutions, Modes and
Scenarios there are ZERO doubled quotes and ZERO embedded newlines. The
hand reader's only job is comma-in-quoted-field, which it already did.
Refusing csv on a measurement rather than on a preference.
Vendored Actions, Solutions and Modes -- the text a player reads. Not the
production artifacts (BOM, Print_Manifest, Back_Designs, Symbols). NOT
Extensions.csv, which names content the designer placed outside the core;
importing it would break the claim that this engine plays the edition as
printed. It is now known to exist, which was the real risk.
One Table reader with four callers, because a per-file copy is how a
parser acquires four subtly different bugs. The GROUND card now shows
"Regulate. Restore the frame. Decide." with its GR/OU/ND text on demand;
Problems show their own titles where a priority number used to be.
The load-bearing test asserts the text is a SUBSTRING OF THE VENDORED
FILE rather than equal to a Rust literal -- a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends.
`edition` came out from behind #[cfg(feature = "scenarios")]. It was gated
because its only consumer was; the edition is the game's own data and the
shipped runtime now reads it. Test machinery and game content are
different things and only one of them is optional.
And edition-check was written for a single-file world: it compared the
first recorded digest against Problems.csv regardless of which file that
digest described. It now checks every file both ways -- a vendored file
with no digest fails, a digest naming an absent file fails -- and asserts
ADR-0015 D3's falsifier directly rather than trusting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:22:04 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn at(&self, name: &str) -> Result<usize, String> {
|
CB-WP-0037 T01: F18 gets a reproduction, and F24 falls out of it
F18 was the only open finding clay-borg owns, the only register row
lacking a reproduction, and the only off-target metric. It is also
understated: it reads as display data, but among the 14 unvendored files
are DARVO.csv (mandatory_effect, advance), Relations.csv (formation,
breaking) and Scenarios.csv — rules the engine already implements from a
secondary source and has never checked against the primary one.
The reproduction records column reads AT THE ACCESSOR rather than counting
them from the source: a list beside the code would be a second copy of a
fact the get calls already carry, and grepping would over-count because
six column names are shared between vendored files.
The first version was wrong in this repo's signature way — it watched
Table::at only, so it called visibility, required_solution and point_value
unread when the engine reads all three through problems_of's own index
lookups. Correct about the accessor, wrong about the engine: the ADR-0018
family, committed inside the artifact built to measure it. Problems.csv
went 7/13 to 10/13 once the manual reader was recorded too.
F24 raised: solution_deck() is a Rust literal that never opens
Solutions.csv. It agrees today, which is the point — the engine is right
by maintenance coincidence rather than by reading. Role `default`, with a
test that goes red the moment either side moves.
open, lacking a reproduction: 1 -> 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:11:17 +02:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
crate::edition::probe::record(&self.what, name);
|
CB-WP-0028 T01/T02: the cards say what they do
ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.
THE MEASUREMENT IS THE DECISION, and the gap is bigger than "one file of
nineteen". Of the file we DID vendor, the engine reads 5 of 13 columns:
title, problem_text, front_rules, reveal_effect and unresolved_effect were
discarded at parse time. The cheapest part of this pass costs no new bytes
and was sitting in the repo for eight days. And SCN_01 is hardcoded at
lib.rs:1824 -- the edition ships FOUR scenarios and the engine has never
dealt three of them. Nobody had said so.
ADR-0011's revisit condition is measurably absent, so the dependency
argument does not get re-run: across Actions, Solutions, Modes and
Scenarios there are ZERO doubled quotes and ZERO embedded newlines. The
hand reader's only job is comma-in-quoted-field, which it already did.
Refusing csv on a measurement rather than on a preference.
Vendored Actions, Solutions and Modes -- the text a player reads. Not the
production artifacts (BOM, Print_Manifest, Back_Designs, Symbols). NOT
Extensions.csv, which names content the designer placed outside the core;
importing it would break the claim that this engine plays the edition as
printed. It is now known to exist, which was the real risk.
One Table reader with four callers, because a per-file copy is how a
parser acquires four subtly different bugs. The GROUND card now shows
"Regulate. Restore the frame. Decide." with its GR/OU/ND text on demand;
Problems show their own titles where a priority number used to be.
The load-bearing test asserts the text is a SUBSTRING OF THE VENDORED
FILE rather than equal to a Rust literal -- a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends.
`edition` came out from behind #[cfg(feature = "scenarios")]. It was gated
because its only consumer was; the edition is the game's own data and the
shipped runtime now reads it. Test machinery and game content are
different things and only one of them is optional.
And edition-check was written for a single-file world: it compared the
first recorded digest against Problems.csv regardless of which file that
digest described. It now checks every file both ways -- a vendored file
with no digest fails, a digest naming an absent file fails -- and asserts
ADR-0015 D3's falsifier directly rather than trusting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:22:04 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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>
2026-08-07 09:54:20 +02:00
|
|
|
|
/// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0028 T01/T02: the cards say what they do
ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.
THE MEASUREMENT IS THE DECISION, and the gap is bigger than "one file of
nineteen". Of the file we DID vendor, the engine reads 5 of 13 columns:
title, problem_text, front_rules, reveal_effect and unresolved_effect were
discarded at parse time. The cheapest part of this pass costs no new bytes
and was sitting in the repo for eight days. And SCN_01 is hardcoded at
lib.rs:1824 -- the edition ships FOUR scenarios and the engine has never
dealt three of them. Nobody had said so.
ADR-0011's revisit condition is measurably absent, so the dependency
argument does not get re-run: across Actions, Solutions, Modes and
Scenarios there are ZERO doubled quotes and ZERO embedded newlines. The
hand reader's only job is comma-in-quoted-field, which it already did.
Refusing csv on a measurement rather than on a preference.
Vendored Actions, Solutions and Modes -- the text a player reads. Not the
production artifacts (BOM, Print_Manifest, Back_Designs, Symbols). NOT
Extensions.csv, which names content the designer placed outside the core;
importing it would break the claim that this engine plays the edition as
printed. It is now known to exist, which was the real risk.
One Table reader with four callers, because a per-file copy is how a
parser acquires four subtly different bugs. The GROUND card now shows
"Regulate. Restore the frame. Decide." with its GR/OU/ND text on demand;
Problems show their own titles where a priority number used to be.
The load-bearing test asserts the text is a SUBSTRING OF THE VENDORED
FILE rather than equal to a Rust literal -- a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends.
`edition` came out from behind #[cfg(feature = "scenarios")]. It was gated
because its only consumer was; the edition is the game's own data and the
shipped runtime now reads it. Test machinery and game content are
different things and only one of them is optional.
And edition-check was written for a single-file world: it compared the
first recorded digest against Problems.csv regardless of which file that
digest described. It now checks every file both ways -- a vendored file
with no digest fails, a digest naming an absent file fails -- and asserts
ADR-0015 D3's falsifier directly rather than trusting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:22:04 +02:00
|
|
|
|
/// 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)
|
|
|
|
|
|
}
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
|
|
|
|
|
|
/// Split one CSV record, honouring `"…"` quoting.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `problem_text` contains commas, which is the only reason this is not a
|
|
|
|
|
|
/// `split(',')`. Doubled quotes inside a quoted field are not handled and
|
|
|
|
|
|
/// do not occur; if they ever do, this returns the wrong field count and
|
|
|
|
|
|
/// `problems_of` fails loudly rather than mis-parsing.
|
|
|
|
|
|
fn fields(line: &str) -> Vec<String> {
|
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
|
let mut cur = String::new();
|
|
|
|
|
|
let mut quoted = false;
|
|
|
|
|
|
for c in line.chars() {
|
|
|
|
|
|
match c {
|
|
|
|
|
|
'"' => quoted = !quoted,
|
|
|
|
|
|
',' if !quoted => out.push(std::mem::take(&mut cur)),
|
|
|
|
|
|
c => cur.push(c),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
out.push(cur);
|
|
|
|
|
|
out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn suit_of(s: &str) -> Option<Suit> {
|
|
|
|
|
|
match s.trim() {
|
|
|
|
|
|
"Clarify" => Some(Suit::Clarify),
|
|
|
|
|
|
"Repair" => Some(Suit::Repair),
|
|
|
|
|
|
"Boundary" => Some(Suit::Boundary),
|
|
|
|
|
|
"Change" => Some(Suit::Change),
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Every Problem of one scenario, ordered by `hidden_priority`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns `Err` rather than an empty list when the data does not parse:
|
|
|
|
|
|
/// a loader that silently reads nothing would hand `setup` a game with no
|
|
|
|
|
|
/// Problems and look like a rules bug.
|
|
|
|
|
|
pub fn problems_of(scenario_id: &str) -> Result<Vec<EditionProblem>, String> {
|
|
|
|
|
|
let mut lines = CSV.lines();
|
|
|
|
|
|
let header = lines.next().ok_or("edition data is empty")?;
|
|
|
|
|
|
let cols: Vec<String> = fields(header)
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|c| c.trim_start_matches('\u{feff}').trim().to_string())
|
|
|
|
|
|
.collect();
|
CB-WP-0037 T01: F18 gets a reproduction, and F24 falls out of it
F18 was the only open finding clay-borg owns, the only register row
lacking a reproduction, and the only off-target metric. It is also
understated: it reads as display data, but among the 14 unvendored files
are DARVO.csv (mandatory_effect, advance), Relations.csv (formation,
breaking) and Scenarios.csv — rules the engine already implements from a
secondary source and has never checked against the primary one.
The reproduction records column reads AT THE ACCESSOR rather than counting
them from the source: a list beside the code would be a second copy of a
fact the get calls already carry, and grepping would over-count because
six column names are shared between vendored files.
The first version was wrong in this repo's signature way — it watched
Table::at only, so it called visibility, required_solution and point_value
unread when the engine reads all three through problems_of's own index
lookups. Correct about the accessor, wrong about the engine: the ADR-0018
family, committed inside the artifact built to measure it. Problems.csv
went 7/13 to 10/13 once the manual reader was recorded too.
F24 raised: solution_deck() is a Rust literal that never opens
Solutions.csv. It agrees today, which is the point — the engine is right
by maintenance coincidence rather than by reading. Role `default`, with a
test that goes red the moment either side moves.
open, lacking a reproduction: 1 -> 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:11:17 +02:00
|
|
|
|
// 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.
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
let at = |name: &str| -> Result<usize, String> {
|
CB-WP-0037 T01: F18 gets a reproduction, and F24 falls out of it
F18 was the only open finding clay-borg owns, the only register row
lacking a reproduction, and the only off-target metric. It is also
understated: it reads as display data, but among the 14 unvendored files
are DARVO.csv (mandatory_effect, advance), Relations.csv (formation,
breaking) and Scenarios.csv — rules the engine already implements from a
secondary source and has never checked against the primary one.
The reproduction records column reads AT THE ACCESSOR rather than counting
them from the source: a list beside the code would be a second copy of a
fact the get calls already carry, and grepping would over-count because
six column names are shared between vendored files.
The first version was wrong in this repo's signature way — it watched
Table::at only, so it called visibility, required_solution and point_value
unread when the engine reads all three through problems_of's own index
lookups. Correct about the accessor, wrong about the engine: the ADR-0018
family, committed inside the artifact built to measure it. Problems.csv
went 7/13 to 10/13 once the manual reader was recorded too.
F24 raised: solution_deck() is a Rust literal that never opens
Solutions.csv. It agrees today, which is the point — the engine is right
by maintenance coincidence rather than by reading. Role `default`, with a
test that goes red the moment either side moves.
open, lacking a reproduction: 1 -> 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:11:17 +02:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
probe::record("Problems.csv", name);
|
CB-WP-0021 T01/T02/T05: the engine plays its own data — AM-7 blocks
ADR-0011 decided it: vendor the CSV with a checked digest, read it with
a ~50-line reader, and let the hashes move.
The declaration's constraint was measured against the WRONG BUDGET. It
said a CSV crate costs 21,613 against AM-4a's 3,798 of headroom, '5.7x
over, settled by measurement'. But setup and problem_priorities are
cfg(scenarios) and are not in the shipped runtime at all, so AM-4a never
sees them. Against AM-4b, csv costs 17,651 against 19,742 -- it FITS,
with 2,091 to spare. It is refused anyway, on proportion: 89% of the
budget's remaining capacity to read 20 rows. The revisit condition is
stated (nested quoting, embedded newlines, multiple dialects).
GR-S01 now deals Surface + hidden 1..=k as ruled, with edition values and
suits. Measured: 6/9/12 available against thresholds 5/7/9 -- the game is
winnable at every seat count, which is what the maintainer could not do.
gd0001 is INVERTED, not deleted, and now also asserts the 6/9/12 so a
deal that is reachable for the wrong reason still fails.
Blast radius was scenario expectations, exactly as the ADR predicted: no
scenario pinned a hash and no bundle is committed. Six scenarios and two
unit tests updated, each with a note. gr-e01-threshold-unreachable-2p is
RENAMED to -reachable- and rewritten as the non-provisional import check
ground-game asked for by name. gr-e03's setup was restructured, not just
renumbered: with values 2,2,2 its personal-edge test would have tied
three ways and asserted nothing.
BLOCKING: AM-7 fails at median 0.845 against its 0.9 floor. Isolated
across three runs -- 3 problems + stand-in 0.97, 3 problems + edition
0.909, 4 problems + edition 0.845. State is BOUNDED (proven: identical
after 5k and 100k events), so this is not the unbounded-growth defect
AM-7 exists to catch; it is a bigger working set streaming a long log.
Whether AM-7's floor is still right for a larger aggregate is a spec
question and lowering it requires an ADR, so it is not being tuned here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:56 +02:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
CB-WP-0028 T01/T02: the cards say what they do
ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.
THE MEASUREMENT IS THE DECISION, and the gap is bigger than "one file of
nineteen". Of the file we DID vendor, the engine reads 5 of 13 columns:
title, problem_text, front_rules, reveal_effect and unresolved_effect were
discarded at parse time. The cheapest part of this pass costs no new bytes
and was sitting in the repo for eight days. And SCN_01 is hardcoded at
lib.rs:1824 -- the edition ships FOUR scenarios and the engine has never
dealt three of them. Nobody had said so.
ADR-0011's revisit condition is measurably absent, so the dependency
argument does not get re-run: across Actions, Solutions, Modes and
Scenarios there are ZERO doubled quotes and ZERO embedded newlines. The
hand reader's only job is comma-in-quoted-field, which it already did.
Refusing csv on a measurement rather than on a preference.
Vendored Actions, Solutions and Modes -- the text a player reads. Not the
production artifacts (BOM, Print_Manifest, Back_Designs, Symbols). NOT
Extensions.csv, which names content the designer placed outside the core;
importing it would break the claim that this engine plays the edition as
printed. It is now known to exist, which was the real risk.
One Table reader with four callers, because a per-file copy is how a
parser acquires four subtly different bugs. The GROUND card now shows
"Regulate. Restore the frame. Decide." with its GR/OU/ND text on demand;
Problems show their own titles where a priority number used to be.
The load-bearing test asserts the text is a SUBSTRING OF THE VENDORED
FILE rather than equal to a Rust literal -- a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends.
`edition` came out from behind #[cfg(feature = "scenarios")]. It was gated
because its only consumer was; the edition is the game's own data and the
shipped runtime now reads it. Test machinery and game content are
different things and only one of them is optional.
And edition-check was written for a single-file world: it compared the
first recorded digest against Problems.csv regardless of which file that
digest described. It now checks every file both ways -- a vendored file
with no digest fails, a digest naming an absent file fails -- and asserts
ADR-0015 D3's falsifier directly rather than trusting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:22:04 +02:00
|
|
|
|
|
|
|
|
|
|
#[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.
|
CB-WP-0037 T01: F18 gets a reproduction, and F24 falls out of it
F18 was the only open finding clay-borg owns, the only register row
lacking a reproduction, and the only off-target metric. It is also
understated: it reads as display data, but among the 14 unvendored files
are DARVO.csv (mandatory_effect, advance), Relations.csv (formation,
breaking) and Scenarios.csv — rules the engine already implements from a
secondary source and has never checked against the primary one.
The reproduction records column reads AT THE ACCESSOR rather than counting
them from the source: a list beside the code would be a second copy of a
fact the get calls already carry, and grepping would over-count because
six column names are shared between vendored files.
The first version was wrong in this repo's signature way — it watched
Table::at only, so it called visibility, required_solution and point_value
unread when the engine reads all three through problems_of's own index
lookups. Correct about the accessor, wrong about the engine: the ADR-0018
family, committed inside the artifact built to measure it. Problems.csv
went 7/13 to 10/13 once the manual reader was recorded too.
F24 raised: solution_deck() is a Rust literal that never opens
Solutions.csv. It agrees today, which is the point — the engine is right
by maintenance coincidence rather than by reading. Role `default`, with a
test that goes red the moment either side moves.
open, lacking a reproduction: 1 -> 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:11:17 +02:00
|
|
|
|
/// **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 files = [
|
|
|
|
|
|
("Problems.csv", CSV),
|
|
|
|
|
|
("Actions.csv", ACTIONS_CSV),
|
|
|
|
|
|
("Solutions.csv", SOLUTIONS_CSV),
|
|
|
|
|
|
("Modes.csv", MODES_CSV),
|
|
|
|
|
|
("Tokens.csv", TOKENS_CSV),
|
|
|
|
|
|
];
|
|
|
|
|
|
let mut report = String::new();
|
|
|
|
|
|
let mut total_unread = 0usize;
|
|
|
|
|
|
for (name, csv) in files {
|
|
|
|
|
|
let header: Vec<String> = fields(csv.lines().next().expect("header"))
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|c| c.trim_start_matches('\u{feff}').trim().to_string())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
let read = probe::columns_read(name);
|
|
|
|
|
|
let unread: Vec<&String> = header.iter().filter(|c| !read.contains(*c)).collect();
|
|
|
|
|
|
total_unread += unread.len();
|
|
|
|
|
|
report.push_str(&format!(
|
|
|
|
|
|
"{name}: {} of {} columns read; unread: {unread:?}\n",
|
|
|
|
|
|
header.len() - unread.len(),
|
|
|
|
|
|
header.len(),
|
|
|
|
|
|
));
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!read.is_empty(),
|
|
|
|
|
|
"{name} is vendored and no column of it is read at all:\n{report}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
// The probe must actually have seen something, or every count
|
|
|
|
|
|
// above is zero for a reason that has nothing to do with the gap.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
total_unread > 0,
|
|
|
|
|
|
"every vendored column is read — F18's premise no longer holds, \
|
|
|
|
|
|
so the finding needs closing rather than this test passing:\n{report}"
|
|
|
|
|
|
);
|
|
|
|
|
|
println!("{report}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// **F24** (CB-WP-0037 T01). The draw pile is a Rust literal.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `solution_deck()` builds 6 of each suit from an array and never
|
|
|
|
|
|
/// opens `Solutions.csv`, whose `suit` and `quantity` columns say the
|
|
|
|
|
|
/// same thing. **It currently agrees** — 24 rows, 6 per suit — so
|
|
|
|
|
|
/// nothing is wrong today, and that is exactly the problem: the
|
|
|
|
|
|
/// engine is right by maintenance coincidence, not by reading, and
|
|
|
|
|
|
/// the day `ground-game` changes a quantity nothing here notices.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This test is the guard until the literal is deleted (T02): it goes
|
|
|
|
|
|
/// red if either side moves.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn the_hardcoded_deck_still_matches_the_edition() {
|
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
let t = Table::parse(SOLUTIONS_CSV, "Solutions.csv").expect("solutions");
|
|
|
|
|
|
let mut from_file: BTreeMap<String, u32> = BTreeMap::new();
|
|
|
|
|
|
for row in &t.rows {
|
|
|
|
|
|
let suit = t.get(row, "suit").expect("suit").to_string();
|
|
|
|
|
|
let n: u32 = t
|
|
|
|
|
|
.get(row, "quantity")
|
|
|
|
|
|
.expect("quantity")
|
|
|
|
|
|
.parse()
|
|
|
|
|
|
.expect("quantity is a number");
|
|
|
|
|
|
*from_file.entry(suit).or_default() += n;
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut from_code: BTreeMap<String, u32> = BTreeMap::new();
|
|
|
|
|
|
for c in solution_deck() {
|
|
|
|
|
|
*from_code.entry(format!("{:?}", c.suit)).or_default() += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
from_file, from_code,
|
|
|
|
|
|
"the hardcoded solution deck and the edition disagree — the \
|
|
|
|
|
|
literal is the copy, so the edition is right and F24 has \
|
|
|
|
|
|
become a live defect rather than a latent one"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0028 T01/T02: the cards say what they do
ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.
THE MEASUREMENT IS THE DECISION, and the gap is bigger than "one file of
nineteen". Of the file we DID vendor, the engine reads 5 of 13 columns:
title, problem_text, front_rules, reveal_effect and unresolved_effect were
discarded at parse time. The cheapest part of this pass costs no new bytes
and was sitting in the repo for eight days. And SCN_01 is hardcoded at
lib.rs:1824 -- the edition ships FOUR scenarios and the engine has never
dealt three of them. Nobody had said so.
ADR-0011's revisit condition is measurably absent, so the dependency
argument does not get re-run: across Actions, Solutions, Modes and
Scenarios there are ZERO doubled quotes and ZERO embedded newlines. The
hand reader's only job is comma-in-quoted-field, which it already did.
Refusing csv on a measurement rather than on a preference.
Vendored Actions, Solutions and Modes -- the text a player reads. Not the
production artifacts (BOM, Print_Manifest, Back_Designs, Symbols). NOT
Extensions.csv, which names content the designer placed outside the core;
importing it would break the claim that this engine plays the edition as
printed. It is now known to exist, which was the real risk.
One Table reader with four callers, because a per-file copy is how a
parser acquires four subtly different bugs. The GROUND card now shows
"Regulate. Restore the frame. Decide." with its GR/OU/ND text on demand;
Problems show their own titles where a priority number used to be.
The load-bearing test asserts the text is a SUBSTRING OF THE VENDORED
FILE rather than equal to a Rust literal -- a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends.
`edition` came out from behind #[cfg(feature = "scenarios")]. It was gated
because its only consumer was; the edition is the game's own data and the
shipped runtime now reads it. Test machinery and game content are
different things and only one of them is optional.
And edition-check was written for a single-file world: it compared the
first recorded digest against Problems.csv regardless of which file that
digest described. It now checks every file both ways -- a vendored file
with no digest fails, a digest naming an absent file fails -- and asserts
ADR-0015 D3's falsifier directly rather than trusting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:22:04 +02:00
|
|
|
|
#[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)"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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>
2026-08-07 09:54:20 +02:00
|
|
|
|
|
|
|
|
|
|
#[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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|