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>
15 KiB
| id | kind | title | status | state_hub_workstream_id |
|---|---|---|---|---|
| CB-WP-0024 | product | The table you can watch: the piles, the other seats' moves, the score that carries | done | 5f17b6f9-cd4b-4c31-a8af-712313149cf2 |
Purpose
structural tier S (renders state the projection already carries, inside
cb-render-html and cb-play — no new port, no canonical
interface, no dependency)
chaos d8 = 6 → no override
declared tier S
Declaration 7 of chaos window 2.
Five remarks from play, and what is actually true of each
The maintainer played several games and reported five things. Four are
this pass. Each was checked against the code before being written down,
because two findings have already reached ground-game on wrong
premises (CB-WP-0023 T01, GROUND-WP-0004 T02) and the rule now is that a
claim names the line that makes it true.
| # | reported | what the code says |
|---|---|---|
| 1 | "the button next to new game says 'I need to read this' — why? the UI is not closing" | doc.rs:825 renders close — I have read this. hotseat.rs:248 reads it as shut the server down. The label describes a reading; the control ends a process. And nothing closes: a page cannot close a tab it did not open, so the tab sits there showing a dead game against a dead port. |
| 4 | "following the other players is only possible in the log" | Wrong by degree, not in kind. player_card (doc.rs:385) draws each seat, and selections are Shown for every seat once step != Select (view.rs) — so the data is there and is being drawn as words. What is missing is that a seat's play is not drawn as a card on the table. |
| 4b | "visualise the draw stack and the discard stack" | Both already project: solution_deck_len and solution_discard (view.rs). body() prints them as one line of text (doc.rs:575). Nothing is missing from the model; the whole gap is rendering. |
| 5 | "if we calculate scores, show them — and a cumulative score over several games" | Per-game scores exist and are already drawn: OutcomeView.personal, .mastery, .winners, .total/.threshold (doc.rs:582–628). Cumulative does not exist at all: table.rs:241 loops run_game and returns only the last summary. |
Remarks 2 (how could we have won) and 3 (difficulty) are not this pass — they need a search and a measurement, not a renderer. They are CB-WP-0025.
So this pass is smaller than the remarks make it sound. One control is
mislabelled, one number is missing, and three things that already project
are being written as text where a picture would read better. That is why
it is S: no new state leaves the aggregate, and view.rs gains nothing
except where task T04 says so explicitly.
Task: the ending control says what it does, and the page acts on it
id: CB-WP-0024-T01
status: done
priority: high
state_hub_task_id: "acb4231c-35df-490f-93fd-be71c0abf1dc"
Two defects behind one button.
The label lies about the mechanism. done does not mean I have read
this; it means stop serving. Rename it to what it does — the session
ends, the process exits — and drop the first-person reading claim. The
data-drop key stays done: hotseat.rs:248 matches on the body and the
tests in hotseat.rs:524 drive it.
The page does not change. After the POST the server replies closed
and breaks its loop; the tab keeps showing a live-looking table with a
play again control that now hits a closed port. Replace the page body on
acknowledgement with a terminal state that says the session has ended and
this tab can be closed — and disable play again at the same moment,
because a control that can no longer work must not look like it can.
Controls:
- a test asserts the acknowledged page no longer offers
again— not just that it shows different words; hotseat.rs's existingplay_again_deals_a_second_gamemust still pass unchanged, or the key was renamed and the server was not told;- 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
id: CB-WP-0024-T02
status: done
priority: high
state_hub_task_id: "37b16a87-e791-4c85-9f60-671812f6dd2e"
Draw the draw stack and the discard stack as stacks, with their counts on
top, replacing the text line at doc.rs:575. problem_svg and
relations_svg are the precedent for how this repo draws — follow them
rather than inventing a second idiom.
The maintainer asked for one thing the model does not yet do: when the
draw stack runs out, the discard shows a shuffle. Before drawing that,
establish whether it happens. Search the aggregate for the reshuffle: if
solution_deck is refilled from solution_discard anywhere, render the
moment; if it is not, that is a finding, not a rendering task — the
deck can be exhausted and the rules do not say what then. Raise it through
CB-WP-0022's register as underdetermined and report it to ground-game.
Do not implement a reshuffle. Inventing rules is what CB-WP-0023 exists to
stop.
Controls:
- the drawn counts come from
solution_deck_lenandsolution_discard.len(), asserted against the view, never recomputed; - a discard of zero cards renders as an empty pile, not as a missing one — an absent slot reads as "no discard exists in this game";
- 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 <title> (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
id: CB-WP-0024-T03
status: done
priority: high
state_hub_task_id: "7b0b08d9-cf0d-40f4-8697-049bdb20085d"
Each seat's revealed selection should be legible as a played card in that
seat's area, not only as a sentence in selection_words and a line in the
log. The log stays — it is the record, and CB-WP-0018 built it for a
reason. This adds the picture, it does not replace the text.
The hiding rule is not negotiable and is not this pass's to touch.
SelectionView::Hidden is what a face-down selection projects, and
a_seat_never_sees_another_seats_face_down_selection asserts it on the
serialized view. A rendering that draws something suggestive of the hidden
choice — a suit tint, a differently-shaped back, anything varying with the
hidden value — is a leak even though the model did not leak. Draw
face-down as one identical back.
Controls:
- a test renders a mid-
Selectview and asserts the HTML contains nothing that varies with another seat's hidden selection; - the same view after Reveal shows it — otherwise the assertion above
passes for a renderer that draws nothing at all (this is the shape
view.rs's own test already uses; copy it); make simstill 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
id: CB-WP-0024-T04
status: done
priority: medium
state_hub_task_id: "4f312267-2b82-4cf9-bbb1-7f287dc9e766"
play (table.rs:219) already owns the session — one listener, a seed
that advances, run_game in a loop — and throws away every summary but
the last. Accumulate them and show the running tally on the ending page,
beside the per-game outcome that is already drawn.
Decide what "cumulative" means before summing anything, and write the
answer down. OutcomeView carries personal (per seat, i32),
mastery, group_success and winners. Summing personal across games
and counting winners across games are different games' worth of
different answers, and the game is co-operative in a way that may make
neither the right one. If the rules do not say, it is underdetermined:
pick the one the code can defend, say in the UI which one is shown, and
raise the question rather than silently canonising a choice.
Controls:
- two games in one session produce a tally that is not either game's
score alone — the weakest possible assertion, and the one that catches a
tally reset by
play again; - 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
id: CB-WP-0024-T05
status: done
priority: high
state_hub_task_id: "db176595-e00d-467c-ba54-0714cfb7ba5c"
evidence/CB-EV-0022-*.md. Short — this is an S pass.
- How many of the five remarks were already implemented, and what that says about the table's legibility versus its content. Three of five were data already in the view being drawn as text. That is a finding about rendering, and it is worth naming.
- Whether the deck can be exhausted, as T02 settled it.
- 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.
- 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.