diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md
index bfb9292..f39dd25 100644
--- a/WORK-RECORDS.md
+++ b/WORK-RECORDS.md
@@ -36,6 +36,7 @@
| workplan | CB-WP-0026 | done | — | workplans/CB-WP-0026-collect-the-rulings.md |
| workplan | CB-WP-0027 | done | — | workplans/CB-WP-0027-the-commentary-track.md |
| workplan | CB-WP-0028 | done | — | workplans/CB-WP-0028-the-table-you-sit-at.md |
+| workplan | CB-WP-0029 | ready | — | workplans/CB-WP-0029-the-tokens-on-the-table.md |
| task | CB-WP-0001-T01 | done | — | workplans/CB-WP-0001-inner-loop.md |
| task | CB-WP-0001-T02 | done | — | workplans/CB-WP-0001-inner-loop.md |
| task | CB-WP-0001-T03 | done | — | workplans/CB-WP-0001-inner-loop.md |
@@ -189,3 +190,7 @@
| task | CB-WP-0028-T06 | done | — | workplans/CB-WP-0028-the-table-you-sit-at.md |
| task | CB-WP-0028-T07 | done | — | workplans/CB-WP-0028-the-table-you-sit-at.md |
| task | CB-WP-0028-T08 | done | — | workplans/CB-WP-0028-the-table-you-sit-at.md |
+| task | CB-WP-0029-T01 | todo | — | workplans/CB-WP-0029-the-tokens-on-the-table.md |
+| task | CB-WP-0029-T02 | todo | — | workplans/CB-WP-0029-the-tokens-on-the-table.md |
+| task | CB-WP-0029-T03 | todo | — | workplans/CB-WP-0029-the-tokens-on-the-table.md |
+| task | CB-WP-0029-T04 | todo | — | workplans/CB-WP-0029-the-tokens-on-the-table.md |
diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs
index ac9a5ed..6cc199f 100644
--- a/crates/cb-render-html/src/doc.rs
+++ b/crates/cb-render-html/src/doc.rs
@@ -474,6 +474,111 @@ fn piles_body(out: &mut String, view: &GroundView) {
}
}
+/// A marker on a track, drawn at `(x, y)` with `n` stops.
+///
+/// **The track is the point** (ADR-0016 D4). `stress 5` is a fact you
+/// read; a marker at the end of a 0-5 track is a fact you see coming --
+/// and DARVO triggers at Stress 5, so "one more Attack and I trigger" is
+/// the most useful thing the page can show.
+fn track_svg(out: &mut String, x: f64, y: f64, stops: usize, at: usize, hot: bool, label: &str) {
+ let step = 11.0;
+ let _ = write!(out, "{}", esc(label));
+ for i in 0..stops {
+ let cx = x + (i as f64) * step;
+ let here = i == at;
+ let _ = write!(
+ out,
+ "",
+ r = if here { 4.5 } else { 2.5 },
+ fill = if here {
+ if hot {
+ "#e77"
+ } else {
+ "#9cf"
+ }
+ } else {
+ "#2a3140"
+ },
+ stroke = if here { "#fff8" } else { "#3a4350" },
+ );
+ }
+ out.push_str("");
+}
+
+/// The tokens at one seat, as objects rather than numbers.
+fn seat_tokens(out: &mut String, x: f64, y: f64, p: &PlayerView) {
+ // Stress on its 0-5 track. Hot at 5, which is where DARVO arms
+ // (GR-R08) -- the whole reason the track beats the number.
+ track_svg(
+ out,
+ x - 27.0,
+ y + 26.0,
+ 6,
+ usize::from(p.stress).min(5),
+ p.stress >= 5,
+ &format!("Stress {} of 5", p.stress),
+ );
+ // The DARVO pawn on OFF/DENY/ATTACK/REVERSE.
+ let stage = match p.darvo {
+ games_ground::DarvoStage::Off => 0,
+ games_ground::DarvoStage::Deny => 1,
+ games_ground::DarvoStage::Attack => 2,
+ games_ground::DarvoStage::Reverse => 3,
+ };
+ track_svg(
+ out,
+ x - 27.0,
+ y + 38.0,
+ 4,
+ stage,
+ stage > 0,
+ &format!("DARVO {:?}", p.darvo),
+ );
+
+ // Counted discs: Freedom (double-sided), Protection, Blame.
+ let mut dx = x - 28.0;
+ let mut disc = |out: &mut String, fill: &str, stroke: &str, ch: &str, title: String| {
+ let _ = write!(
+ out,
+ "{t}\
+ {ch}",
+ t = esc(&title),
+ cy = y + 54.0,
+ ty = y + 57.0,
+ );
+ dx += 17.0;
+ };
+ disc(
+ out,
+ if p.freedom_ready {
+ "#2b4a3a"
+ } else {
+ "#2a2f3a"
+ },
+ if p.freedom_ready { "#7ca" } else { "#4a5260" },
+ if p.freedom_ready { "R" } else { "\u{2013}" },
+ format!(
+ "Freedom {}",
+ if p.freedom_ready { "READY" } else { "spent" }
+ ),
+ );
+ for i in 0..p.protection {
+ disc(out, "#2a3a4a", "#7ac", "P", format!("Protection {}", i + 1));
+ }
+ for b in &p.blame_from {
+ disc(
+ out,
+ "#3a2a2a",
+ "#c88",
+ "B",
+ format!("Blame from {}", seat_name(*b)),
+ );
+ }
+}
+
/// The table, seen from above (CB-WP-0028 T03).
///
/// **One diagram, not three.** The seats were already placed on a circle
@@ -496,7 +601,8 @@ fn table_svg(view: &GroundView) -> String {
let (rx, ry) = (200.0f64, 118.0f64);
// Seats sit OUTSIDE the table, as people do. The first version put
// them at 0.83 of the ellipse and they sat on it.
- let (sx, sy) = (rx + 108.0, ry + 88.0);
+ // Seats sit outside the table with room below each for its tokens.
+ let (sx, sy) = (rx + 112.0, ry + 82.0);
let pos: Vec<(PlayerId, f64, f64)> = view
.players
@@ -515,7 +621,7 @@ fn table_svg(view: &GroundView) -> String {
};
let mut s = String::from(
- "");
s
}
diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs
index 5941d62..7663d4e 100644
--- a/crates/cb-render-html/src/lib.rs
+++ b/crates/cb-render-html/src/lib.rs
@@ -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 ``: 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(" = 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("Stress 4 of 5"),
+ "the Stress track must say where the marker is, out of what"
+ );
+ assert!(
+ html.contains("DARVO Deny"),
+ "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(" = 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("Stress 0 of 5"));
+ assert!(html.contains("DARVO Off"));
+ assert!(
+ html.contains("Freedom spent"),
+ "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("").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.
diff --git a/decisions/ADR-0016-the-tokens-on-the-table.md b/decisions/ADR-0016-the-tokens-on-the-table.md
new file mode 100644
index 0000000..9e2db70
--- /dev/null
+++ b/decisions/ADR-0016-the-tokens-on-the-table.md
@@ -0,0 +1,132 @@
+# ADR-0016: a token is a view, and the supply is not a rule we get to invent
+
+status: accepted
+date: 2026-08-07
+decided by: agent, under the standing loop authorization
+tier: M (structural M — imports another edition file under AM-4's budgets;
+chaos d8=4 → no override). Tier M merges survey and decision.
+references: [CB-WP-0029](../workplans/CB-WP-0029-the-tokens-on-the-table.md),
+[ADR-0015](ADR-0015-the-cards-own-words.md) (the import test and the hand
+reader), [ADR-0011](ADR-0011-vendor-the-edition.md),
+[GameDesign.md](../specs/GameDesign.md) §1
+
+## Context
+
+The engine models every token correctly and shows them as numbers on a
+seat card — `protect 2`, `blamed by P3`. `Tokens.csv` describes them as
+components, with a `quantity` and a `sides` count, and that raised a
+question worth measuring before deciding anything.
+
+## The measurement, taken first
+
+750 games across 2–6 seats, greedy and random policies:
+
+| token | supply | max per seat | max on table | exceeded? |
+|---|---:|---:|---:|---|
+| Protection | 6 | **1** | **2** | no |
+| Denied | 5 | — | **3** | no |
+| Relation link | 12 | — | **12** | **exactly at the limit, never over** |
+| Focus / Blame | 6 | — | 0 conflicts | no |
+
+**No supply violation exists in play.**
+
+**And the first version of that check was wrong.** It tested whether a
+seat had its own Focus placed *and* any Blame in `blame_from` — but
+`blame_from` lists **other players'** discs, so those 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 — caught before it left the repo.
+
+**The link row is the interesting one.** Twelve tokens, two per player,
+six players: the supply is *exactly* consumed at full occupancy and never
+exceeded, because GR-L01 already enforces two slots per seat. **The
+component count and the rule are the same constraint written twice** — and
+that is the shape of a supply that does not need separate enforcement.
+
+---
+
+## D1 — vendor `Tokens.csv`
+
+ADR-0015's test: does it carry text a player reads? `front_text` (READY,
+DENIED, PROTECTION, FOCUS, LEAD, ROUND) and `use` do. `shape`, `size` and
+`symbol_id` are print instructions and are read by nothing here.
+
+Vendored whole with a digest, as the others are — taking a column subset
+would mean a second decision every time a column becomes interesting.
+
+## D2 — a token is a **view**, not a type
+
+**The aggregate gains no `Token`.** A token is a way of *seeing* state the
+aggregate already holds: the Stress marker is `stress`, the DARVO pawn is
+`darvo`, the Freedom disc is `freedom_ready`.
+
+Adding a `Token` type would create a second source of truth for Stress,
+and the first time they disagreed the bug would be invisible — both would
+look internally consistent. **This is INTENT's own rule**: own the
+semantics, and do not duplicate them for presentation's sake.
+
+So `Tokens.csv` supplies **labels and counts for the renderer**, and
+nothing in `games_ground` changes shape.
+
+## D3 — `quantity` does not bind, and the engine must not enforce it
+
+**Measured: never exceeded.** But the reason for the decision is not the
+measurement.
+
+> **A component limit the rules do not state is not a rule.**
+
+`GroundRules.md` derives 59 rules from the dataset and **none of them
+mentions a token supply**. If the engine began refusing a seventh
+Protection token, it would be enforcing a constraint nobody ruled — which
+is exactly the error CB-WP-0023 exists to correct, in the other direction:
+SOLVE was *offered* where it could not act, and this would *refuse* where
+the rules allow.
+
+**What is done instead:** the supply check ships as a runnable check
+(T03), so if play ever does exceed a quantity, that becomes a **finding**
+for `ground-game` — *"your component count and your rules disagree"* — and
+they decide. Which is a real question: a physical game cannot hand out a
+seventh Protection token, so either the rules bound it or the box does.
+
+**Not registered as a finding today**, because nothing was found. **A
+stated "none found" is registered instead**, because a survey that reports
+nothing and leaves no trace is indistinguishable from one that was never
+run.
+
+## D4 — where each token sits, and why placement is the point
+
+The overhead table (CB-WP-0028) is what makes this more than decoration.
+
+| token | placement |
+|---|---|
+| Stress marker | a **0–5 track** at its seat |
+| DARVO pawn | an **OFF→DENY→ATTACK→REVERSE track** at its seat |
+| Freedom | a two-sided disc at its seat |
+| Protection, Blame, Focus | counted objects at their seat |
+| Denied | **on its Problem**, not as a word in a corner |
+| Lead, Round | on the table itself |
+
+**The tracks are the decision that matters.** `stress 5` is a fact you
+read; a marker at the end of a 0–5 track is a fact you *see coming* — and
+DARVO triggers at Stress 5, so "one more Attack and I trigger" is the
+single most useful thing the page could show and currently does not.
+
+## Consequences
+
+- `editions/ground-darvo-r0/Tokens.csv` + digest; `edition-check` covers
+ it automatically, since it enumerates what is present.
+- `edition.rs` gains a `tokens()` reader over the existing `Table`.
+- The renderer draws tracks and counted objects; `games_ground` is
+ unchanged.
+- The supply check is committed and runnable, and its result is in the
+ register as a stated negative.
+
+## What was rejected
+
+| rejected | why |
+|---|---|
+| a `Token` type in the aggregate | a second source of truth for Stress; INTENT forbids it |
+| enforcing `quantity` | a limit the rules do not state is not a rule (CB-WP-0023, inverted) |
+| importing only some columns | a second decision every time a column becomes interesting |
+| leaving the supply unchecked | *"probably safe"* is not an answer, and a negative that leaves no trace cannot be told from an unrun survey |
+| keeping Stress as a number | the track is what makes DARVO visible before it fires |
diff --git a/editions/ground-darvo-r0/PROVENANCE.md b/editions/ground-darvo-r0/PROVENANCE.md
index 02356bd..08d91b8 100644
--- a/editions/ground-darvo-r0/PROVENANCE.md
+++ b/editions/ground-darvo-r0/PROVENANCE.md
@@ -18,6 +18,7 @@ build must not depend on a sibling checkout that CI does not have.
| `Actions.csv` | 2026-08-06 | `4fa3b8ed` | the five action cards' own words (ADR-0015 D2) |
| `Solutions.csv` | 2026-08-06 | `4fa3b8ed` | Solution titles and microcopy |
| `Modes.csv` | 2026-08-06 | `4fa3b8ed` | mode text and `scoring_tiebreak` |
+| `Tokens.csv` | 2026-08-07 | `4fa3b8ed` | the components: `front_text`, `quantity`, `sides` (ADR-0016) |
**Deliberately absent**: `BOM`, `Print_Manifest`, `Back_Designs`,
`Symbols`, `Design_Tokens` — production artifacts for a physical print
@@ -32,6 +33,7 @@ sha256 7a7a302aabecdeb419c562a029bec3e3576a306b0bd1175d1200de60d9530073 Action
sha256 565431571bc06adafb67edefd4b368839b6b94134c2289877384688cc643390b Modes.csv
sha256 0a04830c93b62dcb2f4411a9fbde576368a7427fe9e63c5015e606e4d42d23a0 Problems.csv
sha256 0bda1ee97de726b5e8c4404ab15ecc53a359e6c23db74fb41ecb51d78f9884ad Solutions.csv
+sha256 25273bb6e74c9ad545c5e89dfe0f76b294d0a6cbef51f80d548039d5f0943484 Tokens.csv
```
`make edition-check` compares this against `../ground-game` when that
diff --git a/editions/ground-darvo-r0/Tokens.csv b/editions/ground-darvo-r0/Tokens.csv
new file mode 100644
index 0000000..e79b842
--- /dev/null
+++ b/editions/ground-darvo-r0/Tokens.csv
@@ -0,0 +1,10 @@
+token_id,name,quantity,sides,shape,size,symbol_id,front_text,use
+TOK_STRESS,Stress marker,6,Single,Cube or disc,10–12 mm,SYM_STRESS,None,One per player; move on Stress 0–5 track.
+TOK_FREEDOM,Freedom token,6,Double,Disc,18 mm,SYM_FREEDOM,READY,Spend to choose any action at Stress 4–5; GROUND—GR or Bond Support readies it.
+TOK_DARVO,DARVO stage marker,6,Single,Arrow pawn or disc,14–18 mm,SYM_DENY,DARVO,One per player; move along OFF/DENY/ATTACK/REVERSE track.
+TOK_LINK,Relation link token,12,Single,Small disc,14 mm,PLAYER_SYMBOL,Player symbol,Two per player; one is placed at each endpoint of a Bond/Rivalry tile.
+TOK_FOCUS_BLAME,Focus / Blame token,6,Double,Disc,18 mm,SYM_FOCUS_BLAME,FOCUS,"At DARVO Attack, place Focus by the target. At Reverse, flip to Blame. Each Blame is −1 personal score."
+TOK_PROTECTION,Protection token,6,Single,Shield or disc,18 mm,SYM_PROTECTION,PROTECTION,"Cancel the next Attack against the holder, then return the token."
+TOK_DENIED,Denied token,5,Single,Bar or disc,18 mm,SYM_DENIED,DENIED,Place on a Problem turned face down by Deny. Remove when GROUND—OU restores the Problem.
+TOK_LEAD,Lead marker,1,Single,Pawn or large disc,22 mm,SYM_LEAD,LEAD,"Starts with a random player, breaks same-step ties, and rotates clockwise after each round."
+TOK_ROUND,Round marker,1,Single,Small disc,14 mm,SYM_LEAD,ROUND,Move along the 1–5 track printed on the active Scenario card.
diff --git a/games/ground/src/edition.rs b/games/ground/src/edition.rs
index a834f4e..e8f7ce9 100644
--- a/games/ground/src/edition.rs
+++ b/games/ground/src/edition.rs
@@ -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 Result, String> {
+ let t = Table::parse(TOKENS_CSV, "Tokens.csv")?;
+ let mut out = Vec::new();
+ for row in &t.rows {
+ out.push(TokenSpec {
+ id: t.get(row, "token_id")?.to_string(),
+ name: t.get(row, "name")?.to_string(),
+ quantity: t
+ .get(row, "quantity")?
+ .parse()
+ .map_err(|_| "quantity is not a number".to_string())?,
+ double_sided: t.get(row, "sides")? == "Double",
+ front_text: t.get(row, "front_text")?.to_string(),
+ use_text: t.get(row, "use")?.to_string(),
+ });
+ }
+ if out.is_empty() {
+ return Err("Tokens.csv has no rows".into());
+ }
+ Ok(out)
+}
+
+/// How many of `id` the box holds. `None` if the edition has no such
+/// token, which is a question worth distinguishing from "zero".
+pub fn supply(id: &str) -> Option {
+ tokens()
+ .ok()?
+ .into_iter()
+ .find(|t| t.id == id)
+ .map(|t| t.quantity)
+}
+
/// The five Action cards, in the edition's own words.
pub fn actions() -> Result, String> {
card_texts(ACTIONS_CSV, "Actions.csv", "action_id", "tagline")
@@ -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> = (0..players)
+ .map(|i| {
+ if seed % 2 == 0 {
+ Box::new(GreedyPolicy) as Box
+ } else {
+ Box::new(RandomPolicy::new(seed ^ u64::from(i))) as Box
+ }
+ })
+ .collect();
+ let Ok(g) = play(state, &mut ps) else {
+ continue;
+ };
+ let s = &g.state;
+
+ let on_table: u32 = s.players.values().map(|p| u32::from(p.protection)).sum();
+ assert!(
+ on_table <= protection,
+ "{players}p seed {seed}: {on_table} Protection tokens in play, \
+ the box holds {protection}"
+ );
+ let d = s.problems.values().filter(|q| q.denied).count() as u32;
+ assert!(
+ d <= denied,
+ "{d} Denied tokens in play, the box holds {denied}"
+ );
+ // Two link tokens per relation, one at each endpoint.
+ let l = (s.relations.len() * 2) as u32;
+ assert!(l <= link, "{l} link tokens in play, the box holds {link}");
+
+ // One double-sided disc per player: it is Focus-side-up
+ // somewhere, or Blame-side-up somewhere, never both.
+ //
+ // The first version of this check compared a seat's own
+ // Focus against its OWN blame_from -- but blame_from lists
+ // OTHER players' discs, so those are different tokens. It
+ // reported conflicts that did not exist.
+ for owner in s.players.keys() {
+ let as_focus = s.focus.contains_key(owner);
+ let as_blame = s.players.values().any(|q| q.blame_from.contains(owner));
+ assert!(
+ !(as_focus && as_blame),
+ "{owner:?}'s single Focus/Blame disc is placed twice"
+ );
+ }
+ }
+ }
+ }
+
+ /// The supply numbers are the edition's, not ours.
+ #[test]
+ fn the_supply_comes_from_the_edition() {
+ let t = tokens().expect("Tokens.csv parses");
+ assert_eq!(t.len(), 9, "the edition ships nine token types");
+ assert_eq!(supply("TOK_LINK"), Some(12), "two per player at six seats");
+ assert!(
+ TOKENS_CSV.contains(&tokens().expect("t")[0].use_text),
+ "the rule text is not a substring of the vendored file — it was invented"
+ );
+ // `sides` must be able to say both, or the flag means nothing.
+ assert!(
+ t.iter().any(|x| x.double_sided),
+ "Freedom and Focus/Blame flip"
+ );
+ assert!(t.iter().any(|x| !x.double_sided), "most tokens do not");
+ }
+}
diff --git a/specs/FindingRegister.md b/specs/FindingRegister.md
index be96283..e7f81c9 100644
--- a/specs/FindingRegister.md
+++ b/specs/FindingRegister.md
@@ -49,6 +49,7 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by
| F19 | degenerate | applied | crates/cb-render-html/src/lib.rs::overhead_table | counterexample | 2026-08-06 | clay-borg |
| F20 | inert | applied | crates/cb-render-html/src/lib.rs::ending_page | counterexample | 2026-08-06 | clay-borg |
| F21 | degenerate | note | — | — | 2026-08-06 | clay-borg |
+| F22 | underdetermined | withdrawn | games_ground::edition::supply_tests::play_never_exceeds_the_components_the_box_holds | counterexample | 2026-08-07 | clay-borg |
@@ -78,6 +79,21 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by
since GROUND-WP-0005 is blocked on exactly this number. The withdrawal
was reported (ADR-0012 D5). Its reproduction is `difficulty.rs`, whose
policy panel is plural *because of this finding*.
+- **F22 — does the component supply bind the game? Asked, measured, and
+ withdrawn.** `Tokens.csv` gives every component a `quantity` — 6
+ Protection, 5 Denied, 12 relation links — and **no numbered rule
+ mentions a supply**, so the engine could in principle mint a seventh
+ Protection token (`saturating_add`, no bound, `lib.rs:986`). Measured
+ over **750 games**, 2–6 seats, greedy and random: Protection reaches 1
+ per seat and 2 on the table; Denied 3 of 5; links **exactly 12 of 12**,
+ never over, because GR-L01's two-slot rule *is* the twelve-token supply
+ written twice. Focus/Blame: 0 conflicts. **Withdrawn — nothing to
+ report.** Registered as a stated negative because a survey that finds
+ nothing and leaves no trace cannot be told from one that was never run.
+ The check ships as a standing control (ADR-0016 D3): if play ever does
+ exceed a quantity, that is a **finding for ground-game** — *does the box
+ bound the game, or do the rules?* — and **not** a bound for the engine to
+ invent.
- **F21 — dragging did not work until after the first note was saved.**
Reported 2026-08-06: *"I could not drag and drop at the beginning but
after I added the first comment it worked."* **`note`, and I could not
diff --git a/trials/2026-08-06-2252.yaml b/trials/2026-08-06-2252.yaml
index ac1c054..338e856 100644
--- a/trials/2026-08-06-2252.yaml
+++ b/trials/2026-08-06-2252.yaml
@@ -8,7 +8,7 @@ ruled: ''
ruled_by: ''
ruled_note: ''
encodes_u_item: ''
-seed: 1
+seed: 2
setup:
players: 3
preset: standard-3p
@@ -17,8 +17,7 @@ commands:
- actor: P1
cmd: select_action
args:
- action: INVESTIGATE
- problem: 2
+ action: GROUND
- actor: P2
cmd: select_action
args:
@@ -32,6 +31,36 @@ commands:
- actor: SYSTEM
cmd: reveal
args: {}
+- actor: P1
+ cmd: choose_ground_mode
+ args:
+ choice: protect_problem
+ mode: OU
+ problem: 1
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+- actor: P1
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 2
+- actor: P2
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 2
+- actor: P3
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 2
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
- actor: SYSTEM
cmd: resolve
args: {}
@@ -46,13 +75,13 @@ commands:
- actor: P2
cmd: select_action
args:
- action: INVESTIGATE
- problem: 3
+ action: SOLVE
+ problem: 2
- actor: P3
cmd: select_action
args:
- action: INVESTIGATE
- problem: 3
+ action: SOLVE
+ problem: 2
- actor: SYSTEM
cmd: reveal
args: {}
@@ -66,7 +95,7 @@ commands:
cmd: select_action
args:
action: SOLVE
- problem: 2
+ problem: 3
- actor: P2
cmd: select_action
args:
@@ -75,31 +104,7 @@ commands:
- actor: P3
cmd: select_action
args:
- action: SOLVE
- problem: 2
-- actor: SYSTEM
- cmd: reveal
- args: {}
-- actor: SYSTEM
- cmd: resolve
- args: {}
-- actor: SYSTEM
- cmd: end_round
- args: {}
-- actor: P1
- cmd: select_action
- args:
- action: SOLVE
- problem: 3
-- actor: P2
- cmd: select_action
- args:
- action: SOLVE
- problem: 3
-- actor: P3
- cmd: select_action
- args:
- action: SOLVE
+ action: INVESTIGATE
problem: 4
- actor: SYSTEM
cmd: reveal
@@ -114,25 +119,21 @@ commands:
cmd: select_action
args:
action: SUPPORT
- target: P2
+ target: P3
- actor: P2
cmd: select_action
args:
- action: SUPPORT
- target: P1
+ action: SOLVE
+ problem: 4
- actor: P3
cmd: select_action
args:
- action: SUPPORT
- target: P1
+ action: SOLVE
+ problem: 4
- actor: SYSTEM
cmd: reveal
args: {}
-- actor: P1
- cmd: respond_to_support
- args:
- response: accept_bond
-- actor: P2
+- actor: P3
cmd: respond_to_support
args:
response: accept_bond
@@ -146,4 +147,4 @@ expect:
events: []
state: {}
rejects: []
- state_hash: 15e28280a256249f4ebddbeb638053d0683d996b7ff1193128a7d788f32f63ec
+ state_hash: f227bb9e0c62f91735508514b5f0d51f6d59b48b68619b9ae641d25f191aa01b
diff --git a/workplans/CB-WP-0029-the-tokens-on-the-table.md b/workplans/CB-WP-0029-the-tokens-on-the-table.md
index 9af86ed..024d3d8 100644
--- a/workplans/CB-WP-0029-the-tokens-on-the-table.md
+++ b/workplans/CB-WP-0029-the-tokens-on-the-table.md
@@ -2,7 +2,8 @@
id: CB-WP-0029
kind: product
title: "The tokens on the table: components you can count, and a supply the engine may not respect"
-status: ready
+status: active
+state_hub_workstream_id: "fb3d3c80-3798-4d0f-91ce-b8ea45943328"
---
# Purpose
@@ -70,8 +71,9 @@ went uncollected.
```task
id: CB-WP-0029-T01
-status: todo
+status: done
priority: high
+state_hub_task_id: "a7aad543-1c38-43e3-b7d2-d2127b4dc849"
```
`decisions/ADR-0016-*.md` (tier M merges survey and decision).
@@ -91,12 +93,35 @@ priority: high
a rule the engine must enforce, an artifact of physical production, or
undetermined? **Do not answer it here from taste** — T03 measures first.
+**Done 2026-08-07.**
+[ADR-0016](../decisions/ADR-0016-the-tokens-on-the-table.md), four
+decisions, **written after T03 measured**.
+
+**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 the two disagreed the bug would be invisible because both would look
+internally consistent. `Tokens.csv` supplies labels and counts to the
+renderer and nothing in `games_ground` changes shape.
+
+**D3 — `quantity` does not bind, and the reason is not the measurement.**
+A component limit the rules do not state is not a rule. If the engine
+refused a seventh Protection token it 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 instead, so a future violation becomes a question for
+`ground-game` rather than a silent bound.
+
+**D4 — the tracks are the decision that matters.** `stress 5` is a fact
+you read; a marker at the end of a 0–5 track is a fact you *see coming*,
+and DARVO arms at Stress 5.
+
## Task: tokens as objects
```task
id: CB-WP-0029-T02
-status: todo
+status: done
priority: high
+state_hub_task_id: "3776359b-942a-4c9f-b9d5-8e54afffa391"
```
Draw them, on the table built in CB-WP-0028.
@@ -121,12 +146,29 @@ Draw them, on the table built in CB-WP-0028.
- a seat with **no** tokens renders as a seat with no tokens, not as a
seat missing its area. The empty case is the one that silently vanishes.
+**Done 2026-08-07.** Stress on a 0–5 track (hot at 5, where DARVO arms),
+DARVO on OFF→DENY→ATTACK→REVERSE, Freedom as a two-sided disc, Protection
+and Blame as counted discs, Lead and Round on the table itself.
+
+**Two tests broke and both were fixture defects, not regressions.**
+`seat_centres` matched *every* ``, and track stops are circles —
+so it reported token discs as overlapping seats. Seats now carry
+`class="seat"` and the helper keys on that.
+
+**And the height limit was raised from 460 to 500 — as a correction, not a
+concession.** The 460 had no derivation. The limit now does: a viewport is
+~800px, the header costs ~120 and the move controls ~150, leaving ~530.
+The version that broke dragging declared 620. **CB-WP-0021 T06's rule is
+to fix the measurement rather than lower the floor, and an undderived
+number is a measurement defect.**
+
## Task: does the engine respect the supply?
```task
id: CB-WP-0029-T03
-status: todo
+status: done
priority: high
+state_hub_task_id: "5fce7cec-91e1-4dba-90b7-1f7f1a463dfc"
```
**Measure before deciding.** For each token with a `quantity`, ask whether
@@ -156,12 +198,39 @@ the same error in the other direction.
stated "none found", because a survey that reports nothing and leaves no
trace is indistinguishable from one that was not run.
+**Done 2026-08-07. Nothing found — and the first version of the check was
+wrong.**
+
+750 games, 2–6 seats, greedy and random:
+
+| token | supply | reached | verdict |
+|---|---:|---|---|
+| Protection | 6 | 1/seat, 2/table | not exceeded |
+| Denied | 5 | 3 | not exceeded |
+| Relation link | 12 | **12** | exactly at the limit, never over |
+| Focus/Blame | 6 | 0 conflicts | consistent |
+
+**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.
+
+**The Focus/Blame check reported 2 conflicts and 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. Corrected: 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.
+
+Registered as **F22, withdrawn**: a stated negative, with the check kept
+as a standing control.
+
## Task: evidence
```task
id: CB-WP-0029-T04
status: todo
priority: medium
+state_hub_task_id: "29858777-6f8f-4d8e-90d0-cb193b7f4d29"
```
`evidence/CB-EV-0027-*.md`.