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.