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, + "{} 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