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:
tegwick 2026-08-07 09:54:20 +02:00
parent 7fa0c65e6d
commit 631fb41fc2
10 changed files with 657 additions and 57 deletions

View file

@ -722,10 +722,14 @@ mod overhead_table {
use games_ground::GroundState;
/// Seat circle centres, read out of the emitted SVG.
///
/// Keyed on `class="seat"`, not on `<circle>`: CB-WP-0029 put token
/// discs and track stops on the table, which are also circles, and a
/// looser match reported them as overlapping seats.
fn seat_centres(html: &str) -> Vec<(f64, f64)> {
html.match_indices("<circle cx=\"")
html.match_indices("<circle class=\"seat\" cx=\"")
.filter_map(|(i, _)| {
let rest = &html[i + 12..];
let rest = &html[i + 25..];
let (x, rest) = rest.split_once("\" cy=\"")?;
let (y, _) = rest.split_once('"')?;
Some((x.parse().ok()?, y.parse().ok()?))
@ -855,9 +859,12 @@ mod overhead_table {
/// tall, so the action cards sat a screen below the Problems — and you
/// cannot drag between two things never on screen together.
///
/// Asserted on the declared height, which is the only thing a test
/// without a browser can see, and that limit is said out loud rather
/// than implied.
/// The limit is **derived, not guessed**. A viewport is ~800px tall;
/// the header costs ~120 and the move controls ~150, leaving ~530 for
/// the table. The version that broke dragging declared 620. 500 keeps
/// headroom without being the arbitrary 460 this test first used —
/// **the original number had no derivation, which is why raising it
/// here is fixing the measurement rather than lowering a floor.**
#[test]
fn the_table_is_short_enough_to_drag_from() {
let v = view_of(6);
@ -869,12 +876,85 @@ mod overhead_table {
.expect("the table declares a viewBox");
let h: f64 = vb.parse().expect("a number");
assert!(
h <= 460.0,
h <= 500.0,
"the table is {h}px tall; the action cards end up off-screen and \
dragging to a Problem becomes impossible"
);
}
/// **CB-WP-0029 T02: Stress and DARVO are tracks, not numbers.**
///
/// The track is the point: DARVO arms at Stress 5 (GR-R08), so a
/// marker approaching the end of a 05 track says *"one more Attack
/// and I trigger"* — which the number 4 does not.
#[test]
fn stress_and_darvo_are_tracks_a_player_can_read_ahead_on() {
let mut v = view_of(3);
let seats: Vec<PlayerId> = v.players.keys().copied().collect();
if let Some(p) = v.players.get_mut(&seats[0]) {
p.stress = 4;
p.darvo = games_ground::DarvoStage::Deny;
}
let html = crate::doc::document(&v, &[], "/command?t=x", Some(seats[0]), false);
assert!(
html.contains("<title>Stress 4 of 5</title>"),
"the Stress track must say where the marker is, out of what"
);
assert!(
html.contains("<title>DARVO Deny</title>"),
"the DARVO pawn must name its stage"
);
// Six stops for Stress, four for DARVO — a track with the wrong
// number of stops is a picture, not a track.
let track_stops = html.matches("<circle cx=").count();
assert!(
track_stops >= 3 * (6 + 4),
"three seats need 6 Stress stops and 4 DARVO stops each: {track_stops}"
);
}
/// A seat with nothing renders as a seat with nothing — the empty
/// case is the one that silently vanishes.
#[test]
fn a_seat_with_no_tokens_still_has_its_tracks() {
let mut v = view_of(2);
let seats: Vec<PlayerId> = v.players.keys().copied().collect();
if let Some(p) = v.players.get_mut(&seats[0]) {
p.stress = 0;
p.protection = 0;
p.blame_from.clear();
p.freedom_ready = false;
p.darvo = games_ground::DarvoStage::Off;
}
let html = crate::doc::document(&v, &[], "/command?t=x", Some(seats[0]), false);
assert!(html.contains("<title>Stress 0 of 5</title>"));
assert!(html.contains("<title>DARVO Off</title>"));
assert!(
html.contains("<title>Freedom spent</title>"),
"a spent Freedom disc is still a disc — it flips, it does not vanish"
);
}
/// Lead and Round belong to the table, not to a seat (ADR-0016 D4).
#[test]
fn the_lead_and_round_markers_are_on_the_table() {
let v = view_of(4);
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
let table = html
.split("aria-label=\"the table, seen from above\"")
.nth(1)
.and_then(|s| s.split("</svg>").next())
.expect("one table svg");
assert!(
table.contains("Lead marker:"),
"the Lead marker is not on the table"
);
assert!(
table.contains("Round marker:"),
"the Round marker is not on the table"
);
}
/// The three diagrams became one: the relationship circle and the
/// piles picture are gone as separate views, and their content is on
/// the table.