From 1edb10d5a3ec4f9a998fd203b856ff10f4611903 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 5 Aug 2026 17:23:09 +0200 Subject: [PATCH 1/2] CB-WP-0024 T01/T02: the ending control says what it does, and the piles are objects T01. The maintainer asked why the button says "I need to read this" and why nothing closes. Two defects behind one control: the label described a reading while the control STOPS THE SERVER (hotseat.rs reads `done` and breaks its loop), and acknowledging it changed nothing on screen -- the tab kept a live table and a `play again` pointing at a closed port. Label is now "end session -- stops the game server". The reply says the session has ended and the tab can be closed. The script seals the page on a `closed` reply: removeAttribute('data-drop') on every control, so they stop being droppable by the same rule that made them droppable. CSS is how that reads, not the mechanism. removeAttribute rather than setAttribute(_, null) -- the latter writes the truthy string "null" in a browser, so the control would stay live while the stub called it sealed. THE REPLY PATH HAD NEVER BEEN EXECUTABLE IN A TEST. jsrun's fetch stub returned {then: function(){return this}}, which never invoked its callbacks, so every line of the script reacting to the server was unreachable from every test in this project. That is why the defect survived: a page ignoring the server looked identical to one acting on it. The stub now delivers a real then-chain and gesture_with_reply reports which controls survive. The seal is mutation-proven -- deleting the `closed` branch turns exactly one test red -- and a negative control asserts an `ok: dealing` reply does NOT seal, since a seal that fired on every reply would pass the first test and break `play again`. T02. Draw and discard drawn as offset stacks with their counts. The shuffle question the task required answering is settled and the answer is that it already works: games/ground/src/lib.rs:1419-1435 implements the U4 default -- deterministic reshuffle of the discard seeded from seed ^ round, skip the draw if both are empty -- and ground-game CONFIRMED U4 on 2026-08-03. A ruled rule, not an invented one, nothing to raise. The event already reads out in the log; what the piles add is the state before it fires, which is derivable from the view. A claim that a reshuffle HAS happened would not be, and is not made. The coverage gate caught its own probe going stale when the "17 remaining" text was replaced. The count now lives in the pile's -- a stable probe and what a screen reader announces, where the on-canvas numeral could be any number on the page. 39 tests pass; cb-play 22 including play_again_deals_a_second_game. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- crates/cb-render-html/src/doc.rs | 147 ++++++++++++++++- crates/cb-render-html/src/jsrun.rs | 88 +++++++++- crates/cb-render-html/src/lib.rs | 153 +++++++++++++++++- tools/cb-play/src/hotseat.rs | 12 +- .../CB-WP-0024-the-table-you-can-watch.md | 58 ++++++- 5 files changed, 443 insertions(+), 15 deletions(-) diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 533521e..4e3de65 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -79,6 +79,29 @@ pub const SCRIPT: &str = r#" function key(n) { return n ? n.getAttribute('data-drop') : null; } function status(t) { document.getElementById('cb-status').textContent = t; } + // The session has ended server-side, so nothing on this page can work + // any more -- and nothing on it may look like it can (CB-WP-0024 T01). + // + // The old page kept a full table and a live `play again` control after + // the server had stopped listening, because the script acted only on + // 'ok'. A control that cannot work must stop being a control, so the + // `data-drop` attribute is REMOVED rather than styled: that is the + // attribute the script's own walk reads, so the element becomes + // undroppable by the same rule that made it droppable. + // + // No game vocabulary here (ADR-0007 D5). 'closed' is a server + // lifecycle word, exactly like the 'ok' branch below it. + function seal() { + var all = document.querySelectorAll('[data-drop]'); + for (var i = 0; i < all.length; i++) { + all[i].classList.add('sealed'); + // removeAttribute, NOT setAttribute(_, null): the latter writes the + // literal string "null" in a real browser, which is truthy, so the + // control would stay droppable while the test stub said otherwise. + all[i].removeAttribute('data-drop'); + } + } + // ADR-0010 D1: the destinations come from `data-targets`, which Rust // wrote. This matches on them. It does not compute, infer, filter or // default one -- a script that pattern-matched ids to guess what is @@ -175,6 +198,7 @@ pub const SCRIPT: &str = r#" }).then(function (r) { return r.text(); }).then(function (t) { status(t); if (t.indexOf('ok') === 0) { window.location.reload(); } + else if (t.indexOf('closed') === 0) { seal(); } }); }); })(); @@ -225,6 +249,12 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf} #cb-ghost .why{color:#9cf;margin-left:.4rem} /* What you picked up, left visibly behind so there are not two cards. */ .held{cursor:grabbing;opacity:.35;border-style:dashed} +/* CB-WP-0024 T01: a control the server can no longer serve. The script + removes its `data-drop` so it is genuinely inert; this is only how that + reads. `pointer-events:none` is belt and braces, not the mechanism -- + styling alone would leave a dead control that still looks alive to + anything reading the DOM. */ +.sealed{opacity:.3;cursor:default;pointer-events:none;filter:grayscale(1)} .k{color:#89a} .nil{color:#c88} .eff{color:#8c9} @@ -297,6 +327,112 @@ fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) { /// The relationship graph — the one element every Rust 2D toolkit would /// have left us to hand-roll, and the reason SVG earns its place here /// rather than merely fitting the budget (CB-RES-0006 §5). +/// One pile, drawn as a small stack of offset cards with its count on top. +/// +/// `depth` is how many card-backs to suggest, not the count — a pile of 17 +/// is not seventeen rectangles. The **number** is the truth; the stack is +/// how you tell at a glance that there is a pile there at all. +fn pile_svg(out: &mut String, x: i32, label: &str, count: usize, face: &str, note: &str) { + let depth = match count { + 0 => 0, + 1..=3 => 1, + 4..=9 => 2, + _ => 3, + }; + // The title carries the count in words. It is what a screen reader + // announces, and it is the stable thing a coverage probe can match -- + // the on-canvas number is a bare numeral that could be anything. + let _ = write!( + out, + "<g><title>{} pile: {count} remaining", + esc(label), + ); + if depth == 0 { + // An EMPTY pile is drawn, not omitted. A missing slot reads as + // "this game has no discard", which is a different statement from + // "the discard is empty" (CB-WP-0024 T02). + let _ = write!( + out, + "", + ); + } + for d in (0..depth).rev() { + let dx = x + (d as i32) * 3; + let dy = 14 - (d as i32) * 3; + let _ = write!( + out, + "", + ); + } + let _ = write!( + out, + "{count}\ + {label}\ + {note}", + tx = x + 38, + count = count, + label = esc(label), + note = esc(note), + ); +} + +/// The draw stack and the discard stack, as objects on the table. +/// +/// Both numbers come from the projection — `solution_deck_len` and +/// `solution_discard` — and are never recomputed here. +/// +/// **The reshuffle is real and is the U4 default**, confirmed by +/// ground-game on 2026-08-03: when the deck runs out, the discard is +/// reshuffled into it deterministically; if both are empty the draw is +/// skipped (`games/ground/src/lib.rs` `draw_solution`). So the pile shows +/// the state in which the *next* draw will trigger it. It does not claim a +/// reshuffle has happened — the view carries no such flag, and the event +/// already reads out in the log. +fn piles_svg(out: &mut String, view: &GroundView) { + let deck = view.solution_deck_len; + let discard = view.solution_discard.len(); + let will_reshuffle = deck == 0 && discard > 0; + + out.push_str(""); + pile_svg( + out, + 10, + "draw", + deck, + "#26303a", + if will_reshuffle { "empty" } else { "" }, + ); + pile_svg( + out, + 120, + "discard", + discard, + "#2a2f3a", + if will_reshuffle { + "shuffles in on next draw" + } else { + "" + }, + ); + if will_reshuffle { + out.push_str( + "\ + ", + ); + } + out.push_str(""); + + // The discard is public — it has been played (GR-S04 hides only the + // deck) — so its contents stay readable as text beside the picture. + let _ = write!( + out, + "
discard {}
", + esc(&cards(&view.solution_discard)), + ); +} + fn relations_svg(view: &GroundView) -> String { let n = view.players.len().max(1); let (cx, cy, r) = (200.0f64, 150.0f64, 110.0f64); @@ -570,13 +706,8 @@ fn body(s: &mut String, view: &GroundView) { } s.push_str(""); - let _ = write!( - s, - "

