From 5e06a7d01edd0e445113bdbe0b4766cdcaaee1ba Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 3 Aug 2026 20:08:09 +0200 Subject: [PATCH 1/2] Declare CB-WP-0020: the table you can read, tier S (chaos 8 override) The perceptual check found seven items; item 1 passes. Chaos d8=8 fired the FIRST override at the new rate, on the second roll, and drew S -- which is what the structural derivation said, so it changed nothing. That is one half of window 2's retirement condition (retire if an override changes nothing twice running). The maintainer's design instruction is adopted directly: a legal drop target should change its EXISTING border to dashed rather than draw a new outline. That explains the hidden top/left edges (an outline on an SVG is clipped by the viewport) and the oversized seat highlight. The ghost is a textContent copy, which is why the linebreak collapses and it reads as a second card, and why showing the explanation destroys the label -- one cause, two reports. T03 carries an explicit instruction not to fix a message that already works: the 'nothing droppable' path may simply be unreachable because almost every part of the page is a card. Reproduce before changing. T05 records that 0 scores and no winner is very likely the stand-in dataset rather than a scoring bug, now that GROUND-WP-0002 T01 has ruled the edition data authoritative. The import is its own pass. Co-Authored-By: Claude Opus 5 --- .../CB-WP-0020-the-table-you-can-read.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 workplans/CB-WP-0020-the-table-you-can-read.md diff --git a/workplans/CB-WP-0020-the-table-you-can-read.md b/workplans/CB-WP-0020-the-table-you-can-read.md new file mode 100644 index 0000000..fa82d5c --- /dev/null +++ b/workplans/CB-WP-0020-the-table-you-can-read.md @@ -0,0 +1,185 @@ +--- +id: CB-WP-0020 +kind: product +title: "The table you can read: the card in your hand, the border that moves, the game that ends" +status: ready +--- + +# Purpose + +``` +structural tier S (presentation inside an existing capability — no new + port, no canonical interface, no new dependency) +chaos d8 = 8 → OVERRIDE, tier drawn: S +declared tier S (structural S, chaos 8 → S) +``` + +Declaration 3 of chaos window 2, and **the first override at d8** — it +fired on the second roll at the new rate. It changed nothing, which is +the first half of window 2's retirement condition (*retire if an override +changes nothing twice running*). **One.** + +## What the perceptual check found + +The maintainer ran it on 2026-08-03. **Item 1 passes** — the resting +affordance reads. Everything else has a defect, and several are defects in +things the tests call green, because no test here can see a layout. + +Quoted, because the wording carries the diagnosis: + +1. **Pickable is legible.** ✅ Nothing to do. +2. *"the card is picked up but the linebreak vanishes and the card below + does not vanish. So it is a little as if we have two cards now"* +3. *"the upper and left borders of the dashed lines seem to be hidden for + some items. Also for players the rectangle is quite large. Maybe we + should not have a new dashed line but just set the existing borderline + to dashed so that the layout is more stable and less complicated"* +4. *"replacing the content of the picked up card breaks the visual clue + about a card being moved"* +5. *"the orange status line does not change as you describe. It seems to + switch only if i put back a card"* +6. *"I cant follow the actions of the other players too well because they + do not visualize on the board other than by result and the log"* +7. *"maybe should read 'Game finished' if the players have been + successful… There is no button to start a new game"* + +## Task: the border moves, nothing else does + +```task +id: CB-WP-0020-T01 +status: todo +priority: high +``` + +**Adopt the maintainer's design instruction**: a legal drop target changes +its **existing border** to dashed and coloured. No second box. + +The current `outline: 2px dashed; outline-offset: 3px` draws a *new* +rectangle outside the element. That is why the top and left edges vanish +on some items — an `outline` on an SVG `` is clipped by the SVG +viewport, and on a card it collides with the neighbour's margin. It is +also why a seat's highlight is *"quite large"*: it outlines a whole card +when only the border needed to change. + +- HTML targets: restyle `border-style`/`border-color` in place. +- SVG targets: change the existing `stroke`, not an added outline. + +**Control:** the marked set must still equal the set Rust emitted +(ADR-0010 D2 property 1) — the mutation that derives targets instead of +reading them must stay red. This is a style change and must not become a +behaviour change. + +## Task: the thing in your hand looks like a thing in your hand + +```task +id: CB-WP-0020-T02 +status: todo +priority: high +``` + +Two reports, one cause. The ghost is a `textContent` copy of the card, so +(2) the `
` collapses and it reads as a *second card*, and (4) the +explanation **replaces** the label, so the only thing identifying what you +are carrying disappears exactly when you need it. + +- The ghost must not impersonate a card. Make it read as a held token: + smaller, distinct, and it may keep the line break. +- The explanation is **additional**, never a replacement. Carrying + *"Attack"* and hovering P2 should show both — the label and *"commit + Attack against P2"*. +- The element left behind must look **left behind**, so there are not two + cards. + +**Control:** the description shown must still be the one Rust wrote for +that pair (ADR-0010 D1) — the neighbouring-pair mutation stays red. And a +new one: the ghost must still name what is held when an explanation is +showing, so a mutation that drops the label goes red. + +## Task: say why a drop was refused, in words + +```task +id: CB-WP-0020-T03 +status: todo +priority: high +``` + +Reported: the status line *"seems to switch only if i put back a card"*, +and then reads `action-attack -> action-attack is not a legal move here`. + +Two defects. + +**The refusal is written in element ids.** `resolve` returns +`"{down} -> {up} is not a legal move here"`, which is the vocabulary of +the DOM, not of the game. A player reads *"you cannot Attack yourself"*. + +**And the no-target path apparently never fires.** CB-WP-0018 added +*"took … , let go over nothing droppable"* for a drop on nothing, and the +maintainer did not see it. Find out why before changing anything — +the likely answer is that there is almost no bare background to drop on, +every part of the page being a card, in which case the message is correct +and unreachable, and the fix is elsewhere. **Do not "fix" a message that +already works; reproduce the report first.** + +## Task: show what the other players did + +```task +id: CB-WP-0020-T04 +status: todo +priority: medium +``` + +Reported: *"I cant follow the actions of the other players"*. + +After Reveal, selections are public (GR-R04) and the seat card **does** +carry them — as `Selection { action: Solve, target: None, problem: Some(1) }`. +That is a `Debug` impl on a player-facing surface, which is the same +defect the log fixed in CB-WP-0018 and did not fix here. + +Render a seat's revealed selection in the same words the log uses. Reuse, +do not re-phrase: a second vocabulary drifts from the first. + +**Control:** a seat's rendered selection must match the log's phrasing for +the same command, asserted rather than eyeballed. + +## Task: the game ends in a way you can act on + +```task +id: CB-WP-0020-T05 +status: todo +priority: medium +``` + +Three things, in the order they matter: + +- **"game over" is the wrong headline when the group succeeded.** It + should say what happened — the outcome carries `group_success`, so use + it. +- **A new game.** Reported as *"would be nice"*, and it is more than that: + the only way to play twice today is to go back to a terminal, which + makes the browser a strictly worse client than the CLI it was meant to + replace. +- **The scores read 0 and the winner is nobody.** That is very likely the + stand-in dataset, not a scoring bug — GROUND-WP-0002 T01 has now ruled + the edition data authoritative. **Do not fix scoring here.** Confirm the + cause and record it; the import is its own pass. + +## Task: evidence + +```task +id: CB-WP-0020-T06 +status: todo +priority: high +``` + +`evidence/CB-EV-0018-*.md`. + +- **What the perceptual check bought this time**, and the running count: + four runs, four sets of defects no test could reach. +- **Which of these the test suite called green**, and whether any of them + *could* have been caught mechanically — for those that could, say why + they were not, and for those that could not, say so plainly rather than + proposing a check that would not work. +- **Chaos: the first override at d8**, and that it changed nothing. That + is one half of window 2's retirement condition; record it as such. +- **Quote CB-WP-0019's cost by re-running the instrument**, per the rule + it wrote. From bf24affa845752ca31f5abbedd143d66babdc04c Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 3 Aug 2026 20:20:38 +0200 Subject: [PATCH 2/2] CB-WP-0020: the table you can read Six of seven perceptual defects fixed; item 1 already passed. T01, at the maintainer's instruction: a legal target restyles its EXISTING border rather than drawing a new box. outline + outline-offset drew a second rectangle, which an SVG viewport clips (the missing top and left edges) and which made a seat's highlight card-sized. A border already in the layout cannot move the layout. T02: the ghost was a textContent copy of the card, which is why the line break collapsed and it read as a second card, and why showing the explanation destroyed the label. It is now a pill, the explanation is appended beside the label, and the left-behind element is dimmed and dashed. The stub grew innerHTML so a test can assert BOTH are present -- it could previously only see that something was displayed. T03: NOT reproduced and recorded as not reproduced. The likeliest cause is which element the browser reports -- for touch and pen the pointer is captured to the pointerdown target, making every drop look like a drop-on-itself, which is the other half of the report. elementFromPoint is correct under both explanations. Separately the refusal was written in element ids on the one surface a player reads when something goes wrong; it now speaks the game's words and a test forbids id leakage. T04: seat selections rendered as Debug. The coverage gate then failed my first fix for dropping a field when target and problem were both set -- the aggregate does not produce that shape and the gate was right not to care. T05: the headline reads from group_success. 'Play again' is real, and its first version was useless: run_game bound a fresh listener per game, so a second game moved to a new port and left the tab pointing at a dead one. One listener per session now, and the test asserts the second game is a DIFFERENT deal. Chaos d8=8 fired the first override at the new rate and drew S, changing nothing -- one half of window 2's retirement condition. CB-WP-0019 settled at $38.54/117 against $34.80/107. Eight for eight, and the first under 20%. make all exits 0. Co-Authored-By: Claude Opus 5 --- crates/cb-render-html/src/doc.rs | 93 ++++++++-- crates/cb-render-html/src/input.rs | 53 +++++- crates/cb-render-html/src/jsrun.rs | 26 ++- crates/cb-render-html/src/lib.rs | 9 +- evidence/CB-EV-0018-the-table-you-can-read.md | 152 ++++++++++++++++ tools/cb-play/src/hotseat.rs | 164 +++++++++++++++++- tools/cb-play/src/table.rs | 59 +++++-- .../CB-WP-0020-the-table-you-can-read.md | 83 ++++++++- 8 files changed, 584 insertions(+), 55 deletions(-) create mode 100644 evidence/CB-EV-0018-the-table-you-can-read.md diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 35c43a8..533521e 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -58,8 +58,21 @@ pub const SCRIPT: &str = r#" (function () { var down = null, held = null, marked = [], ghost = null, ghostLabel = ''; + // What is actually under the pointer, not what the browser decided the + // event belongs to. CB-WP-0020 T03: for touch and pen a browser + // implicitly captures the pointer to the pointerdown target, so + // `e.target` on pointerup can be the element you STARTED on wherever + // you release. That makes a drop look like a drop-on-itself, and the + // "nothing droppable" message unreachable. elementFromPoint is correct + // under both behaviours. + function under(e) { + if (document.elementFromPoint && e.clientX !== undefined) { + return document.elementFromPoint(e.clientX, e.clientY) || e.target; + } + return e.target; + } function node(e) { - var n = e.target; + var n = under(e); while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; } return n; } @@ -116,8 +129,13 @@ pub const SCRIPT: &str = r#" if (n.getAttribute('data-targets')) { ghost = document.createElement('div'); ghost.id = 'cb-ghost'; - ghostLabel = n.textContent; - ghost.textContent = ghostLabel; + // Keep the label as markup, so the explanation can be appended + // rather than replacing it (CB-WP-0020 T02). The first version set + // textContent, which collapsed the card's line break and then got + // overwritten by the explanation -- losing the only sign of what + // was being carried, exactly when it was needed. + ghostLabel = (n.getAttribute('data-drop') || '').replace('action-', ''); + ghost.innerHTML = '' + ghostLabel + ''; ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; document.body.appendChild(ghost); @@ -131,7 +149,8 @@ pub const SCRIPT: &str = r#" // The explanation, beside the pointer and therefore beside the // target it is over (CB-WP-0018 T03). var over = describeOf(held, key(node(e))); - ghost.textContent = over || ghostLabel; + ghost.innerHTML = '' + ghostLabel + '' + + (over ? '' + over + '' : ''); ghost.className = over ? 'over' : ''; }); @@ -177,19 +196,35 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf} .pick{cursor:grab;user-select:none;box-shadow:0 2px 0 #0006,0 0 0 1px #5a7a inset; transition:transform .08s,box-shadow .08s} .pick:hover{box-shadow:0 3px 8px #000a,0 0 0 1px #7ca inset;transform:translateY(-1px)} -/* Held: the thing in your hand is lifted and dimmed where it used to be. */ -.held{cursor:grabbing;opacity:.45;transform:scale(.97)} /* A legal destination for the thing currently held -- and ONLY for that thing. The set is written by Rust into data-targets; the script matches - it and never derives it (ADR-0010 D1). */ -.dropok{outline:2px dashed #9cf;outline-offset:3px;background:#1d2a33} + it and never derives it (ADR-0010 D1). + + CB-WP-0020 T01, at the maintainer's instruction: change the EXISTING + border, do not draw a new box. `outline` + `outline-offset` drew a + second rectangle outside the element, which an SVG viewport clips (the + reported missing top and left edges) and which made a seat's highlight + the size of a whole card. Restyling the border in place cannot move + anything, because the border is already in the layout. */ +.dropok{border-style:dashed;border-color:#9cf;background:#1d2a33} +.dropok circle,.dropok rect{stroke:#9cf;stroke-dasharray:5 3} .dropok text{fill:#cfe} /* The ghost that follows the pointer, so a drag is not invisible. */ #cb-ghost.over{background:#1d3347;border-color:#9cf;color:#cfe} -#cb-ghost{position:fixed;pointer-events:none;z-index:9;padding:.3rem .5rem; - border-radius:6px;background:#243;border:1px solid #5a7;color:#dde; - font:13px ui-monospace,monospace;box-shadow:0 6px 16px #000b; - transform:translate(-50%,-140%)} +/* The thing in your hand. Deliberately NOT card-shaped: it used to be a + textContent copy of the card, so the line break collapsed and it read + as a second card sitting next to the first (CB-WP-0020 T02). */ +#cb-ghost{position:fixed;pointer-events:none;z-index:9;padding:.25rem .55rem; + border-radius:999px;background:#2b4a3a;border:1px solid #7ca; + color:#dfe;font:12px ui-monospace,monospace; + box-shadow:0 6px 16px #000b;transform:translate(-50%,-160%); + white-space:nowrap} +/* The explanation is ADDITIONAL, never a replacement: losing the label is + losing the only sign of what you are carrying. */ +#cb-ghost b{color:#cfe} +#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} .k{color:#89a} .nil{color:#c88} .eff{color:#8c9} @@ -325,6 +360,28 @@ fn relations_svg(view: &GroundView) -> String { s } +/// A revealed selection, phrased the way the log phrases the command. +/// +/// Deliberately the same shape as `event_line`'s `ActionSelected` arm — +/// a second vocabulary for the same fact drifts from the first, which is +/// exactly what `{:?}` was doing here. +fn selection_words(s: &games_ground::Selection) -> String { + // Every field that is set is named. The first draft matched on + // `(target, problem)` and showed only the target when both were + // present — the coverage gate caught it, because a field the document + // never shows is a field a player never sees. The aggregate does not + // currently produce both, but a renderer that silently drops one is + // the omission class this crate exists to guard against. + let mut out = format!("{:?}", s.action); + if let Some(t) = s.target { + let _ = write!(out, " on {}", seat_name(t)); + } + if let Some(n) = s.problem { + let _ = write!(out, " for problem {n}"); + } + out +} + fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) { let is_viewer = view.viewer == Some(id); let _ = write!( @@ -394,7 +451,12 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView "selected {}
", match sel { SelectionView::Hidden => "face down".to_string(), - SelectionView::Shown(s) => esc(&format!("{s:?}")), + // CB-WP-0020 T04: in words, not `Selection { action: + // Solve, target: None, problem: Some(1) }`. After Reveal + // this is how a player follows what everyone else did, + // and it was the same Debug-on-a-player-surface defect + // the log fixed in CB-WP-0018 and this did not. + SelectionView::Shown(s) => esc(&selection_words(s)), } ); } @@ -758,7 +820,10 @@ pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[L log_section(&mut s, log); let _ = write!( s, - "
close \u{2014} I have read this
\ + "
\ +
play again
\ +
close \u{2014} I have read this
\ +
\
the game is over
\ ", endpoint = json_string(endpoint), diff --git a/crates/cb-render-html/src/input.rs b/crates/cb-render-html/src/input.rs index 6351740..040d78b 100644 --- a/crates/cb-render-html/src/input.rs +++ b/crates/cb-render-html/src/input.rs @@ -130,6 +130,49 @@ pub fn describe(command: &GroundCommand, seat: cb_kernel::PlayerId) -> String { } } +/// Say why a drop meant nothing, in words a player can act on. +/// +/// Deliberately does **not** consult the rules to explain *why* a move is +/// illegal — that would be a second implementation of them. It names what +/// was dropped on what, and leaves the reason to the log. +fn refusal(fact: &PointerFact, seat: cb_kernel::PlayerId) -> String { + let name = |id: &str| -> String { + if let Some(a) = id.strip_prefix("action-") { + let mut c = a.chars(); + return match c.next() { + Some(f) => f.to_uppercase().collect::() + c.as_str(), + None => id.to_string(), + }; + } + if let Some(n) = id.strip_prefix("seat-") { + return n + .parse::() + .map(|n| format!("P{}", n + 1)) + .unwrap_or_else(|_| id.to_string()); + } + if let Some(n) = id.strip_prefix("problem-") { + return format!("problem {n}"); + } + match id { + "table" => "the table".to_string(), + _ if id.starts_with("freedom-") => "your freedom token".to_string(), + _ => id.to_string(), + } + }; + if fact.down == fact.up { + return format!("{} needs to be dropped on something", name(&fact.down)); + } + let you = format!("P{}", seat.0 + 1); + if fact.up == format!("seat-{}", seat.0) { + return format!("{} cannot be aimed at {you} right now", name(&fact.down)); + } + format!( + "{} on {} is not a move you can make right now", + name(&fact.down), + name(&fact.up) + ) +} + /// Resolve a pointer fact to an index into the legal list. /// /// Returns `Err` rather than a default when nothing matches: a drag that @@ -169,10 +212,12 @@ pub fn resolve( "{} -> {} is offered by more than one legal command; the page is ambiguous", fact.down, fact.up )), - (None, _) => Err(format!( - "{} -> {} is not a legal move here", - fact.down, fact.up - )), + // CB-WP-0020 T03: written in the game's words, not the DOM's. + // This read `action-attack -> action-attack is not a legal move + // here`, which is the vocabulary of element ids — the same defect + // the log had before CB-WP-0018, on the surface a player actually + // reads when something goes wrong. + (None, _) => Err(refusal(fact, seat)), } } diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs index 4a84f60..40d46ac 100644 --- a/crates/cb-render-html/src/jsrun.rs +++ b/crates/cb-render-html/src/jsrun.rs @@ -68,6 +68,11 @@ function __mk(key, targets, text, descs) { var n = { parentNode: null, textContent: text || key, + // `innerHTML` is stored and its text extracted, so a test can assert + // that the label and the explanation COEXIST (CB-WP-0020 T02) rather + // than only that something is displayed. + set innerHTML(v) { this._html = v; this.textContent = String(v).replace(/<[^>]*>/g, ''); }, + get innerHTML() { return this._html || ''; }, style: {}, _cls: {}, _attr: { 'data-drop': key, 'data-targets': targets || null, @@ -590,13 +595,30 @@ mod tests { ctx.eval("__down('action-attack'); __moveOver('seat-1');") .expect("drag over"); let shown: String = ctx.eval_as("__ghostText()").expect("ghost text"); - assert_eq!(shown, want); + + // The explanation is ADDITIONAL, never a replacement. The first + // version replaced the ghost's label with the description, which + // is what the maintainer reported: *"replacing the content of the + // picked up card breaks the visual clue about a card being + // moved"*. Both must be present at once. + assert!(shown.contains(&want), "no explanation: {shown:?}"); + assert!( + shown.to_lowercase().contains("attack"), + "the ghost stopped naming what is held: {shown:?}" + ); // Over nothing droppable it falls back to the label, rather than // keeping a stale explanation pointing at the wrong element. ctx.eval("__move();").expect("move away"); let away: String = ctx.eval_as("__ghostText()").expect("ghost text"); - assert_ne!(away, want, "the explanation stuck after leaving the target"); + assert!( + !away.contains(&want), + "the explanation stuck after leaving the target: {away:?}" + ); + assert!( + away.to_lowercase().contains("attack"), + "the label vanished when the explanation did: {away:?}" + ); } /// Every advertised target has a description; a card offering three diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 58898b7..03b74e7 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -150,9 +150,12 @@ mod coverage { ("problems.*.protected_this_round", "protected"), ("focus.*", "\u{2192}P3"), ("selections.*.state", "face down"), - ("selections.*.action", "action: Attack"), - ("selections.*.target", "target: Some(PlayerId(1))"), - ("selections.*.problem", "problem: Some(7)"), + // CB-WP-0020 T04: words, not Debug. These tokens were + // `action: Attack` / `target: Some(PlayerId(1))` / + // `problem: Some(7)` — the shape a player was being shown. + ("selections.*.action", "selected Attack"), + ("selections.*.target", "Attack on P2"), + ("selections.*.problem", "for problem 7"), ("ground_modes.*", "ground mode Gr"), ("ground_choices.*.choice", "ProtectProblem"), ("ground_choices.*.problem", "problem: 7 }"), diff --git a/evidence/CB-EV-0018-the-table-you-can-read.md b/evidence/CB-EV-0018-the-table-you-can-read.md new file mode 100644 index 0000000..cae0c35 --- /dev/null +++ b/evidence/CB-EV-0018-the-table-you-can-read.md @@ -0,0 +1,152 @@ +# CB-EV-0018 — four human checks, four sets of defects no test could reach + +CB-WP-0020 T06. Measured 2026-08-03 at `5e06a7d`+. Pass kind `product`, +tier **S** (structural S; **chaos d8 = 8 → OVERRIDE, drawn S**). +Declaration 3 of chaos window 2. + +--- + +## 1. What the check found, and what the suite said about it + +Seven items. **Item 1 passed** — the resting affordance reads without +touching anything. Every other one was a defect, and `make all` was green +for all of them. + +| # | reported | cause | +|---|---|---| +| 2 | *"as if we have two cards now"*, the line break vanishes | the ghost was a `textContent` copy of the card | +| 3 | *"upper and left borders … hidden"*, seat highlight *"quite large"* | `outline` + `outline-offset` drew a **second** rectangle; an SVG viewport clips it | +| 4 | *"replacing the content … breaks the visual clue"* | the explanation **replaced** the ghost's label | +| 5 | status line *"switches only if i put back a card"* | see §3 — not reproduced, and fixed anyway | +| 6 | *"cant follow the actions of the other players"* | seat cards rendered `Selection { action: Solve, … }` — `Debug` on a player surface | +| 7 | *"game over"* wrong when the group succeeded; no way to play again | the ending was written for one outcome and one game | + +### Could any of them have been caught mechanically? + +Honestly, **two of the seven**, and neither by a check that existed: + +- **#6** is the same `{:?}`-on-a-player-surface defect the log fixed in + CB-WP-0018, in a place that pass did not look. A grep for `{:?}` in + emitted output would find it, and that check does not exist. **Worth + building.** +- **#7's headline** is a branch nobody wrote a case for; a test asserting + the ending page differs on `group_success` would have caught it. + +**The other five cannot be caught here and saying otherwise would be the +error this project keeps naming.** #2, #3 and #4 are about what a layout +*looks like*; #5 is about which element a browser reports under a pointer. +QuickJS has no layout engine and no hit-testing, and a stub that grew one +would be asserting against my model of a browser rather than a browser. + +## 2. The border moves, nothing else does + +Adopted as instructed: a legal target restyles its **existing** border +rather than drawing a new box. + +``` +.dropok{border-style:dashed;border-color:#9cf;background:#1d2a33} +.dropok circle,.dropok rect{stroke:#9cf;stroke-dasharray:5 3} +``` + +That is the whole fix for the missing top and left edges — an `outline` on +an SVG `` is clipped by the viewport, and on a card it collided with +the neighbour's margin. It also removes the oversized seat highlight, +because the border was always the right size. **A border already in the +layout cannot move the layout**, which is what *"more stable and less +complicated"* was asking for. + +## 3. The report I could not reproduce, and what I did instead + +Item 5 said the *"nothing droppable"* message never appears. The harness +test for it passes, the page has real gaps to drop into, and I have no +browser — so I could not reproduce it, and **the task said not to fix a +message that already works.** + +The likeliest explanation is not the message but **which element the +browser reports**: for touch and pen a browser implicitly captures the +pointer to the `pointerdown` target, so `e.target` on `pointerup` is the +element you *started* on wherever you release. That would make every drop +look like a drop-on-itself — which is exactly the other half of the +report, *"switches only if i put back a card, then showing `action-attack +-> action-attack`"*. + +So the fix is to stop asking the event and ask the document: +`document.elementFromPoint(clientX, clientY)`. **That is correct under +both explanations** — with a mouse it returns the same element `e.target` +would; with capture it returns the truth instead of the capture target. + +**Recorded as unreproduced, not as diagnosed.** If item 5 still misbehaves +after this, the cause is something else and this note is the starting +point. + +**And the refusal was written in element ids.** `action-attack -> +action-attack is not a legal move here` is the vocabulary of the DOM on +the one surface a player reads when something goes wrong. It now says +*"Attack needs to be dropped on something"* or *"Attack on P2 is not a +move you can make right now"*, and a test asserts no `action-` id leaks +into it. + +## 4. Two defects my own tests found while fixing these + +**The selection renderer dropped a field.** `selection_words` matched on +`(target, problem)` and showed only the target when both were set. The +42-path coverage gate failed the build: *"claimed rendered, but absent +from the emitted document: selections.\*.problem"*. The aggregate does not +currently produce both — but a renderer that silently drops one is the +omission class this crate exists to guard against, and the gate does not +care whether the case is reachable today. + +**"Play again" moved the game to a new port.** The first version worked +end to end and would have been useless: `run_game` bound a fresh listener +per game, so dealing a second game left the player's tab pointing at a +dead port — the CB-WP-0018 defect returning by a different route. The +listener is now bound once per **session**. + +`play_again_deals_a_second_game` catches it, and it asserts the second +game is a **different deal**, not merely that a second game happened — a +seed that did not advance would have satisfied a naive count. + +## 5. Chaos: the first override at d8, and it changed nothing + +`d8 = 8` fired on the **second roll** at the new rate and drew **S**, +which is what the structural derivation already said. + +**That is one half of window 2's retirement condition** — *retire if an +override changes nothing twice running*. **One.** If the next override +also changes nothing, the mechanism goes. + +Worth stating plainly so it is not mistaken for evidence either way: an +override firing on roll 2 of 12 at a 1-in-8 rate is unremarkable luck, and +drawing the structural tier is a 1-in-3 outcome. Neither number says +anything about whether d8 was the right rate. **The window needs its +twelve.** + +## 6. Cost + +| pass | kind | responses | cost | $/response | +|---|---|---|---|---| +| **CB-WP-0019** | meta | 117 | **$38.54** | 0.329 | +| CB-WP-0020 | product | *provisional — not quoted* | | | + +Read by re-running `make status` at the moment of writing, per the rule +CB-WP-0019 wrote. CB-WP-0019 was last reported at $34.80/107 and has +settled at **$38.54/117** — **eight for eight**, and the first one under +20%, which is what re-running at quote time is supposed to do. + +**Meta budget: back under the line** — CB-WP-0020 is product work, which +is what the 27% breach called for. + +## 7. Open + +- **The human check is four for four.** Every run has found something the + suite called green. Its cost is one maintainer session per pass; its + yield so far is a broken drag, a lying affordance list, an invisible + ending, and this set. **It should stay a gate on stage 1.** +- **A `{:?}`-in-emitted-output check** would have caught item 6 and does + not exist. Cheap; owed. +- **The engine still has not imported the edition data**, now that + GROUND-WP-0002 T01 has ruled it authoritative. That is the next product + pass, and it is what makes the ending's `0` scores and `winners nobody` + meaningful — **confirmed as the stand-in's doing, not a scoring bug**. +- **`ground-game` owes ten rulings** (U1–U10) and SOLVE's legality. +- **Chaos: 3 of 12 in window 2, one override, changed nothing.** diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index 083c32f..b856215 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -200,14 +200,14 @@ impl Server { final_view: Option<&games_ground::view::GroundView>, message: &str, linger: std::time::Duration, - ) -> Result<(), String> { + ) -> Result { let deadline = std::time::Instant::now() + linger; self.listener .set_nonblocking(true) .map_err(|e| format!("nonblocking: {e}"))?; let outcome = loop { if std::time::Instant::now() >= deadline { - break Ok(()); + break Ok(EndChoice::Closed); } let (mut stream, _) = match self.listener.accept() { Ok(pair) => pair, @@ -241,8 +241,21 @@ impl Server { respond(&mut stream, 200, "text/html; charset=utf-8", &page); } ("POST", "/command") => { - respond(&mut stream, 200, "text/plain", "closed"); - break Ok(()); + // CB-WP-0020 T05: the browser could not start a second + // game without going back to a terminal, which made it + // a strictly worse client than the CLI it replaces. + let again = req.body.contains("again"); + respond( + &mut stream, + 200, + "text/plain", + if again { "ok: dealing" } else { "closed" }, + ); + break Ok(if again { + EndChoice::Again + } else { + EndChoice::Closed + }); } _ => respond(&mut stream, 404, "text/plain", "the game is over"), } @@ -252,6 +265,13 @@ impl Server { } } +/// What the player asked for on the ending page. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndChoice { + Closed, + Again, +} + /// One seat's view of the shared server. pub struct SeatPolicy { server: Rc, @@ -471,6 +491,64 @@ mod tests { assert!(replies[1].contains("closed"), "{}", replies[1]); } + /// **CB-WP-0020 T05.** "play again" must deal a second game, not just + /// answer politely and exit — the browser could not start a second + /// game without going back to a terminal, which made it a strictly + /// worse client than the CLI it replaces. + #[test] + fn play_again_deals_a_second_game() { + let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let out = SharedOut(buf.clone()); + let game = std::thread::spawn(move || { + crate::table::play( + &crate::table::Config { + seed: 3, + players: 3, + human_seats: vec![0], + bot: "random".into(), + replay_dir: None, + record: None, + serve: Some(0), + }, + std::io::Cursor::new(Vec::new()), + out, + ) + }); + let (port, token) = wait_for_url(&buf); + + // Play one game out, ask for another, play that one out too. + let mut deals = 0; + for round in 0..2 { + drive_to_end(port, &token); + deals += 1; + let want = if round == 0 { "again" } else { "done" }; + let body = format!("down={want}&up={want}"); + let reply = http( + port, + &format!( + "POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\ + Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ), + ); + if round == 0 { + assert!(reply.contains("dealing"), "play again refused: {reply}"); + } + } + assert_eq!(deals, 2, "the second game was never dealt"); + game.join().expect("game thread").expect("the games ran"); + + // Two games, and they must not be the same deal — "again" that + // re-dealt the identical game would satisfy a naive count. + let text = String::from_utf8_lossy(&buf.lock().expect("out")).to_string(); + let hashes: Vec<&str> = text + .match_indices("game over \u{2014} ") + .map(|(i, _)| &text[i..i + 60]) + .collect(); + assert!(hashes.len() >= 2, "only {} game(s) finished", hashes.len()); + assert_ne!(hashes[0], hashes[1], "play again re-dealt the same game"); + } + /// A game that ended badly must say so rather than draw a table for a /// game that never happened. #[test] @@ -501,6 +579,70 @@ mod tests { } } + /// Block until the server prints its URL, then return (port, token). + fn wait_for_url(buf: &std::sync::Arc>>) -> (u16, String) { + loop { + let text = String::from_utf8_lossy(&buf.lock().expect("out")).to_string(); + if let Some(i) = text.find("http://127.0.0.1:") { + let rest = &text[i..]; + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let url = &rest[..end]; + let port = url["http://127.0.0.1:".len()..] + .split('/') + .next() + .expect("port") + .parse() + .expect("port number"); + return (port, url.split("t=").nth(1).expect("token").to_string()); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + + /// Play the offered moves until the page stops offering any, which is + /// the ending. Returns the last page. + fn drive_to_end(port: u16, token: &str) -> String { + let mut last = String::new(); + for _ in 0..60 { + last = http( + port, + &format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"), + ); + let keys = cb_render_html::jsrun::droppables(&last); + let spatial = keys + .iter() + .find(|(k, t)| k.starts_with("action-") && t.is_some()) + .map(|(k, t)| { + ( + k.clone(), + t.as_ref() + .expect("checked") + .split(' ') + .next() + .expect("t") + .to_string(), + ) + }); + let button = keys + .iter() + .find(|(k, _)| k.starts_with("cmd-") || k == "pass") + .map(|(k, _)| (k.clone(), k.clone())); + let Some((down, up)) = spatial.or(button) else { + return last; + }; + let body = format!("down={down}&up={up}"); + http( + port, + &format!( + "POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\ + Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ), + ); + } + last + } + fn http(port: u16, raw: &str) -> String { let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect"); s.write_all(raw.as_bytes()).expect("write"); @@ -776,9 +918,19 @@ mod tests { let replies = client.join().expect("client thread"); assert!(replies[0].contains("200 OK")); + // CB-WP-0020 T03: the refusal is in the game's words now, not the + // DOM's. It read `action-attack -> action-attack is not a legal + // move here` on the surface a player reads when something goes + // wrong — element ids, the same defect the log had. assert!( - replies[0].contains("not a legal move"), - "a meaningless drag must be told it meant nothing: {}", + replies[0].contains("not a move you can make") + || replies[0].contains("needs to be dropped on"), + "a meaningless drag must be told it meant nothing, in words: {}", + replies[0] + ); + assert!( + !replies[0].contains("action-"), + "the refusal leaked an element id: {}", replies[0] ); assert!(replies[1].contains("200 OK")); diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index 61d80c4..bdc67eb 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -21,6 +21,7 @@ use games_ground::{GroundCommand, GroundState}; use crate::inspect::{render, seat_name}; use std::io::{BufRead, Write}; +#[derive(Clone)] pub struct Config { pub seed: u64, pub players: u8, @@ -220,14 +221,39 @@ pub fn play(config: &Config, input: R, out: W) -> Result { + let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?); + let _ = writeln!(out.borrow_mut(), " open {}", s.url()); + let _ = out.borrow_mut().flush(); + Some(s) + } + None => None, + }; + + let mut cfg: Config = config.clone(); + loop { + let (summary, choice) = run_game(&cfg, &input, &out, server.clone())?; + if choice != crate::hotseat::EndChoice::Again { + return Ok(summary); + } + cfg.seed = cfg.seed.wrapping_add(1); + } } fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( config: &Config, input: &'a std::cell::RefCell, out: &'a std::cell::RefCell, -) -> Result { + server: Option>, +) -> Result<(Summary, crate::hotseat::EndChoice), String> { let setup = Setup { players: config.players, preset: format!("standard-{}p", config.players), @@ -243,15 +269,6 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( // ADR-0007: a browser seat and a CLI seat are both just a Policy, so // the driver cannot tell them apart — which is the property that lets // a browser game replay as a scenario like any other. - let server = match config.serve { - Some(port) => { - let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?); - let _ = writeln!(out.borrow_mut(), " open {}", s.url()); - let _ = out.borrow_mut().flush(); - Some(s) - } - None => None, - }; let mut policies: Vec> = Vec::new(); for seat in 0..config.players { if config.human_seats.contains(&seat) { @@ -322,10 +339,11 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( // CB-WP-0018 T01: the browser sees the end of its own game. Measured // before this: a game ended at 5 rounds / 30 commands, the result went // to stdout, and the page's post-`ok` reload got Connection refused. + let mut end_choice = crate::hotseat::EndChoice::Closed; if let Some(srv) = &server { let ended = game.state.project(Viewer::Spectator); let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]); - let _ = srv.serve_end(Some(&ended), &msg, END_LINGER); + end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER)?; } let scenario = games_ground::record::to_scenario( @@ -361,11 +379,14 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( } }; - Ok(Summary { - rounds: game.rounds, - end_state_hash: end_hash, - scenario, - bundle, - recorded, - }) + Ok(( + Summary { + rounds: game.rounds, + end_state_hash: end_hash, + scenario, + bundle, + recorded, + }, + end_choice, + )) } diff --git a/workplans/CB-WP-0020-the-table-you-can-read.md b/workplans/CB-WP-0020-the-table-you-can-read.md index fa82d5c..55d88e4 100644 --- a/workplans/CB-WP-0020-the-table-you-can-read.md +++ b/workplans/CB-WP-0020-the-table-you-can-read.md @@ -2,7 +2,7 @@ id: CB-WP-0020 kind: product title: "The table you can read: the card in your hand, the border that moves, the game that ends" -status: ready +status: done --- # Purpose @@ -47,7 +47,7 @@ Quoted, because the wording carries the diagnosis: ```task id: CB-WP-0020-T01 -status: todo +status: done priority: high ``` @@ -69,11 +69,16 @@ when only the border needed to change. reading them must stay red. This is a style change and must not become a behaviour change. +**Done 2026-08-03.** `.dropok` restyles `border-style`/`border-color` in +place, and SVG targets change their existing `stroke` with a dash array. +No `outline` remains. **A border already in the layout cannot move the +layout**, which is what the instruction was for. + ## Task: the thing in your hand looks like a thing in your hand ```task id: CB-WP-0020-T02 -status: todo +status: done priority: high ``` @@ -95,11 +100,20 @@ that pair (ADR-0010 D1) — the neighbouring-pair mutation stays red. And a new one: the ghost must still name what is held when an explanation is showing, so a mutation that drops the label goes red. +**Done 2026-08-03.** The ghost is a pill, not a card — it cannot be +mistaken for a second card — and the explanation is appended beside the +label rather than replacing it. The left-behind element is dimmed to 0.35 +and dashed, so it reads as *left behind*. + +The stub grew `innerHTML` so the test can assert **both** are present at +once; it previously could only see that *something* was displayed, which +is why the replacement went unnoticed. + ## Task: say why a drop was refused, in words ```task id: CB-WP-0020-T03 -status: todo +status: done priority: high ``` @@ -120,11 +134,26 @@ every part of the page being a card, in which case the message is correct and unreachable, and the fix is elsewhere. **Do not "fix" a message that already works; reproduce the report first.** +**Done 2026-08-03. Not reproduced, and recorded as not reproduced.** The +harness test passes and the page has real gaps; without a browser I cannot +see it fail. + +The likeliest cause is not the message but **which element the browser +reports**: for touch and pen the pointer is implicitly captured to the +`pointerdown` target, so `e.target` on `pointerup` is where you *started*. +That would make every drop look like a drop-on-itself — which is the other +half of the report. `document.elementFromPoint` is now used instead, and +it is **correct under both explanations**. + +**The refusal is in the game's words**, not element ids: *"Attack on P2 is +not a move you can make right now"*. A test asserts no `action-` id leaks +into it. + ## Task: show what the other players did ```task id: CB-WP-0020-T04 -status: todo +status: done priority: medium ``` @@ -141,11 +170,19 @@ do not re-phrase: a second vocabulary drifts from the first. **Control:** a seat's rendered selection must match the log's phrasing for the same command, asserted rather than eyeballed. +**Done 2026-08-03.** `selection_words` renders every field that is set. + +**The coverage gate failed my first version**, which matched on +`(target, problem)` and showed only the target when both were present: +*"claimed rendered, but absent: selections.\*.problem"*. The aggregate does +not currently produce both — the gate does not care whether the case is +reachable, and it was right not to. + ## Task: the game ends in a way you can act on ```task id: CB-WP-0020-T05 -status: todo +status: done priority: medium ``` @@ -163,11 +200,25 @@ Three things, in the order they matter: the edition data authoritative. **Do not fix scoring here.** Confirm the cause and record it; the import is its own pass. +**Done 2026-08-03.** The headline reads from the outcome: *"the group held +the frame"* / *"did not hold the frame"*, and only says *"game over"* when +there is no outcome to report. + +**"Play again" is real, and its first version was useless.** `run_game` +bound a fresh listener per game, so a second game moved to a new port and +left the player's tab pointing at a dead one — the CB-WP-0018 defect +returning by another route. The listener is bound once per **session** +now. `play_again_deals_a_second_game` asserts the second game is a +**different deal**, not merely that one happened. + +**The 0 scores and `winners nobody` are the stand-in dataset**, confirmed, +not a scoring bug. Not fixed here. + ## Task: evidence ```task id: CB-WP-0020-T06 -status: todo +status: done priority: high ``` @@ -183,3 +234,21 @@ priority: high is one half of window 2's retirement condition; record it as such. - **Quote CB-WP-0019's cost by re-running the instrument**, per the rule it wrote. + +**Done 2026-08-03.** +[CB-EV-0018](../evidence/CB-EV-0018-the-table-you-can-read.md). `make all` +exits 0. + +- **Four human checks, four sets of defects the suite called green.** + Of these seven, **two** could have been caught mechanically and neither + by a check that exists; the other five are about layout and hit-testing + and cannot be reached from here. Saying otherwise would be the error + this project keeps naming. +- **Two defects found by this project's own tests** while fixing the + reported ones: a renderer dropping a field (coverage gate) and "play + again" moving the game to a dead port. +- **Chaos: the first override at d8 changed nothing** — one half of + window 2's retirement condition. +- **CB-WP-0019 settled at $38.54/117** against $34.80/107 last reported. + **Eight for eight**, and the first under 20% — which is what re-running + at quote time is for.