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 <title> -- 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>
This commit is contained in:
tegwick 2026-08-05 17:23:09 +02:00
parent 5f9c2839bb
commit 1edb10d5a3
5 changed files with 443 additions and 15 deletions

View file

@ -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</title>",
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,
"<rect x=\"{x}\" y=\"14\" width=\"76\" height=\"64\" rx=\"7\" fill=\"none\" \
stroke=\"#3a4350\" stroke-dasharray=\"4 3\"/>",
);
}
for d in (0..depth).rev() {
let dx = x + (d as i32) * 3;
let dy = 14 - (d as i32) * 3;
let _ = write!(
out,
"<rect x=\"{dx}\" y=\"{dy}\" width=\"76\" height=\"64\" rx=\"7\" fill=\"{face}\" \
stroke=\"#5a6b7a\"/>",
);
}
let _ = write!(
out,
"<text x=\"{tx}\" y=\"46\" fill=\"#dde\" font-size=\"18\" text-anchor=\"middle\">{count}</text>\
<text x=\"{tx}\" y=\"64\" fill=\"#89a\" font-size=\"10\" text-anchor=\"middle\">{label}</text>\
<text x=\"{tx}\" y=\"96\" fill=\"#fc9\" font-size=\"10\" text-anchor=\"middle\">{note}</text></g>",
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("<svg viewBox=\"0 0 300 110\" width=\"300\" height=\"110\" role=\"img\">");
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(
"<path d=\"M100 46 q10 -14 20 0\" fill=\"none\" stroke=\"#fc9\" stroke-width=\"2\"/>\
<path d=\"M118 40 l4 6 -7 1z\" fill=\"#fc9\"/>",
);
}
out.push_str("</svg>");
// 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,
"<div><span class=\"k\">discard</span> {}</div>",
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("</div>");
let _ = write!(
s,
"<h2>solutions</h2><div><span class=\"k\">deck</span> {} remaining \
<span class=\"k\">discard</span> {}</div>",
view.solution_deck_len,
esc(&cards(&view.solution_discard)),
);
s.push_str("<h2>solutions</h2>");
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,
"<div class=\"row\">\
<div class=\"card btn pick\" data-drop=\"again\">play again</div>\
<div class=\"card btn pick\" data-drop=\"done\">close \u{2014} I have read this</div>\
<div class=\"card btn pick\" data-drop=\"done\">end session \u{2014} stops the game server</div>\
</div>\
<div id=\"cb-status\">the game is over</div>\
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>",