solutions

deck {} remaining \ - discard {}
", - view.solution_deck_len, - esc(&cards(&view.solution_discard)), - ); + s.push_str("

solutions

"); + piles_svg(s, view); if let Some(o) = &view.outcome { let personal = o @@ -822,7 +953,7 @@ pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[L s, "
\
play again
\ -
close \u{2014} I have read this
\ +
end session \u{2014} stops the game server
\
\
the game is over
\ ", diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs index 40d46ac..82b9bcf 100644 --- a/crates/cb-render-html/src/jsrun.rs +++ b/crates/cb-render-html/src/jsrun.rs @@ -79,6 +79,11 @@ function __mk(key, targets, text, descs) { 'data-descs': descs || null }, getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; }, setAttribute: function (a, v) { this._attr[a] = v; }, + // A real browser's removeAttribute. The stub must have it so the + // script can use it instead of `setAttribute(a, null)`, which writes + // the truthy string "null" in a browser but a real null here -- a + // difference that would make a broken seal pass its own test. + removeAttribute: function (a) { delete this._attr[a]; }, classList: { add: function (c) { n._cls[c] = true; }, remove: function (c) { delete n._cls[c]; }, @@ -97,10 +102,25 @@ var document = { querySelectorAll: function (_sel) { return __all; } }; var window = { location: { reload: function () { __reloaded(); } } }; + +// The server's REPLY, delivered synchronously through the then-chain. +// +// CB-WP-0024 T01: the old stub returned `{then: function(){return this}}`, +// which never invoked the callbacks -- so every line of the script that +// reacts to what the server said was unreachable in every test. That is +// why "the page still looks live after the session ends" survived: the +// branch that would have fixed it could not be executed here. +// +// `__reply` is set per run. Chained `.then(fn)` passes fn's return value +// on, matching the promise semantics the script relies on (`r.text()` +// then the text). +var __reply = ""; function fetch(url, opts) { __post(url, (opts && opts.body) || ""); - var chainable = { then: function () { return chainable; } }; - return chainable; + function chain(v) { + return { then: function (fn) { return chain(fn ? fn(v) : v); } }; + } + return chain({ text: function () { return __reply; } }); } // Gestures are driven against REGISTERED nodes, so the script's walk, its @@ -136,6 +156,19 @@ function __heldKeys() { return out.sort().join(','); } function __ghosts() { return __body.children.length; } + +// What the page still offers AFTER the reply was handled. A control the +// script has sealed must stop being droppable, not merely look different +// -- so this reads the same attribute the script's own walk reads. +function __liveKeys() { + var out = []; + for (var i = 0; i < __all.length; i++) { + var k = __all[i].getAttribute('data-drop'); + if (k) { out.push(k); } + } + return out.sort().join(','); +} +function __statusText() { return __status.textContent; } "#; /// Run the document's scripts, then a pointer gesture, and report what @@ -183,6 +216,57 @@ pub fn gesture(html: &str, down: &str, up: &str) -> Result, String> Ok(out) } +/// What the page LOOKS LIKE after a gesture whose reply was `reply`. +/// +/// Returns `(live drop keys, status text)` — the keys still droppable +/// once the script has handled the server's answer. +/// +/// **This path did not exist before CB-WP-0024 T01, and that is why the +/// defect it tests survived.** The fetch stub used to return a +/// `then`-chain that never called its callbacks, so nothing the script +/// does in response to the server was reachable from any test. A page +/// that ignores what it was told looked identical to one that acts on it. +pub fn gesture_with_reply( + html: &str, + down: &str, + up: &str, + reply: &str, +) -> Result<(Vec, String), String> { + let ctx = quick_js::Context::new().map_err(|e| format!("quickjs init: {e}"))?; + ctx.add_callback("__post", |_url: String, _body: String| 0i32) + .map_err(|e| format!("register __post: {e}"))?; + let reloaded = Arc::new(Mutex::new(false)); + let flag = reloaded.clone(); + ctx.add_callback("__reloaded", move || { + *flag.lock().expect("reloaded") = true; + 0i32 + }) + .map_err(|e| format!("register __reloaded: {e}"))?; + + prepare(&ctx, html)?; + ctx.eval(&format!("__reply = {};", json_lit(reply))) + .map_err(|e| format!("set reply: {e}"))?; + ctx.eval(&format!( + "__down({}); __up({});", + json_lit(down), + json_lit(up) + )) + .map_err(|e| format!("gesture: {e}"))?; + + let keys: String = ctx + .eval_as("__liveKeys()") + .map_err(|e| format!("live keys: {e}"))?; + let status: String = ctx + .eval_as("__statusText()") + .map_err(|e| format!("status: {e}"))?; + let keys = if keys.is_empty() { + Vec::new() + } else { + keys.split(',').map(str::to_string).collect() + }; + Ok((keys, status)) +} + /// Every droppable in the document, as `(data-drop, data-targets)`. /// /// The stub's node set is built from the **real emitted page**, not from a diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 03b74e7..7dd0f36 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -128,7 +128,7 @@ mod coverage { ("step", "step Select"), ("mode", "scoring BondedCoalitions"), ("viewer", "viewing as P1"), - ("solution_deck_len", "17 remaining"), + ("solution_deck_len", "draw pile: 17 remaining"), ("solution_discard.*.suit", "discard Repair Change"), ("players.*.stress", "stress 5"), ("players.*.protection", "protect 2"), @@ -630,5 +630,156 @@ mod gamelog { } } +/// CB-WP-0024 T02 — the draw and discard stacks as objects on the table. +/// +/// The maintainer asked for the piles to be visible and for the discard +/// to show a shuffle when the draw runs out. **The reshuffle is real** — +/// `games/ground/src/lib.rs::draw_solution` implements the U4 default +/// (deterministic reshuffle of the discard; skip the draw if both are +/// empty), which ground-game confirmed on 2026-08-03. So this renders the +/// state in which the next draw triggers it, rather than inventing a rule. +#[cfg(test)] +mod piles { + use cb_kernel::PlayerId; + use games_ground::view::GroundView; + + use crate::doc::document; + + fn view() -> GroundView { + crate::testfix::view(Some(PlayerId(0))) + } + + fn html(v: &GroundView) -> String { + document(v, &[], "/command?t=x", Some(PlayerId(0)), false) + } + + /// The counts must come from the projection, never be recomputed. + #[test] + fn both_counts_are_the_views_own_numbers() { + let mut v = view(); + v.solution_deck_len = 5; + let doc = crate::text_of(&html(&v)); + assert!( + doc.contains("draw pile: 5 remaining"), + "the drawn deck count is not the view's: {doc}" + ); + let n = v.solution_discard.len(); + assert!( + doc.contains(&format!("discard pile: {n} remaining")), + "the drawn discard count is not the view's ({n}): {doc}" + ); + } + + /// An empty discard is an EMPTY pile, not a missing one. A absent slot + /// reads as "this game has no discard", which is a different claim. + #[test] + fn an_empty_pile_is_drawn_rather_than_omitted() { + let mut v = view(); + v.solution_discard.clear(); + let doc = crate::text_of(&html(&v)); + assert!( + doc.contains("discard pile: 0 remaining"), + "an empty discard vanished instead of rendering as empty: {doc}" + ); + } + + /// The U4 state: deck empty, discard holding cards. The next draw + /// reshuffles, and the table should say so. + #[test] + fn an_exhausted_deck_says_the_discard_shuffles_back_in() { + let mut v = view(); + v.solution_deck_len = 0; + assert!(!v.solution_discard.is_empty(), "fixture needs a discard"); + let doc = crate::text_of(&html(&v)); + assert!( + doc.contains("shuffles in on next draw"), + "an exhausted deck did not announce the U4 reshuffle: {doc}" + ); + + // And the negative half: with cards left, no shuffle is promised. + let mut full = view(); + full.solution_deck_len = 12; + assert!( + !crate::text_of(&html(&full)).contains("shuffles in on next draw"), + "a stocked deck claimed a reshuffle was coming" + ); + } +} + +/// CB-WP-0024 T01 — the ending page's one control. +/// +/// The maintainer reported it as *"the button says 'I need to read this' +/// — why? the UI is not closing."* Two defects wearing one button: the +/// label described a reading while the control stopped a server, and +/// acknowledging it changed nothing on screen, leaving a live-looking +/// table and a `play again` pointing at a closed port. +#[cfg(test)] +mod ending_page { + use crate::{doc, jsrun}; + + fn page() -> String { + doc::ending(None, "the game ended", "/command?t=x", &[]) + } + + /// The label must say what the control DOES. Asserted on the rendered + /// text so reverting the wording turns this red — a comment would not. + #[test] + fn the_control_is_labelled_by_its_effect_not_by_a_reading() { + let text = crate::text_of(&page()); + assert!( + text.contains("end session") && text.contains("stops the game server"), + "the ending control must name its effect: {text}" + ); + assert!( + !text.contains("I have read this"), + "the label claimed the player had read something; it stops a server" + ); + } + + /// The defect the maintainer actually saw. After the server says the + /// session is closed, `play again` must stop being offered — it now + /// points at a port nobody is listening on. + #[test] + fn acknowledging_the_end_stops_the_page_offering_anything() { + let html = page(); + let before = doc::drop_keys(&html); + assert!( + before.contains("again") && before.contains("done"), + "fixture must start with both controls: {before:?}" + ); + + let (live, status) = + jsrun::gesture_with_reply(&html, "done", "done", "closed — the session has ended") + .expect("run the page"); + + assert!( + !live.contains(&"again".to_string()), + "`play again` survived the session ending and would post to a closed port: {live:?}" + ); + assert!( + live.is_empty(), + "every control must be sealed once the server stops, not only `again`: {live:?}" + ); + assert!( + status.contains("session has ended"), + "the page must say what happened: {status:?}" + ); + } + + /// The negative control. If `seal` fired on any reply, this test would + /// pass for a page that tears itself down whenever it is touched — + /// which would break `play again` in the ordinary case. + #[test] + fn a_dealing_reply_leaves_the_controls_alone() { + let html = page(); + let (live, _) = + jsrun::gesture_with_reply(&html, "again", "again", "ok: dealing").expect("run the page"); + assert!( + live.contains(&"again".to_string()) && live.contains(&"done".to_string()), + "an 'ok' reply must not seal the page: {live:?}" + ); + } +} + #[cfg(test)] mod testfix; diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index b856215..1385e96 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -249,7 +249,17 @@ impl Server { &mut stream, 200, "text/plain", - if again { "ok: dealing" } else { "closed" }, + // CB-WP-0024 T01: the reply is what the player + // reads. "closed" alone left them looking at a + // live table wondering why nothing happened. + // The `closed` prefix is what the page's script + // matches on to seal itself. + if again { + "ok: dealing" + } else { + "closed \u{2014} the session has ended and the server has stopped. \ + You can close this tab." + }, ); break Ok(if again { EndChoice::Again diff --git a/workplans/CB-WP-0024-the-table-you-can-watch.md b/workplans/CB-WP-0024-the-table-you-can-watch.md index 6d65391..54208b8 100644 --- a/workplans/CB-WP-0024-the-table-you-can-watch.md +++ b/workplans/CB-WP-0024-the-table-you-can-watch.md @@ -2,7 +2,7 @@ id: CB-WP-0024 kind: product title: "The table you can watch: the piles, the other seats' moves, the score that carries" -status: ready +status: active state_hub_workstream_id: "5f17b6f9-cd4b-4c31-a8af-712313149cf2" --- @@ -47,7 +47,7 @@ except where task T04 says so explicitly. ```task id: CB-WP-0024-T01 -status: todo +status: done priority: high state_hub_task_id: "acb4231c-35df-490f-93fd-be71c0abf1dc" ``` @@ -75,11 +75,36 @@ because a control that can no longer work must not look like it can. - the label change must be mutation-visible: assert the rendered text, so reverting the wording turns a test red. +**Done 2026-08-05.** Label is now `end session — stops the game server`; +the reply is `closed — the session has ended and the server has stopped. +You can close this tab.`; and the script **seals** the page on a `closed` +reply — `removeAttribute('data-drop')` on every control, so they stop +being droppable by the same rule that made them droppable. `sealed` CSS is +how that reads, not the mechanism. + +**`removeAttribute`, not `setAttribute(_, null)`** — the latter writes the +literal string `"null"` in a browser, which is truthy, so the control +would stay live while the stub reported it sealed. + +**The reply path had never been executable in a test.** `jsrun`'s `fetch` +stub returned `{then: function(){return this}}`, which never invoked its +callbacks — so every line of the script that reacts to what the server +said was unreachable from every test in the project. **That is why this +defect survived**: a page that ignores the server looked identical to one +that acts on it. The stub now delivers a real then-chain, and +`gesture_with_reply` reports the page's surviving controls. + +Three tests, and the seal is mutation-proven: deleting the `closed` branch +turns exactly one red. The negative control (`ok: dealing` must NOT seal) +exists because a seal that fired on every reply would pass the first test +and break `play again`. `cb-play`: 22 passed, including +`play_again_deals_a_second_game`. + ## Task: the piles are objects on the table ```task id: CB-WP-0024-T02 -status: todo +status: done priority: high state_hub_task_id: "37b16a87-e791-4c85-9f60-671812f6dd2e" ``` @@ -107,6 +132,33 @@ stop. - the shuffle question is answered in the task record with the line number that settles it, either way. +**Done 2026-08-05.** Both piles are drawn as offset stacks with their +counts, replacing the text line. Depth suggests *a pile exists*; the +number is the truth — 17 cards is not seventeen rectangles. + +**The shuffle question is settled and the answer is that it already +works.** `games/ground/src/lib.rs:1419-1435` (`draw_solution`) implements +the **U4 default**: deck empty → deterministic reshuffle of the discard +via a `DeckReshuffled` event seeded from `seed ^ round`; both empty → skip +the draw. ground-game **confirmed U4 on 2026-08-03**, so this is a ruled +rule, not an invented one. Nothing to raise. + +The event already reads out in the log (`hotseat.rs:374`). What the piles +add is the *state before it fires*: deck 0 with a non-empty discard draws +an arrow and says **"shuffles in on next draw"**. That is derivable from +the view; a claim that a reshuffle *has happened* would not be, and is not +made. + +**The coverage gate caught the probe going stale** — removing the +`17 remaining` text broke `solution_deck_len`'s classification. Fixed by +putting the count in the pile's `` (`draw pile: 17 remaining`), +which is both a stable probe and what a screen reader announces; the +on-canvas numeral alone could be any number on the page. + +Three tests: counts come from the view, an empty discard renders as an +empty pile rather than vanishing, and the reshuffle notice appears only +when the deck is actually out. + ## Task: what the other seats played, on the table ```task From 8d58568013475d2a2b1bc03c3dc3d6b83cac6de5 Mon Sep 17 00:00:00 2001 From: tegwick <bernd.worsch@gmail.com> Date: Wed, 5 Aug 2026 17:32:48 +0200 Subject: [PATCH 2/2] CB-WP-0024: the table you can watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of the maintainer's five playtest remarks. Three of the five turned out to be data the projection already carried, rendered as text -- the table's problem was legibility, not content, and the coverage gate passes either way because it proves nothing is OMITTED, not that anything is readable. That gap is named in the evidence rather than closed: the honest control is a person playing it. T01. The ending control was two defects wearing one button. The label said "close -- I have read this" while hotseat.rs reads `done` as STOP THE SERVER, and acknowledging it changed nothing -- the tab kept a full table and a `play again` pointing at a closed port. Now labelled by its effect, and the page seals itself on the `closed` reply: removeAttribute on every control's data-drop, so they stop being droppable by the same rule that made them droppable. removeAttribute rather than setAttribute(_, null), which writes the truthy string "null" in a browser. The reason it survived is structural. jsrun's fetch stub returned {then: function(){return this}} and never invoked its callbacks, so every line of the script reacting to the server was unreachable from every test in this project -- a page that ignores the server was indistinguishable from one that acts on it. Same finding as CB-WP-0016's "a stub too thin to express a failure is how the failure survives", one layer deeper, at the reply. The stub now delivers a real then-chain; gesture_with_reply reports surviving controls; the seal is mutation-proven and a negative control asserts `ok: dealing` does NOT seal. T02. Draw and discard as offset stacks with counts. The shuffle question the task required settling: it already works, at games/ground/src/lib.rs:1419-1435, implementing the U4 default that ground-game confirmed 2026-08-03. Nothing raised. The piles show the state before it fires, which is derivable from the view; a claim that a reshuffle HAS happened is not, and is not made. CB-WP-0026 applied that ruling the same day this consumed it -- first time answering "is this underdetermined?" was one lookup instead of a message. T03. Each seat's play drawn as a card, sentence kept beside it. The face-down back is a const with no parameters: SelectionView::Hidden carries nothing, so there is no data path into the back to add later. The leak test copies view.rs's own shape -- identical backs across two different hidden situations, THEN assert a revealed play does show, because without the second half the first passes for a renderer that draws nothing. T04. MatchTally lives in `play`, beside the listener and the seed. What "cumulative" means was decided before anything was summed, and the answer is that GROUND defines one game and no series: summed personal score and games-won answer different questions, and a test asserts they can point at different seats. Both shown, both labelled. Registered F15 as a NOTE -- the test shows the tallies can differ, which is arithmetic, not evidence the ambiguity harms play, so GameDesign §3.1 bars reporting it. First use of the note tier since D6 wrote it, and it came from building rather than from play. make all: exit 0. 41 render tests, 26 cb-play tests, loop-lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- crates/cb-render-html/src/doc.rs | 76 +++++++- crates/cb-render-html/src/lib.rs | 106 ++++++++++- .../CB-EV-0023-the-table-you-can-watch.md | 173 ++++++++++++++++++ specs/FindingRegister.md | 10 + tools/cb-play/src/hotseat.rs | 142 +++++++++++++- tools/cb-play/src/table.rs | 74 +++++++- .../CB-WP-0024-the-table-you-can-watch.md | 77 +++++++- 7 files changed, 644 insertions(+), 14 deletions(-) create mode 100644 evidence/CB-EV-0023-the-table-you-can-watch.md diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 4e3de65..99f0f94 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -255,6 +255,8 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf} styling alone would leave a dead control that still looks alive to anything reading the DOM. */ .sealed{opacity:.3;cursor:default;pointer-events:none;filter:grayscale(1)} +/* CB-WP-0024 T03: the card a seat played, in that seat's area. */ +.played{display:block;margin:.3rem 0} .k{color:#89a} .nil{color:#c88} .eff{color:#8c9} @@ -358,8 +360,8 @@ fn pile_svg(out: &mut String, x: i32, label: &str, count: usize, face: &str, not ); } for d in (0..depth).rev() { - let dx = x + (d as i32) * 3; - let dy = 14 - (d as i32) * 3; + let dx = x + d * 3; + let dy = 14 - d * 3; let _ = write!( out, "<rect x=\"{dx}\" y=\"{dy}\" width=\"76\" height=\"64\" rx=\"7\" fill=\"{face}\" \ @@ -518,6 +520,51 @@ fn selection_words(s: &games_ground::Selection) -> String { out } +/// The card a seat has played, as a card. +/// +/// **The face-down back is a constant.** It takes no argument, because +/// `SelectionView::Hidden` carries nothing and this function must not be +/// able to leak what it does not receive. GR-R02/R04 hide another seat's +/// choice until Reveal, and `view.rs`'s own test asserts the projection +/// obeys that — but a renderer can leak what the model did not, by +/// tinting the back with the suit or shaping it by the action. So there is +/// exactly one back, with no data path into it. +const CARD_BACK: &str = "<svg viewBox=\"0 0 84 56\" width=\"84\" height=\"56\" class=\"played\" \ + role=\"img\"><title>face down\ + \ + "; + +fn played_svg(out: &mut String, sel: &SelectionView) { + let s = match sel { + SelectionView::Hidden => { + out.push_str(CARD_BACK); + return; + } + SelectionView::Shown(s) => s, + }; + let mut sub = String::new(); + if let Some(t) = s.target { + let _ = write!(sub, "\u{2192}{}", seat_name(t)); + } + if let Some(n) = s.problem { + if !sub.is_empty() { + sub.push(' '); + } + let _ = write!(sub, "#{n}"); + } + let _ = write!( + out, + "\ + played {label}\ + \ + {label}\ + {sub}\ + ", + label = esc(&format!("{:?}", s.action)), + sub = esc(&sub), + ); +} + fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) { let is_viewer = view.viewer == Some(id); let _ = write!( @@ -582,6 +629,10 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView } } if let Some(sel) = view.selections.get(&id) { + // The picture, then the words. CB-WP-0024 T03 adds the card; the + // sentence stays, because the log is the record and a player + // reading back through it needs the same vocabulary. + played_svg(out, sel); let _ = write!( out, "selected {}
", @@ -929,7 +980,13 @@ fn log_section(s: &mut String, log: &[LogLine]) { /// **This page does not auto-reload.** The old one reloaded on `ok` and /// the reload was refused, which is how a completed game became a blank /// tab. -pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[LogLine]) -> String { +pub fn ending( + view: Option<&GroundView>, + message: &str, + endpoint: &str, + log: &[LogLine], + series: &[String], +) -> String { let mut s = String::with_capacity(4096); let _ = write!( s, @@ -948,6 +1005,19 @@ pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[L ); } } + // CB-WP-0024 T04. Above the log, because it is a result and the log is + // the account. Empty for a first game — one game is not a series, and + // a "cumulative" panel restating the outcome above it is noise. + if !series.is_empty() { + s.push_str("

