From 1edb10d5a3ec4f9a998fd203b856ff10f4611903 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 5 Aug 2026 17:23:09 +0200 Subject: [PATCH] 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