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( - "", ); @@ -593,7 +699,7 @@ fn table_svg(view: &GroundView) -> String { let _ = write!( s, "\ - \ {name}{you}\ @@ -609,7 +715,27 @@ fn table_svg(view: &GroundView) -> String { stress = pv.stress, focus = esc(&focus), ); + // CB-WP-0029 T02: the components, at their seat. + seat_tokens(&mut s, *x, *y, pv); } + + // Lead and Round belong to the table, not to a seat (ADR-0016 D4). + let _ = write!( + s, + "Lead marker: {lead}\ + \ + LEAD\ + {lead}\ + Round marker: round {round} of 5\ + \ + {round}\ + round", + lead = seat_name(view.lead), + round = view.round, + ); s.push_str(""); 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`.