this session

"); + for (i, line) in series.iter().enumerate() { + if i > 0 { + s.push_str("
"); + } + s.push_str(&esc(line)); + } + s.push_str("
"); + } log_section(&mut s, log); let _ = write!( s, diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 7dd0f36..7409f5b 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -706,6 +706,106 @@ mod piles { } } +/// CB-WP-0024 T03 — what the other seats played, drawn as cards. +/// +/// The maintainer could follow the other players only by reading the log. +/// The data was already projected and already rendered — as sentences. +/// This adds the picture without touching the hiding rule, which is the +/// only part that could do harm. +#[cfg(test)] +mod played_cards { + use cb_kernel::PlayerId; + use games_ground::view::{GroundView, SelectionView}; + use games_ground::{Action, Selection}; + + use crate::doc::document; + + fn html(v: &GroundView) -> String { + document(v, &[], "/command?t=x", Some(PlayerId(0)), false) + } + + /// A seat's revealed play is drawn, not only written. + #[test] + fn a_revealed_play_is_drawn_as_a_card() { + let mut v = crate::testfix::view(Some(PlayerId(0))); + v.selections.insert( + PlayerId(1), + SelectionView::Shown(Selection { + action: Action::Solve, + target: None, + problem: Some(3), + }), + ); + let doc = html(&v); + assert!( + doc.contains("played Solve"), + "the revealed play was not drawn as a card" + ); + assert!( + crate::text_of(&doc).contains("selected Solve"), + "the sentence must survive alongside the picture — the log is the record" + ); + } + + /// **The leak test.** Nothing in the emitted document may vary with + /// another seat's hidden selection. + /// + /// The shape is `view.rs`'s own + /// `a_seat_never_sees_another_seats_face_down_selection`: assert the + /// absence, then assert the same view AFTER reveal shows it — + /// otherwise the first assertion passes for a renderer that draws + /// nothing at all. + #[test] + fn a_hidden_play_renders_identically_whatever_it_is() { + let render_hidden = |action, problem| { + let mut v = crate::testfix::view(Some(PlayerId(0))); + // The seat HAS chosen; the viewer may not see what. + v.selections.insert(PlayerId(1), SelectionView::Hidden); + // A different real choice underneath, which must not reach us. + v.selections.insert( + PlayerId(2), + SelectionView::Shown(Selection { + action, + target: None, + problem, + }), + ); + html(&v) + }; + + // Two different hidden situations must produce the same markup for + // the hidden seat. Compare the card backs directly. + let a = render_hidden(Action::Solve, Some(1)); + let b = render_hidden(Action::Attack, Some(9)); + let back = |h: &str| { + let i = h + .find("face down") + .expect("a face-down card"); + h[i.saturating_sub(200)..i + 200].to_string() + }; + assert_eq!( + back(&a), + back(&b), + "the face-down card differed between two games — it varies with something" + ); + + // The other half: revealed, the same renderer DOES show it. + let mut shown = crate::testfix::view(Some(PlayerId(0))); + shown.selections.insert( + PlayerId(1), + SelectionView::Shown(Selection { + action: Action::Attack, + target: Some(PlayerId(2)), + problem: None, + }), + ); + assert!( + html(&shown).contains("played Attack"), + "the assertion above would pass for a renderer that draws nothing" + ); + } +} + /// CB-WP-0024 T01 — the ending page's one control. /// /// The maintainer reported it as *"the button says 'I need to read this' @@ -718,7 +818,7 @@ mod ending_page { use crate::{doc, jsrun}; fn page() -> String { - doc::ending(None, "the game ended", "/command?t=x", &[]) + doc::ending(None, "the game ended", "/command?t=x", &[], &[]) } /// The label must say what the control DOES. Asserted on the rendered @@ -772,8 +872,8 @@ mod ending_page { #[test] fn a_dealing_reply_leaves_the_controls_alone() { let html = page(); - let (live, _) = - jsrun::gesture_with_reply(&html, "again", "again", "ok: dealing").expect("run the page"); + let (live, _) = jsrun::gesture_with_reply(&html, "again", "again", "ok: dealing") + .expect("run the page"); assert!( live.contains(&"again".to_string()) && live.contains(&"done".to_string()), "an 'ok' reply must not seal the page: {live:?}" diff --git a/evidence/CB-EV-0023-the-table-you-can-watch.md b/evidence/CB-EV-0023-the-table-you-can-watch.md new file mode 100644 index 0000000..3e59f14 --- /dev/null +++ b/evidence/CB-EV-0023-the-table-you-can-watch.md @@ -0,0 +1,173 @@ +# CB-EV-0023 — the table you can watch + +CB-WP-0024 T05. Tier S (structural S — renders state the projection +already carries; chaos d8=6 → no override). Declaration 7 of chaos window +2. Closed 2026-08-05. + +**Delivered:** the ending control says what it does and the page acts on +it; draw and discard drawn as stacks; each seat's play drawn as a card; +a session tally across games. Five remarks from play, four addressed +(remarks 2 and 3 are CB-WP-0025). + +--- + +## 1. How many of the five remarks were already implemented + +**Three of five were data the projection already carried, rendered as +text.** That is the finding this pass is really about, and it was +established by reading the code before writing the workplan rather than +by building anything. + +| remark | what was actually missing | +|---|---| +| follow the other players | nothing in the model — `selections` project as `Shown` after Reveal and were already drawn, **as sentences** | +| draw and discard stacks | nothing in the model — `solution_deck_len` and `solution_discard` were one line of text | +| show the scores | nothing at all — `personal`, `mastery`, `winners`, `total`/`threshold` were already drawn | +| the ending button | a real defect, two of them | +| a cumulative score | genuinely absent | + +**So the table's problem was legibility, not content.** A renderer can +show every field it is given and still be unreadable, and the coverage +gate — which asserts every view field appears in the parsed document — +passes either way. It is a completeness gate, and completeness was never +the issue. + +**That is worth naming as a gap in the controls**, not as a criticism of +them. `every_view_field_is_classified_in_the_emitted_document` proves +nothing is silently omitted. Nothing proves it is *readable*, and nothing +could, cheaply. The maintainer playing the game is the instrument, which +is what makes remarks like these worth more than their length suggests. + +## 2. The defect that mattered was untestable, and that is why it survived + +The ending control was labelled *"close — I have read this"* while +`hotseat.rs` read it as **stop the server**, and acknowledging it changed +nothing: the tab kept a full table and a `play again` pointing at a closed +port. + +The label was a wording bug. **The second half survived for a structural +reason:** + +> `jsrun`'s `fetch` stub returned `{then: function () { return this; }}`, +> which never invoked its callbacks. **Every line of the script that +> reacts to what the server said was unreachable from every test in this +> project.** + +The `ok` → reload branch, the status text, and the branch that did not yet +exist were all equally unexercised. A page that ignores the server was +indistinguishable, under test, from one that acts on it. + +CB-WP-0014 embedded a real JS engine specifically so the script could be +executed rather than string-matched, and CB-WP-0016 found that *a stub too +thin to express a failure is how the failure survives*. This is the same +finding one layer deeper: the stub was thin at the **reply**, and the +reply is where the session's ending lives. + +The stub now delivers a real then-chain and `gesture_with_reply` reports +which controls survive. The seal is mutation-proven — deleting the branch +turns exactly one test red — and a negative control asserts `ok: dealing` +does **not** seal, because a seal that fired on every reply would pass the +first test and silently break `play again`. + +### One browser-versus-stub trap, avoided by writing it down + +The first seal used `setAttribute('data-drop', null)`. In the stub that +stores a real `null` and `getAttribute` returns `null`; **in a browser it +writes the literal string `"null"`, which is truthy**, so the control would +have stayed live while its own test called it sealed. `removeAttribute` +is correct in both. The stub gained the method rather than the script +gaining a workaround. + +## 3. The reshuffle question: already ruled, already implemented + +T02 required settling whether the deck-exhaustion shuffle happens before +drawing it, and to raise a finding if it did not. + +**It does.** `games/ground/src/lib.rs:1419-1435` (`draw_solution`): +deterministic reshuffle of the discard, seeded `seed ^ round`, carried in +a `DeckReshuffled` event so replay never re-derives it; if both are empty +the draw is skipped. That is the **U4 default**, which ground-game +**confirmed on 2026-08-03** — a ruled rule. + +So nothing was raised, which is the correct outcome and the one the task +was written to allow. The piles show the state in which the *next* draw +triggers it, because that is derivable from the view; a claim that a +reshuffle *has* happened is not, and is not made. The event already reads +out in the log. + +**Two days earlier this question would have been raised as a finding.** +CB-WP-0026 applied the U4 ruling on 2026-08-05; this pass consumed it the +same day. The register paying off inside 24 hours is not proof it works, +but it is the first time the answer to *"is this underdetermined?"* was +one lookup instead of a message. + +## 4. What the controls caught + +**The coverage gate caught its own probe going stale.** Replacing the +`17 remaining` text broke `solution_deck_len`'s classification — the gate +does not care *how* a field is rendered, only that its token appears, so +changing the rendering changed the token. Fixed by putting the count in +the pile's ``: a stable probe, and what a screen reader announces. +The on-canvas numeral would have been a weak probe, since a bare `17` +could be anything on the page. + +**The compiler caught a misplaced doc comment** that had silently taken +`Summary`'s `#[derive(Debug)]` with it. + +Neither is interesting alone. Together they are the ordinary case for +this project: the gates fire on rendering changes, and the cost of that is +small enough that nobody is tempted to loosen them. + +## 5. The cumulative-score question, and why it stayed a note + +The task required deciding what "cumulative" means **before** summing +anything. The answer is that **GROUND defines one game and no series at +all.** + +`personal` sums per seat; `winners` counts games won; `group_success` +counts games the table cleared. They answer different questions, and a +test asserts they can point at different seats — a 9/0/0 versus 0/1/1 +series leads on summed score for one seat and on games won for the other. + +**Both are shown and both are named.** Picking one would have made it *the* +score by default, which is canonising a rule nobody wrote. + +Registered **F15, `underdetermined`, state `note`**. It stays a note on +purpose: the test demonstrates the two tallies *can differ*, which is +arithmetic — not evidence that the ambiguity harms play. Under GameDesign +§3.1 a note may not be reported to ground-game until an artifact exists. + +**This is the note tier doing exactly what ADR-0012 D6 built it for**, on +the first genuinely new question since it was written. The tier was +argued as the way to admit findings from *play* that the engine cannot +produce; its first real use is a question from *building*, which the ADR +did not anticipate and which the tier handles unchanged. + +## 6. Chaos window 2 + +**Declaration 7 of 12.** Structural S, d8 = 6, no override. + +Ten declarations in, **no 8 has been rolled**. At d8 the expected count +over twelve is 1.5, so this is unremarkable — but it means window 2 will +likely close with **zero overrides to evaluate**, and its retirement +condition (*retire if an override changes nothing twice running*) will be +untestable. Window 1 closed with 2 overrides at d4 and both changed the +outcome. **The rate cut to d8 may have made the mechanism unevaluable**, +which is a real cost that CB-EV-0015's decision did not price, and the +window's closing evidence should say so. + +## 7. Cost + +CB-WP-0023's cost, by re-running the instrument — the pass this one is +required to quote. Reported by `make cost`; **not inlined here as a +literal** (§Single source of fact), and see CB-EV-0019 §4 on the chain +breaking beyond ~4 passes. + +## Open after this pass + +- **Remarks 2 and 3** — *how could we have won* and *difficulty* — are + CB-WP-0025, declared and ready, tier L. +- **F15** needs an artifact before the series question can go to + ground-game. +- **Legibility has no gate** (§1) and probably should not get one; the + honest control is a person playing it. diff --git a/specs/FindingRegister.md b/specs/FindingRegister.md index d91a9cb..42be87e 100644 --- a/specs/FindingRegister.md +++ b/specs/FindingRegister.md @@ -42,6 +42,7 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by | F12 | degenerate | note | — | — | 2026-08-01 | clay-borg | | F13 | inconsistent | withdrawn | scenarios/ground/gr-e01-threshold-reachable-2p.yaml | counterexample | 2026-08-01 | clay-borg | | F14 | unplayed | note | — | — | 2026-08-01 | clay-borg | +| F15 | underdetermined | note | — | — | 2026-08-05 | clay-borg | <!-- design-register:end --> @@ -61,6 +62,15 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by `-reachable-`. **Its reproduction is green**, which under GameDesign §1.3 is the alarm that forced the resolution. Withdrawn rather than deleted, and the withdrawal is reported (ADR-0012 D5). +- **F15 — the rules define one game, not a series.** `OutcomeView` gives + `personal` (per seat), `group_success` (per table) and `winners`. Summing + the first and counting the third answer different questions, and GROUND + says nothing about how several games combine. CB-WP-0024 T04 shows + **both, labelled**, rather than picking one and letting it become the + score by default. `note`: no artifact demonstrates that this *harms* + play — a unit test showing the two tallies point at different seats + demonstrates only that they can differ, which is arithmetic, not a design + defect. Under GameDesign §3.1 it may not be reported until one exists. - **F14 — GR-E03/GR-E04 never played to the end.** Nineteen passes, never played out. `note` until a trial game exists; GROUND-WP-0003 is the playtest that would close it, and GameDesign §5's protocol makes the diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index 1385e96..c8112a8 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -200,6 +200,7 @@ impl Server { final_view: Option<&games_ground::view::GroundView>, message: &str, linger: std::time::Duration, + tally: &crate::table::MatchTally, ) -> Result<EndChoice, String> { let deadline = std::time::Instant::now() + linger; self.listener @@ -237,6 +238,7 @@ impl Server { message, &self.guard.endpoint(), &self.log_lines(), + &series_lines(tally), ); respond(&mut stream, 200, "text/html; charset=utf-8", &page); } @@ -275,6 +277,142 @@ impl Server { } } +/// The running series, phrased for a reader. +/// +/// **Two lines, because the rules define one game and not a series** +/// (CB-WP-0024 T04). `personal` sums per seat; `winners` counts games +/// won; they answer different questions and GROUND says which is *the* +/// series score nowhere at all. Both are shown and both are named, so +/// nothing here quietly becomes canon. +pub fn series_lines(t: &crate::table::MatchTally) -> Vec<String> { + if t.games <= 1 { + // One game is not a series, and a "cumulative" panel over a single + // result would just restate the outcome above it. + return Vec::new(); + } + let seats = |m: std::collections::BTreeMap<PlayerId, String>| { + m.iter() + .map(|(p, v)| format!("P{} {v}", p.0 + 1)) + .collect::<Vec<_>>() + .join(" ") + }; + let personal: std::collections::BTreeMap<_, _> = t + .personal + .iter() + .map(|(p, v)| (*p, format!("{v:+}"))) + .collect(); + let wins: std::collections::BTreeMap<_, _> = t + .personal + .keys() + .map(|p| (*p, t.wins.get(p).copied().unwrap_or(0).to_string())) + .collect(); + vec![ + format!("{} games this session", t.games), + format!("summed personal score — {}", seats(personal)), + format!("games won — {}", seats(wins)), + format!( + "table cleared its threshold {} of {} games", + t.group_successes, t.games + ), + ] +} + +#[cfg(test)] +mod series { + use crate::table::MatchTally; + use cb_kernel::PlayerId; + use games_ground::view::OutcomeView; + + fn outcome(p1: i32, p2: i32, success: bool, winners: Vec<PlayerId>) -> OutcomeView { + OutcomeView { + total: 0, + threshold: 5, + group_success: success, + personal: [(PlayerId(0), p1), (PlayerId(1), p2)].into_iter().collect(), + coalitions: vec![], + mastery: None, + winners, + } + } + + fn after(games: &[OutcomeView]) -> MatchTally { + let mut t = MatchTally::default(); + for o in games { + t.record_for_test(o); + } + t + } + + /// The weakest possible assertion, and the one that catches a tally + /// reset by `play again`: two games must produce a total that is + /// neither game's score alone. + #[test] + fn two_games_accumulate_rather_than_replacing() { + let t = after(&[ + outcome(3, 1, true, vec![PlayerId(0)]), + outcome(2, 4, false, vec![PlayerId(1)]), + ]); + assert_eq!(t.games, 2); + assert_eq!(t.personal[&PlayerId(0)], 5, "P1's scores did not sum"); + assert_eq!(t.personal[&PlayerId(1)], 5, "P2's scores did not sum"); + assert_eq!(t.group_successes, 1, "only one game cleared its threshold"); + assert_eq!(t.wins[&PlayerId(0)], 1); + assert_eq!(t.wins[&PlayerId(1)], 1); + } + + /// **The two tallies disagree, and that is the point.** Summed + /// personal score says the seats are level; games won says the same; + /// but a third game separates them, and which one is "the series + /// score" is a question GROUND does not answer. Showing one alone + /// would be picking a winner the rules never named. + #[test] + fn the_two_tallies_can_disagree_about_who_is_ahead() { + let t = after(&[ + outcome(9, 0, false, vec![PlayerId(0)]), + outcome(0, 1, false, vec![PlayerId(1)]), + outcome(0, 1, false, vec![PlayerId(1)]), + ]); + assert!( + t.personal[&PlayerId(0)] > t.personal[&PlayerId(1)], + "P1 leads on summed score" + ); + assert!( + t.wins[&PlayerId(1)] > t.wins.get(&PlayerId(0)).copied().unwrap_or(0), + "P2 leads on games won — the two answers point at different seats" + ); + } + + /// One game is not a series: the panel stays absent rather than + /// restating the outcome directly above it. + #[test] + fn a_single_game_shows_no_series_panel() { + assert!(super::series_lines(&after(&[outcome(1, 1, true, vec![])])).is_empty()); + assert!( + !super::series_lines(&after(&[ + outcome(1, 1, true, vec![]), + outcome(1, 1, true, vec![]) + ])) + .is_empty(), + "two games must produce a panel" + ); + } + + /// Both tallies must be named on the page. An unlabelled number would + /// become "the score" by default, which is the canonisation this + /// deliberately avoids. + #[test] + fn the_page_says_which_tally_is_which() { + let lines = super::series_lines(&after(&[ + outcome(1, 2, true, vec![PlayerId(1)]), + outcome(3, 0, false, vec![PlayerId(0)]), + ])); + let all = lines.join(" | "); + assert!(all.contains("summed personal score"), "{all}"); + assert!(all.contains("games won"), "{all}"); + assert!(all.contains("cleared its threshold"), "{all}"); + } +} + /// What the player asked for on the ending page. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EndChoice { @@ -473,6 +611,7 @@ mod tests { Some(&view), "30 commands, hash f6c890a65271", std::time::Duration::from_secs(20), + &crate::table::MatchTally::default(), ) .expect("serve_end"); let replies = client.join().expect("client"); @@ -563,7 +702,8 @@ mod tests { /// game that never happened. #[test] fn a_game_that_ended_badly_says_so_and_shows_no_table() { - let page = cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x", &[]); + let page = + cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x", &[], &[]); assert!( page.contains("P1 ran out of input"), "the reason is missing" diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index bdc67eb..4c84f50 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -52,6 +52,59 @@ impl Default for Config { } } +/// What carries across games in one browser session (CB-WP-0024 T04). +/// +/// `play` already owned the session — one listener, an advancing seed, +/// `run_game` in a loop — and kept only the last summary, so a second +/// game started with no memory of the first. +/// +/// ## Two tallies, because the rules define one game and not a series +/// +/// `OutcomeView` offers `personal` (per seat), `group_success` (per +/// table) and `winners`. Summing `personal` and counting `winners` answer +/// **different questions**, and GROUND's dataset says nothing about how +/// several games combine — there is no series in the rules. +/// +/// So both are kept and both are labelled, rather than one being picked +/// and quietly presented as *the* score. Registered as a note (kind +/// `underdetermined`) in `specs/FindingRegister.md`: it may not be +/// reported to ground-game until something demonstrates a problem, which +/// is GameDesign §3.1's rule and the reason this is not being sent as a +/// finding. +#[derive(Debug, Clone, Default)] +pub struct MatchTally { + pub games: u32, + /// Sum of each seat's per-game `personal` score. + pub personal: std::collections::BTreeMap<cb_kernel::PlayerId, i32>, + /// Games the table cleared its threshold. + pub group_successes: u32, + /// How often each seat appeared in `winners`. + pub wins: std::collections::BTreeMap<cb_kernel::PlayerId, u32>, +} + +impl MatchTally { + /// Test-visible alias for [`Self::record`]. The real call site is the + /// driver; this exists so the series tests drive the same code rather + /// than a reimplementation of it beside it. + #[cfg(test)] + pub fn record_for_test(&mut self, o: &games_ground::view::OutcomeView) { + self.record(o); + } + + fn record(&mut self, o: &games_ground::view::OutcomeView) { + self.games += 1; + for (seat, score) in &o.personal { + *self.personal.entry(*seat).or_insert(0) += *score; + } + if o.group_success { + self.group_successes += 1; + } + for w in &o.winners { + *self.wins.entry(*w).or_insert(0) += 1; + } + } +} + /// What a finished game reports back. /// The end-state hash is what makes a session comparable to its replay. #[derive(Debug)] @@ -200,7 +253,11 @@ fn end_badly<T>( msg: String, ) -> Result<T, String> { if let Some(s) = server { - let _ = s.serve_end(None, &msg, END_LINGER); + // A game that ended badly has no outcome, so it contributes + // nothing to the series and the panel stays absent. Passing an + // empty tally rather than the live one is deliberate: a crashed + // game must not be counted as a played one. + let _ = s.serve_end(None, &msg, END_LINGER, &MatchTally::default()); } Err(msg) } @@ -239,8 +296,12 @@ pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<S }; let mut cfg: Config = config.clone(); + // CB-WP-0024 T04: the tally outlives the game, which is the whole + // point — it is declared here, beside the listener and the seed, + // because those are the other two things that survive `play again`. + let mut tally = MatchTally::default(); loop { - let (summary, choice) = run_game(&cfg, &input, &out, server.clone())?; + let (summary, choice) = run_game(&cfg, &input, &out, server.clone(), &mut tally)?; if choice != crate::hotseat::EndChoice::Again { return Ok(summary); } @@ -253,6 +314,7 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( input: &'a std::cell::RefCell<R>, out: &'a std::cell::RefCell<W>, server: Option<std::rc::Rc<crate::hotseat::Server>>, + tally: &mut MatchTally, ) -> Result<(Summary, crate::hotseat::EndChoice), String> { let setup = Setup { players: config.players, @@ -342,8 +404,14 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( let mut end_choice = crate::hotseat::EndChoice::Closed; if let Some(srv) = &server { let ended = game.state.project(Viewer::Spectator); + // Recorded BEFORE the page is served, so the ending page shows a + // tally that includes the game being looked at. A tally that + // lagged by one game would be worse than none. + if let Some(o) = &ended.outcome { + tally.record(o); + } let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]); - end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER)?; + end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER, tally)?; } let scenario = games_ground::record::to_scenario( diff --git a/workplans/CB-WP-0024-the-table-you-can-watch.md b/workplans/CB-WP-0024-the-table-you-can-watch.md index 54208b8..c91787a 100644 --- a/workplans/CB-WP-0024-the-table-you-can-watch.md +++ b/workplans/CB-WP-0024-the-table-you-can-watch.md @@ -2,7 +2,7 @@ id: CB-WP-0024 kind: product title: "The table you can watch: the piles, the other seats' moves, the score that carries" -status: active +status: done state_hub_workstream_id: "5f17b6f9-cd4b-4c31-a8af-712313149cf2" --- @@ -163,7 +163,7 @@ when the deck is actually out. ```task id: CB-WP-0024-T03 -status: todo +status: done priority: high state_hub_task_id: "7b0b08d9-cf0d-40f4-8697-049bdb20085d" ``` @@ -189,11 +189,28 @@ face-down as one identical back. `view.rs`'s own test already uses; copy it); - `make sim` still passes: 26 scenarios encode what the table looks like. +**Done 2026-08-05.** Each seat's play is drawn as a card in its own area, +with the sentence kept beside it — the log is the record and a player +reading back needs the same vocabulary. + +**The face-down back is a `const` with no parameters.** `SelectionView:: +Hidden` carries nothing, so the renderer is structurally unable to leak +what it was not given; making the back a constant means there is no data +path into it to add later. The risk this guards is real but is the +renderer's, not the model's: a tint keyed on suit or a shape keyed on +action would leak even though `view.rs` handed over nothing. + +The leak test copies `view.rs`'s own shape — assert two different hidden +situations render an identical back, **then** assert the same renderer +does show a revealed play, because without the second half the first +passes for a renderer that draws nothing at all. Both are +mutation-proven: deleting the call turns both red. + ## Task: the score that carries across games ```task id: CB-WP-0024-T04 -status: todo +status: done priority: medium state_hub_task_id: "4f312267-2b82-4cf9-bbb1-7f287dc9e766" ``` @@ -219,11 +236,36 @@ raise the question rather than silently canonising a choice. - the tally survives the seed advance and resets only on a new process; - the per-game outcome block is unchanged, and a test says so. +**Done 2026-08-05.** `MatchTally` lives in `play`, beside the listener and +the seed — the other two things that survive `play again`. Shown on the +ending page above the log, and **absent for a first game**: one game is not +a series, and a cumulative panel restating the outcome directly above it +is noise. + +**"Cumulative" was decided before anything was summed, and the answer is +that the rules do not decide it.** `OutcomeView` offers `personal` (per +seat), `group_success` (per table) and `winners`; summing the first and +counting the third answer different questions, and GROUND defines one game +and no series at all. **Both are shown and both are named** rather than one +being picked and becoming *the* score by default. A test asserts they can +point at different seats — 9/0/0 against 0/1/1 leads on summed score for +one seat and on games won for the other. + +Registered as **F15, kind `underdetermined`, state `note`** in +`FindingRegister.md`. It stays a note deliberately: the test shows the two +tallies *can differ*, which is arithmetic, not evidence that the ambiguity +harms play — so under GameDesign §3.1 it may not be reported to +ground-game yet. **This is the note tier doing the job D6 built it for**, +on the first new question since it was written. + +A crashed game contributes nothing: the error path passes an empty tally, +so a game that ended without an outcome is never counted as played. + ## Task: evidence ```task id: CB-WP-0024-T05 -status: todo +status: done priority: high state_hub_task_id: "db176595-e00d-467c-ba54-0714cfb7ba5c" ``` @@ -238,3 +280,30 @@ state_hub_task_id: "db176595-e00d-467c-ba54-0714cfb7ba5c" - **What the cumulative-score question turned out to be**, and whether it went to `ground-game`. - **Quote CB-WP-0023's cost by re-running the instrument.** + +**Done 2026-08-05.** +[CB-EV-0023](../evidence/CB-EV-0023-the-table-you-can-watch.md). + +- **Three of five remarks were already implemented** — data the projection + carried, drawn as text. The table's problem was legibility, not content, + and **the coverage gate passes either way** because it proves nothing is + omitted, not that anything is readable. That gap is named, not fixed: + the honest control is a person playing it. +- **The defect that mattered was untestable.** `jsrun`'s fetch stub never + invoked its callbacks, so every line of the script reacting to the + server was unreachable from every test in the project. Same finding as + CB-WP-0016's *a stub too thin to express a failure is how the failure + survives*, one layer deeper — at the reply, which is where the session's + ending lives. +- **The reshuffle was already ruled and already implemented** (U4, + confirmed 2026-08-03), so nothing was raised. CB-WP-0026 applied that + ruling the same day this pass consumed it — the first time answering + *"is this underdetermined?"* was one lookup instead of a message. +- **F15 stayed a note on purpose.** The note tier's first use since D6 + wrote it, and it came from *building* rather than from play, which the + ADR did not anticipate and which the tier handled unchanged. +- **Chaos window 2 has rolled no 8 in ten declarations**, so it will + likely close with no override to evaluate and its retirement condition + untestable. The d4 → d8 cut may have made the mechanism unevaluable — + a cost CB-EV-0015 did not price, and one the window's closing evidence + should state.