diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs
index 533521e..35c43a8 100644
--- a/crates/cb-render-html/src/doc.rs
+++ b/crates/cb-render-html/src/doc.rs
@@ -58,21 +58,8 @@ 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 = under(e);
+ var n = e.target;
while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; }
return n;
}
@@ -129,13 +116,8 @@ pub const SCRIPT: &str = r#"
if (n.getAttribute('data-targets')) {
ghost = document.createElement('div');
ghost.id = 'cb-ghost';
- // 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 + '';
+ ghostLabel = n.textContent;
+ ghost.textContent = ghostLabel;
ghost.style.left = e.clientX + 'px';
ghost.style.top = e.clientY + 'px';
document.body.appendChild(ghost);
@@ -149,8 +131,7 @@ 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.innerHTML = '' + ghostLabel + ''
- + (over ? '' + over + '' : '');
+ ghost.textContent = over || ghostLabel;
ghost.className = over ? 'over' : '';
});
@@ -196,35 +177,19 @@ 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).
-
- 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}
+ it and never derives it (ADR-0010 D1). */
+.dropok{outline:2px dashed #9cf;outline-offset:3px;background:#1d2a33}
.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}
-/* 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}
+#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%)}
.k{color:#89a}
.nil{color:#c88}
.eff{color:#8c9}
@@ -360,28 +325,6 @@ 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!(
@@ -451,12 +394,7 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView
"selected {}
",
match sel {
SelectionView::Hidden => "face down".to_string(),
- // 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)),
+ SelectionView::Shown(s) => esc(&format!("{s:?}")),
}
);
}
@@ -820,10 +758,7 @@ pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[L
log_section(&mut s, log);
let _ = write!(
s,
- "
\
-
play again
\
-
close \u{2014} I have read this
\
-
\
+ "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 040d78b..6351740 100644
--- a/crates/cb-render-html/src/input.rs
+++ b/crates/cb-render-html/src/input.rs
@@ -130,49 +130,6 @@ 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
@@ -212,12 +169,10 @@ pub fn resolve(
"{} -> {} is offered by more than one legal command; the page is ambiguous",
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)),
+ (None, _) => Err(format!(
+ "{} -> {} is not a legal move here",
+ fact.down, fact.up
+ )),
}
}
diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs
index 40d46ac..4a84f60 100644
--- a/crates/cb-render-html/src/jsrun.rs
+++ b/crates/cb-render-html/src/jsrun.rs
@@ -68,11 +68,6 @@ 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,
@@ -595,30 +590,13 @@ mod tests {
ctx.eval("__down('action-attack'); __moveOver('seat-1');")
.expect("drag over");
let shown: String = ctx.eval_as("__ghostText()").expect("ghost text");
-
- // 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:?}"
- );
+ assert_eq!(shown, want);
// 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!(
- !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:?}"
- );
+ assert_ne!(away, want, "the explanation stuck after leaving the target");
}
/// 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 03b74e7..58898b7 100644
--- a/crates/cb-render-html/src/lib.rs
+++ b/crates/cb-render-html/src/lib.rs
@@ -150,12 +150,9 @@ mod coverage {
("problems.*.protected_this_round", "protected"),
("focus.*", "\u{2192}P3"),
("selections.*.state", "face down"),
- // 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"),
+ ("selections.*.action", "action: Attack"),
+ ("selections.*.target", "target: Some(PlayerId(1))"),
+ ("selections.*.problem", "problem: Some(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
deleted file mode 100644
index cae0c35..0000000
--- a/evidence/CB-EV-0018-the-table-you-can-read.md
+++ /dev/null
@@ -1,152 +0,0 @@
-# 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 b856215..083c32f 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 {
+ ) -> Result<(), String> {
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(EndChoice::Closed);
+ break Ok(());
}
let (mut stream, _) = match self.listener.accept() {
Ok(pair) => pair,
@@ -241,21 +241,8 @@ impl Server {
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
}
("POST", "/command") => {
- // 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, 200, "text/plain", "closed");
+ break Ok(());
}
_ => respond(&mut stream, 404, "text/plain", "the game is over"),
}
@@ -265,13 +252,6 @@ 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,
@@ -491,64 +471,6 @@ 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]
@@ -579,70 +501,6 @@ 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");
@@ -918,19 +776,9 @@ 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 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].contains("not a legal move"),
+ "a meaningless drag must be told it meant nothing: {}",
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 bdc67eb..61d80c4 100644
--- a/tools/cb-play/src/table.rs
+++ b/tools/cb-play/src/table.rs
@@ -21,7 +21,6 @@ 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,
@@ -221,39 +220,14 @@ 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);
- }
+ run_game(config, &input, &out)
}
fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
config: &Config,
input: &'a std::cell::RefCell,
out: &'a std::cell::RefCell,
- server: Option>,
-) -> Result<(Summary, crate::hotseat::EndChoice), String> {
+) -> Result {
let setup = Setup {
players: config.players,
preset: format!("standard-{}p", config.players),
@@ -269,6 +243,15 @@ 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) {
@@ -339,11 +322,10 @@ 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]);
- end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER)?;
+ let _ = srv.serve_end(Some(&ended), &msg, END_LINGER);
}
let scenario = games_ground::record::to_scenario(
@@ -379,14 +361,11 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
}
};
- Ok((
- Summary {
- rounds: game.rounds,
- end_state_hash: end_hash,
- scenario,
- bundle,
- recorded,
- },
- end_choice,
- ))
+ Ok(Summary {
+ rounds: game.rounds,
+ end_state_hash: end_hash,
+ scenario,
+ bundle,
+ recorded,
+ })
}
diff --git a/workplans/CB-WP-0020-the-table-you-can-read.md b/workplans/CB-WP-0020-the-table-you-can-read.md
deleted file mode 100644
index 55d88e4..0000000
--- a/workplans/CB-WP-0020-the-table-you-can-read.md
+++ /dev/null
@@ -1,254 +0,0 @@
----
-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: done
----
-
-# 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: done
-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.
-
-**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: done
-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.
-
-**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: done
-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.**
-
-**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: done
-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.
-
-**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: done
-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.
-
-**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: done
-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.
-
-**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.