From e8bb7263f229e0462ec44276754bbd45bfed8b7e Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 2 Aug 2026 22:59:05 +0200 Subject: [PATCH 1/5] Declare CB-WP-0018: the browser is a client, tier M Provenance: the maintainer reported 'after some time i get an empty page back. I guess the game crashes or ends but that is unclear as the ui disappears.' Reproduced by driving a real game to completion over HTTP: move 5 accepted, then GET / -> Connection refused. The game ENDED normally, 5 rounds and 30 commands, and its whole result -- coalitions, scores, winners, hash -- went to the terminal. next_choice only accepts connections inside a human decision point, so when play() returns the listener dies and the post-ok reload is refused. A crash and a win render identically: nothing. Same class as CB-WP-0016's silent drop. Also carries the chaos rate change CB-EV-0015 owed to the next declaration (d4 -> d8, second window of 12), which is what makes this structurally M. Rolled at the old d4=3, no override, because a rate changes when the decision lands and not retroactively. Co-Authored-By: Claude Opus 5 --- .../CB-WP-0018-the-browser-is-a-client.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 workplans/CB-WP-0018-the-browser-is-a-client.md diff --git a/workplans/CB-WP-0018-the-browser-is-a-client.md b/workplans/CB-WP-0018-the-browser-is-a-client.md new file mode 100644 index 0000000..abe0ae9 --- /dev/null +++ b/workplans/CB-WP-0018-the-browser-is-a-client.md @@ -0,0 +1,173 @@ +--- +id: CB-WP-0018 +kind: product +title: "The browser is a client: game over, a log, and where a drop goes" +status: todo +--- + +# Purpose + +``` +structural tier M (changes how the loop constrains its own operation: + the chaos rate d4 → d8 and a second calibration + window, owed by CB-EV-0015 §5) +chaos d4 = 3 → no override +declared tier M +``` + +**Declaration 1 of the second chaos window** — opened by T04 below, at the +rate T04 sets. This declaration was rolled at the old d4, because the rate +changes when the decision lands, not retroactively. + +## The defect: a finished game is indistinguishable from a crash + +Reported: *"after some time i get an empty page back. I guess the game +crashes or ends but that is unclear as the ui disappears."* + +Reproduced by driving a real game to completion over HTTP: + +``` +move 5 accepted → ok +GET / → URLError: [Errno 111] Connection refused +``` + +The game **ended normally** — 5 rounds, 30 commands — and everything it +produced went to the terminal: + +``` +coalition [P2] score 0 +coalition [P3] score 0 +winners +game over — 30 commands, hash f6c890a65271 +``` + +The browser got nothing, because `next_choice` only accepts connections +*inside* a human decision point. When `play()` returns, `run_game` writes +the outcome to stdout and the process exits; the listener dies and the +page's post-`ok` reload is refused. + +**The browser is a second-class client.** Every terminal outcome — the +result, the scores, the winners, and every error `run_game` can return — +is invisible to the only interface a player is actually using. A crash and +a win render identically: nothing. + +This is the same defect class as CB-WP-0016's silent drop. The system +refuses to say what happened and the player is left to infer it. + +## Task: serve the end of the game + +```task +id: CB-WP-0018-T01 +status: todo +priority: high +``` + +When `play()` returns — **`Ok` or `Err`** — the browser must be told, on +the page, in terms a player understands. + +- **A win** shows the outcome the spectator projection already carries: + totals, threshold, coalitions, mastery, winners. +- **An error** shows that the game ended badly and why. `run_game` + currently returns `Err(String)` to a terminal nobody is reading. +- The final page must **not** auto-reload into a refused connection. + +The listener has to outlive the game, which it does not today. Decide how +it ends — a timeout, an explicit close, or serving until interrupted — and +say why; a server that never exits is its own defect. + +**Controls:** a test that plays a game to its end through the socket and +asserts the final GET returns a page naming the outcome, **not** a refused +connection. And the error path, driven by a game that fails, asserting the +reason reaches the page. + +## Task: a game log, and what to do next + +```task +id: CB-WP-0018-T02 +status: todo +priority: high +``` + +Requested: *"a game log about the events that have been generated and a +hint of what the next move/options are."* + +The events exist — `validate` produces them and `record::to_scenario` +already turns them into a scenario file. The page shows none of them, so a +player sees state change with no account of why. + +- **The log** is the event stream, in the recorder's vocabulary. Do not + invent a fourth phrasing: `cb-play`'s `describe()` exists precisely so + *"what the player reads is what the scenario file will say"*, and the + HTML renderer currently uses `{c:?}` Debug instead. Reuse it or say why + it cannot be reused. +- **The hint** is what the seat may do now, which is `legal` — already in + hand, already rendered as cards by CB-WP-0017. The hint is the *step* + context that makes those cards make sense: what the round is waiting + for, and what happens when it stops waiting. + +**Control:** the log must be derived from the events the aggregate +produced, not re-narrated from the state. A log that describes the state +after the fact is a second implementation of the rules and will drift. + +## Task: say where a drop goes, and what it means + +```task +id: CB-WP-0018-T03 +status: todo +priority: high +``` + +Requested: *"an overlay arrow pointing to the droptarget element with an +explanation showing up besides it, when we drag on it."* + +CB-WP-0017 marks every legal target. What it does not do is say what any +particular drop **means** — the player sees five outlined boxes and must +still guess which one does what. + +So the explanation is the substance and the arrow is the delivery. Both +need the meaning to exist as data first, and **ADR-0010 Decision 1 binds +here**: the description of what `action-attack → seat-1` does must be +written by Rust into the page. The script may render it on hover; it may +not compose it. + +**Controls:** every advertised target carries a description, and the +description a target shows is the one Rust wrote for *that* pair — a +mutation that shows a neighbouring pair's text must go red. The set +equality property from ADR-0010 D2 must still hold. + +## Task: close the chaos change, and the evidence + +```task +id: CB-WP-0018-T04 +status: todo +priority: high +``` + +CB-EV-0015 §5 recommended and did not make the change, on the grounds that +it alters how the loop constrains its own operation and was therefore owed +a tier-M declaration. This is it. + +**Make it or reject it**, in `specs/InnerLoop.md` and `gates.toml`: + +- rate **d4 → d8**; +- a **second window of 12** declarations; +- new retirement condition: *retire if an override changes nothing twice + running*. + +The argument to test rather than assume: both overrides were informative +*because they were rare*. If that is right the rate should fall; if the +real driver was that they landed on passes with something to find, the +rate is irrelevant and the recommendation is wrong. Say which, and note +that n=2 is thin evidence for a rate change — including the possibility +that the honest answer is to keep d4 for another window and decide with +four data points instead of two. + +Then `evidence/CB-EV-0016-*.md`: + +- **What the maintainer's three reports have cost and bought**, now that + there are three in a row and every one found something no test did. +- **Whether stage 1 closes.** +- **Quote CB-WP-0017's cost by re-running the instrument**, not from + memory — CB-EV-0015 §6 found that quoting a remembered figure defeats + the rule even when the boundary is right, and this is the first pass + that can apply that correction. From 78e82497ee0549bb4d91f07f88b55b6d17110376 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 3 Aug 2026 02:00:21 +0200 Subject: [PATCH 2/5] CB-WP-0018: record the card report and what it actually is A discard pile already exists -- solution_discard on GroundState, SolutionDiscarded removes from hand, and the page renders it. Building one would have been building a thing that is there. What was dragged are ACTION cards, which are not cards and are correctly never consumed; solution cards leave the hand at Resolve because a selection is a face-down commit. But the report points at something real. Measured live: Investigate draws correctly (2 -> 3 -> 4 cards), and Solve was then played three times on problem-1 with the hand unchanged at 4 and discard empty -- because problem-1 was face down and GR-A02's resolver silently continues. legal_commands offers Solve on every face-up problem without consulting the hand. Whether SOLVE should be selectable against a face-down problem or an unmatchable suit is a game-semantics question and INTENT defers those to ground-game. What is ours is that a provably-inert move is offered, accepted and never accounted for. Folded into T02 as the case the log must handle: a command that produced NO events is the one the player needs to see. Co-Authored-By: Claude Opus 5 --- .../CB-WP-0018-the-browser-is-a-client.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/workplans/CB-WP-0018-the-browser-is-a-client.md b/workplans/CB-WP-0018-the-browser-is-a-client.md index abe0ae9..65aaa27 100644 --- a/workplans/CB-WP-0018-the-browser-is-a-client.md +++ b/workplans/CB-WP-0018-the-browser-is-a-client.md @@ -80,6 +80,49 @@ asserts the final GET returns a page naming the outcome, **not** a refused connection. And the error path, driven by a game that fails, asserting the reason reaches the page. +## The second report, and what it actually is + +Reported: *"the cards I play by pulling them on a target will not be +removed, that is wrong i guess we will need a discard pile."* + +**A discard pile already exists** — `solution_discard` on `GroundState`, +`SolutionDiscarded` removes the card from the hand and pushes it there, +and the page already renders `deck N remaining / discard …`. Building one +would have been building a thing that is there. + +What the maintainer dragged were **action** cards. The five GROUND actions +are not cards and are correctly never consumed. Solution cards leave the +hand at **Resolve**, not at Select, because a selection is a face-down +commit. + +But the report is pointing at something real. Measured over a live game: + +| move | hand | discard | +|---|---|---| +| 1 · Investigate → problem-2 | 2 cards | none | +| 2 · Investigate → problem-3 | **3 cards** | none | +| 3 · Solve → problem-1 | 4 cards | none | +| 4 · Solve → problem-1 | **4 cards** | none | +| 5 · Solve → problem-1 | **4 cards** | none | + +Investigate draws, correctly. **Solve was played three times and did +nothing, three times, in silence.** GR-A02's resolver `continue`s when the +problem is face-down, already claimed, denied, or when the seat holds no +card of the matching suit — and `legal_commands` offers Solve on every +face-up problem without consulting the hand, while the page offers it on +problem-1 which was still face-down. + +**Whether that is a rule gap or intended is not this repo's call.** A +face-down commit you cannot fulfil is a plausible bluff in a commit/reveal +game with DARVO, and INTENT defers game semantics to `ground-game`. What +*is* this repo's call is that a move which provably does nothing is +offered, accepted, and never accounted for. That is the log's job (T02) +and the explanation's job (T03), and it is now the concrete case both are +measured against. + +Raised for `ground-game`, not decided here: **should SOLVE be selectable +against a face-down problem, or against a suit the seat cannot match?** + ## Task: a game log, and what to do next ```task @@ -109,6 +152,12 @@ player sees state change with no account of why. produced, not re-narrated from the state. A log that describes the state after the fact is a second implementation of the rules and will drift. +**The case it must handle**, from the measurement above: three SOLVEs that +produced no events at all. A log built only from events would show nothing +for them and reproduce the silence. So the log must distinguish *"this +command produced these events"* from *"this command produced none"* — +the second is the one the player needs and the harder one to render. + ## Task: say where a drop goes, and what it means ```task From 57639623da834d588435ec1ae43fabbec1bd56f8 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 3 Aug 2026 02:08:04 +0200 Subject: [PATCH 3/5] CB-WP-0018-T01: the browser sees the end of its own game Server::serve_end plus doc::ending, wired into both of run_game's exits. Where the browser used to get Connection refused it now gets the ending page with the result and the final table. It serves until the page posts 'done' (the page carries a close control), with a 600s linger so an abandoned tab cannot hold the process open. document() split into body() and move_section() so the ending shows the same table rather than a second rendering of it. The control had to be built twice and the first was worthless: the_end_of_the_game_reaches_the_browser calls serve_end directly, and deleting the call from run_game left it GREEN -- it tested the link and not the chain, which is CB-EV-0012's finding recurring. a_real_game_played_to_its_end_leaves_the_ending_on_screen runs the real play() with a browser seat, drives a real game to its end over a real socket, and goes red under that mutation printing an empty page -- the reported symptom exactly. A weak assertion of mine caught by itself: the first draft grepped the page for location.reload, which would have forced a second script to satisfy a test rather than a requirement. It now asserts the ending endpoint cannot answer 'ok'. Co-Authored-By: Claude Opus 5 --- crates/cb-render-html/src/doc.rs | 78 ++++- tools/cb-play/src/hotseat.rs | 281 ++++++++++++++++++ tools/cb-play/src/table.rs | 51 +++- .../CB-WP-0018-the-browser-is-a-client.md | 40 ++- 4 files changed, 433 insertions(+), 17 deletions(-) diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index c436109..ef1453e 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -431,6 +431,24 @@ pub fn document( }, ); + body(&mut s, view); + move_section(&mut s, legal, seat, may_pass); + let _ = write!( + s, + "
ready
\ + \ + ", + endpoint = json_string(endpoint), + ); + s +} + +/// The table itself: problems, relationships, seats, solutions, outcome. +/// +/// Factored out of [`document`] so [`ending`] shows the SAME table rather +/// than a second rendering of it — two renderings of one state is how +/// they drift. +fn body(s: &mut String, view: &GroundView) { s.push_str( "

problems

", ); @@ -440,7 +458,7 @@ pub fn document( ); } for (i, (priority, p)) in view.problems.iter().enumerate() { - problem_svg(&mut s, *priority, p, 10 + (i as i32) * 130); + problem_svg(s, *priority, p, 10 + (i as i32) * 130); } s.push_str(""); @@ -449,7 +467,7 @@ pub fn document( s.push_str("

seats

"); for (id, p) in &view.players { - player_card(&mut s, *id, p, view); + player_card(s, *id, p, view); } s.push_str("
"); @@ -512,6 +530,16 @@ pub fn document( ); } +} + +/// The "your move" section: action cards, numbered fallbacks, the table, +/// and pass. Emits nothing when the seat has nothing legal to do. +fn move_section( + s: &mut String, + legal: &[games_ground::GroundCommand], + seat: Option, + may_pass: bool, +) { if !legal.is_empty() { s.push_str("

your move

"); for a in [ @@ -572,14 +600,6 @@ pub fn document( "
pass \u{2014} decline to act
", ); } - - let _ = write!( - s, - "
ready
\ - ", - json_string(endpoint), - ); - s } /// Quote a string as a JSON literal, so a token can never end the script. @@ -622,6 +642,44 @@ fn json_string(s: &str) -> String { /// element could ever be `seat-0` — the relationship-graph circle took it /// and the seat card the instruction text points at went without. A seat /// is drawn twice and both drawings are the seat. +/// The page a finished game leaves behind (CB-WP-0018 T01). +/// +/// `view` is `None` when the game ended badly: then there is no result to +/// draw and saying so is the whole point. Drawing a table for a game that +/// crashed would be the same lie the empty page told, dressed up. +/// +/// **This page does not auto-reload.** The old one reloaded on `ok` and +/// the reload was refused, which is how a completed game became a blank +/// tab. +pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str) -> String { + let mut s = String::with_capacity(4096); + let _ = write!( + s, + "\ + \ + GROUND — game over\ +

game over

{msg}
", + msg = esc(message), + ); + match view { + Some(v) => body(&mut s, v), + None => { + s.push_str( + "
The game ended without a result, so there \ + is no final table to show. The reason is above.
", + ); + } + } + let _ = write!( + s, + "
close — I have read this
\ +
the game is over
\ + ", + endpoint = json_string(endpoint), + ); + s +} + pub fn drop_keys(html: &str) -> std::collections::BTreeSet { let mut out = std::collections::BTreeSet::new(); let mut rest = html; diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index c900dd9..b4aec5e 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -122,6 +122,81 @@ impl Server { } } } + + /// Serve the end of the game until the player has seen it. + /// + /// **The defect this exists for (CB-WP-0018 T01):** `next_choice` + /// only accepts connections *inside* a decision point, so when + /// `play()` returned the listener died and the page's post-`ok` + /// reload was refused. Measured: a game ended normally at 5 rounds + /// and 30 commands, its whole result went to the terminal, and the + /// browser got `Connection refused`. **A crash and a win rendered + /// identically — as nothing.** + /// + /// `final_view` is `None` when the game ended badly; then `message` + /// is the reason and the page says the game ended without a result + /// rather than drawing a table that never happened. + /// + /// **How it ends, and why that needed deciding:** a server that never + /// exits is its own defect, and a timeout would race a player reading + /// the result. It serves until the page tells it the result has been + /// seen — the terminal page carries a `done` control and posts it — + /// with `linger` as a bound so an abandoned tab cannot hold the + /// process open forever. + pub fn serve_end( + &self, + final_view: Option<&games_ground::view::GroundView>, + message: &str, + linger: std::time::Duration, + ) -> 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(()); + } + let (mut stream, _) = match self.listener.accept() { + Ok(pair) => pair, + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(25)); + continue; + } + Err(e) => break Err(format!("accept: {e}")), + }; + stream.set_nonblocking(false).ok(); + let Ok(raw) = read_request(&mut stream) else { + continue; + }; + let Ok(req) = Request::parse(&raw) else { + respond(&mut stream, 400, "text/plain", "bad request"); + continue; + }; + if let Err(refusal) = self.guard.admit(&req) { + self.log.borrow_mut().push(refusal.to_string()); + respond(&mut stream, 403, "text/plain", &refusal.to_string()); + continue; + } + match (req.method.as_str(), req.path.as_str()) { + ("GET", "/") => { + let page = cb_render_html::doc::ending( + final_view, + message, + &self.guard.endpoint(), + ); + respond(&mut stream, 200, "text/html; charset=utf-8", &page); + } + ("POST", "/command") => { + respond(&mut stream, 200, "text/plain", "closed"); + break Ok(()); + } + _ => respond(&mut stream, 404, "text/plain", "the game is over"), + } + }; + self.listener.set_nonblocking(false).ok(); + outcome + } } /// One seat's view of the shared server. @@ -225,6 +300,212 @@ mod tests { /// sent. Two concurrent clients would race the server's `accept`, and /// a test whose outcome depends on which socket wins is worse than no /// test. + /// **The defect CB-WP-0018 T01 exists for.** After the game ends the + /// browser must get the result, not a refused connection. + /// + /// Measured before the fix, driving a real game to completion over + /// HTTP: move 5 accepted, then `GET /` → `[Errno 111] Connection + /// refused`, while the whole outcome went to a terminal nobody was + /// reading. A crash and a win rendered identically — as nothing. + #[test] + fn the_end_of_the_game_reaches_the_browser() { + let server = Server::bind(0).expect("bind"); + let port = server.listener.local_addr().unwrap().port(); + let token = server.guard.token().to_string(); + let client = converse( + port, + vec![ + format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"), + 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: 17\r\n\r\n\ + down=done&up=done" + ), + ], + ); + let view = state().project(Viewer::Spectator); + server + .serve_end( + Some(&view), + "30 commands, hash f6c890a65271", + std::time::Duration::from_secs(20), + ) + .expect("serve_end"); + let replies = client.join().expect("client"); + + assert!(replies[0].contains("200 OK"), "{}", replies[0]); + assert!(replies[0].contains("game over"), "the page did not say so"); + assert!( + replies[0].contains("30 commands"), + "the result did not reach the page" + ); + // It must not auto-reload into a refused connection, which is how + // a completed game became a blank tab in the first place. + // + // Asserted on BEHAVIOUR, not on source text. The first draft + // grepped the page for "location.reload" and failed: the ending + // page reuses `SCRIPT`, whose reload is guarded by + // `t.indexOf('ok') === 0`. Grepping for the string would have + // forced a second script to satisfy a test rather than a + // requirement — the exact weak-control shape ADR-0010 D2 demoted. + // What matters is that the endpoint cannot answer "ok". + assert!( + !replies[1].contains("ok"), + "the ending endpoint answered something the page reloads on: {}", + replies[1] + ); + assert!(replies[1].contains("closed"), "{}", replies[1]); + } + + /// A game that ended badly must say so rather than draw a table for a + /// game that never happened. + #[test] + fn a_game_that_ended_badly_says_so_and_shows_no_table() { + let page = cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x"); + assert!(page.contains("P1 ran out of input"), "the reason is missing"); + assert!(page.contains("without a result")); + assert!( + !page.contains("relationships"), + "a failed game drew a table anyway" + ); + } + + /// A writer the test can read while the game is still running. + #[derive(Clone)] + struct SharedOut(std::sync::Arc>>); + + impl Write for SharedOut { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().expect("out").extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + 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"); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + out + } + + /// **The chain, not the links.** `the_end_of_the_game_reaches_the_browser` + /// calls `serve_end` directly, so it passes even when `run_game` never + /// calls it — proven: deleting that call left it green. That is the + /// defect class this project keeps meeting (CB-EV-0012: *"every link + /// was tested and the chain was not"*), and it survived one round of + /// it here before this test existed. + /// + /// So: run the real `play()` with a browser seat, drive a real game to + /// its end over a real socket, and require the last page to be the + /// ending rather than a refused connection. + #[test] + fn a_real_game_played_to_its_end_leaves_the_ending_on_screen() { + 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: 7, + players: 3, + human_seats: vec![0], + bot: "random".into(), + replay_dir: None, + record: None, + serve: Some(0), + }, + std::io::Cursor::new(Vec::new()), + out, + ) + }); + + // The URL is printed as soon as the listener binds. + let url = 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()); + break rest[..end].to_string(); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + let port: u16 = url["http://127.0.0.1:".len()..] + .split('/') + .next() + .expect("port") + .parse() + .expect("port number"); + let token = url.split("t=").nth(1).expect("token").to_string(); + + // Play until the page stops offering moves — which is the ending. + 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); + // A spatial move if one is offered; otherwise the numbered + // fallback or pass, which is what the page offers at steps + // with no draggable action. Breaking here instead would end + // the walk mid-game and assert nothing. + 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("a target") + .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 { + break; + }; + 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() + ), + ); + } + + // Before CB-WP-0018 this GET was a refused connection and the + // whole result went to a terminal nobody was reading. + assert!( + last.contains("game over"), + "the last page the player saw was not the ending: {}", + &last[..last.len().min(300)] + ); + assert!(last.contains("commands, hash"), "no result on the page"); + + // Let the game thread finish: tell it the result has been seen. + let body = "down=done&up=done"; + 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() + ), + ); + game.join().expect("game thread").expect("the game ran"); + } + fn converse(port: u16, requests: Vec) -> std::thread::JoinHandle> { std::thread::spawn(move || { requests diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index 41a0937..dd21eae 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -181,6 +181,29 @@ impl Policy for HumanPolicy<'_, R, W> { } } +/// How long the terminal page stays available for an abandoned tab. +/// +/// A server that never exits is its own defect; a short timeout races a +/// player reading the result. So it serves until the page posts `done`, +/// with this only as the bound. +const END_LINGER: std::time::Duration = std::time::Duration::from_secs(600); + +/// Show the browser that the game ended badly, then return the error. +/// +/// CB-WP-0018 T01: this path used to `return Err(...)` straight to a +/// terminal nobody was reading, and the browser got a refused connection — +/// identical to a normal win. An error the only interface cannot see is +/// not reported. +fn end_badly( + server: &Option>, + msg: String, +) -> Result { + if let Some(s) = server { + let _ = s.serve_end(None, &msg, END_LINGER); + } + Err(msg) +} + fn bot_policy<'a>(kind: &str, seed: u64) -> Result, String> { match kind { "greedy" => Ok(Box::new(GreedyPolicy)), @@ -250,14 +273,17 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( // out-of-range index it had to return to get here. let human_failure = failure.borrow().clone(); let game = match (result, human_failure) { - (_, Some(msg)) => return Err(msg), + (_, Some(msg)) => return end_badly(&server, msg), (Err(BotError::IllegalChoice { seat, offered, .. }), None) => { - return Err(format!( - "{} chose a command outside the {offered} offered", - seat_name(seat) - )) + return end_badly( + &server, + format!( + "{} chose a command outside the {offered} offered", + seat_name(seat) + ), + ) } - (Err(e), None) => return Err(e.to_string()), + (Err(e), None) => return end_badly(&server, e.to_string()), (Ok(game), None) => game, }; @@ -287,6 +313,19 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( let _ = w.flush(); drop(w); + // 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. + 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); + } + let scenario = games_ground::record::to_scenario( "ground/cb-play-session", config.seed, diff --git a/workplans/CB-WP-0018-the-browser-is-a-client.md b/workplans/CB-WP-0018-the-browser-is-a-client.md index 65aaa27..7f12c82 100644 --- a/workplans/CB-WP-0018-the-browser-is-a-client.md +++ b/workplans/CB-WP-0018-the-browser-is-a-client.md @@ -58,7 +58,7 @@ refuses to say what happened and the player is left to infer it. ```task id: CB-WP-0018-T01 -status: todo +status: done priority: high ``` @@ -80,6 +80,44 @@ asserts the final GET returns a page naming the outcome, **not** a refused connection. And the error path, driven by a game that fails, asserting the reason reaches the page. +**Done 2026-08-03.** `Server::serve_end` plus `doc::ending`, wired into +both of `run_game`'s exits. Verified live: where the browser used to get +`Connection refused` it now gets an 8,998-byte page reading *"GROUND — +game over … 30 commands, hash f6c890a65271"* with the final table. + +**How it ends:** it serves until the page posts `done` — the ending page +carries a *"close — I have read this"* control — with a 600 s linger as +the bound, so an abandoned tab cannot hold the process open and a player +reading the result is not raced by a timeout. + +`document()` was split into `body()` and `move_section()` so the ending +shows the **same** table rather than a second rendering of it; two +renderings of one state is how they drift. + +**The control had to be built twice, and the first one was worthless.** +`the_end_of_the_game_reaches_the_browser` calls `serve_end` directly — +and deleting the call from `run_game` left it **green**. It tested the +link and not the chain, which is CB-EV-0012's finding recurring +(*"every link was tested and the chain was not"*). + +`a_real_game_played_to_its_end_leaves_the_ending_on_screen` runs the real +`play()` with a browser seat, drives a real game to its end over a real +socket, and requires the last page to be the ending. Under the same +mutation it goes red — and prints an **empty page**, which is exactly the +symptom that was reported. + +A second control: a game that ended badly says why and draws **no** table, +because drawing a table for a game that never happened is the same lie the +empty page told. + +**And a weak assertion of mine, caught by itself.** The first version +grepped the page for `location.reload`. The ending page reuses `SCRIPT`, +whose reload is guarded by `t.indexOf('ok') === 0`, and the ending +endpoint answers `closed` — so the grep would have forced a second script +to satisfy a test rather than a requirement. That is the source-text +control shape ADR-0010 D2 demoted. It now asserts the endpoint cannot +answer `ok`. + ## The second report, and what it actually is Reported: *"the cards I play by pulling them on a target will not be From 7a78c584043da77f8e0bd826d921b2c514a6f695 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 3 Aug 2026 02:15:30 +0200 Subject: [PATCH 4/5] CB-WP-0018-T02: a game log the player can read bot::Journal -- a shared list of Applied { actor, command, events } the driver appends to via play_journaled; play delegates with None so nothing existing changed. BotGame.events only appears after play returns, which is no use to a page rendered mid-game. Phrased with record::to_step, the recorder's vocabulary, so what the player reads is what the scenario file will say, and all 29 GroundEvent variants now render in words instead of Debug. A command that produced no events says 'no effect'; the mutation dropping that branch goes red. Honest limitation recorded: the reported SOLVE case is resolved inside the system's resolve command, which does produce events for other seats, so it shows as a selection with no claim following rather than an explicit 'no effect'. Making it explicit would mean the renderer deciding why a rule did nothing -- a second implementation of the rules, which this task's control forbids. Co-Authored-By: Claude Opus 5 --- crates/cb-render-html/src/doc.rs | 63 +++++++- crates/cb-render-html/src/lib.rs | 54 +++++++ games/ground/src/bot.rs | 46 +++++- tools/cb-play/src/hotseat.rs | 134 +++++++++++++++++- tools/cb-play/src/table.rs | 14 +- .../CB-WP-0018-the-browser-is-a-client.md | 37 ++++- 6 files changed, 333 insertions(+), 15 deletions(-) diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index ef1453e..351176f 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -170,6 +170,9 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf} font:13px ui-monospace,monospace;box-shadow:0 6px 16px #000b; transform:translate(-50%,-140%)} .k{color:#89a} +.nil{color:#c88} +.eff{color:#8c9} +#cb-log{max-height:16rem;overflow-y:auto;font-size:13px} #cb-status{margin-top:1rem;color:#fc9;min-height:1.2em} svg{background:#1b1e26;border:1px solid #445;border-radius:6px} "; @@ -405,6 +408,18 @@ pub fn document( endpoint: &str, seat: Option, may_pass: bool, +) -> String { + document_with_log(view, legal, endpoint, seat, may_pass, &[]) +} + +/// The table, plus the game log (CB-WP-0018 T02). +pub fn document_with_log( + view: &GroundView, + legal: &[games_ground::GroundCommand], + endpoint: &str, + seat: Option, + may_pass: bool, + log: &[LogLine], ) -> String { let mut s = String::with_capacity(8192); let _ = write!( @@ -433,6 +448,7 @@ pub fn document( body(&mut s, view); move_section(&mut s, legal, seat, may_pass); + log_section(&mut s, log); let _ = write!( s, "
ready
\ @@ -529,7 +545,6 @@ fn body(s: &mut String, view: &GroundView) { winners = esc(&winners), ); } - } /// The "your move" section: action cards, numbered fallbacks, the table, @@ -642,6 +657,47 @@ fn json_string(s: &str) -> String { /// element could ever be `seat-0` — the relationship-graph circle took it /// and the seat card the instruction text points at went without. A seat /// is drawn twice and both drawings are the seat. +/// One line of the game log: what was done, and what it produced. +/// +/// Built by the caller from `bot::Applied` so this crate stays free of +/// the driver, and phrased in the **recorder's** vocabulary via +/// `record::to_step` — CB-WP-0018 T02 forbids a fourth phrasing, because +/// what the player reads should be what the scenario file will say. +pub struct LogLine { + pub who: String, + pub what: String, + /// Rendered effects. **Empty is the case that matters:** a command + /// that produced no events is the one the player cannot otherwise + /// account for. + pub effects: Vec, +} + +/// The game log, newest last, with the empty case spelled out. +fn log_section(s: &mut String, log: &[LogLine]) { + s.push_str("

log

"); + if log.is_empty() { + s.push_str("nothing has happened yet"); + } + for line in log { + let _ = write!( + s, + "
{who} {what}", + who = esc(&line.who), + what = esc(&line.what), + ); + if line.effects.is_empty() { + // The silence CB-WP-0018 was reported for, said out loud. + s.push_str(" \u{2014} no effect"); + } else { + for e in &line.effects { + let _ = write!(s, "
\u{2192} {}", esc(e)); + } + } + s.push_str("
"); + } + s.push_str("
"); +} + /// The page a finished game leaves behind (CB-WP-0018 T01). /// /// `view` is `None` when the game ended badly: then there is no result to @@ -651,7 +707,7 @@ fn json_string(s: &str) -> String { /// **This page does not auto-reload.** The old one reloaded on `ok` and /// the reload was refused, which is how a completed game became a blank /// tab. -pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str) -> String { +pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[LogLine]) -> String { let mut s = String::with_capacity(4096); let _ = write!( s, @@ -670,9 +726,10 @@ pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str) -> Strin ); } } + log_section(&mut s, log); let _ = write!( s, - "
close — 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/lib.rs b/crates/cb-render-html/src/lib.rs index 1b15091..58898b7 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -573,5 +573,59 @@ mod affordances { } } +#[cfg(test)] +mod gamelog { + //! CB-WP-0018 T02. + + use crate::doc::{document_with_log, LogLine}; + use cb_kernel::PlayerId; + + fn line(effects: &[&str]) -> LogLine { + LogLine { + who: "P1".into(), + what: "select_action action=SOLVE problem=1".into(), + effects: effects.iter().map(|s| (*s).to_string()).collect(), + } + } + + fn page(log: &[LogLine]) -> String { + document_with_log( + &crate::testfix::view(Some(PlayerId(0))), + &[], + "/command?t=x", + Some(PlayerId(0)), + false, + log, + ) + } + + /// **The case the pass was reported for.** A SOLVE that cannot be + /// fulfilled produces no events, and a log built only from events + /// would render nothing for it — reproducing the silence the + /// maintainer hit when the same move did nothing three rounds + /// running. + #[test] + fn a_command_that_produced_nothing_says_so() { + let html = page(&[line(&[])]); + assert!( + html.contains("no effect"), + "a command with no events rendered as if it had done something" + ); + let text = crate::text_of(&html); + assert!(text.contains("select_action action=SOLVE problem=1")); + } + + #[test] + fn effects_are_listed_and_an_empty_log_says_it_is_empty() { + let text = crate::text_of(&page(&[line(&["problem 1 claimed by P1"])])); + assert!(text.contains("problem 1 claimed by P1")); + assert!( + !text.contains("no effect"), + "a command WITH effects was marked as having none" + ); + assert!(crate::text_of(&page(&[])).contains("nothing has happened yet")); + } +} + #[cfg(test)] mod testfix; diff --git a/games/ground/src/bot.rs b/games/ground/src/bot.rs index b160058..551cad9 100644 --- a/games/ground/src/bot.rs +++ b/games/ground/src/bot.rs @@ -409,10 +409,23 @@ const MAX_ATTEMPTS: usize = 8; /// Seats are matched to `policies` by index: `PlayerId(n)` gets /// `policies[n]`. pub fn play<'a>( - mut state: GroundState, + state: GroundState, policies: &mut [Box], ) -> Result { - let mut log = Log::default(); + play_journaled(state, policies, None) +} + +/// The same, appending every applied command and its events to `journal` +/// as it goes, for a caller rendering the game while it runs. +pub fn play_journaled<'a>( + mut state: GroundState, + policies: &mut [Box], + journal: Option, +) -> Result { + let mut log = Log { + journal, + ..Log::default() + }; let seats: Vec = state.players.keys().copied().collect(); for seat in &seats { @@ -504,11 +517,33 @@ pub fn play<'a>( }) } +/// One command and everything it produced. +/// +/// **The empty case is the load-bearing one** (CB-WP-0018 T02). GR-A02's +/// resolver silently `continue`s when a SOLVE cannot be fulfilled, so a +/// player can select it three rounds running and change nothing. A journal +/// built only from events would show nothing for those and reproduce the +/// silence; keeping the command with an empty `events` is what lets a +/// reader say *"this happened and did nothing"*. +#[derive(Debug, Clone)] +pub struct Applied { + pub actor: Actor, + pub command: GroundCommand, + pub events: Vec, +} + +/// A live account of a game in progress, shared with whoever is watching. +/// +/// `BotGame.events` is the same information but only after `play` returns, +/// which is no use to a page rendered mid-game. +pub type Journal = std::rc::Rc>>; + /// What the driver accumulates while a game runs. #[derive(Default)] struct Log { events: Vec, steps: Vec<(Actor, GroundCommand)>, + journal: Option, } /// Offer one seat its legal commands and apply what the policy picks. @@ -573,6 +608,13 @@ fn apply( for event in &produced { state.fold(event); } + if let Some(j) = &log.journal { + j.borrow_mut().push(Applied { + actor, + command: cmd.clone(), + events: produced.clone(), + }); + } log.events.extend(produced); log.steps.push((actor, cmd.clone())); Ok(()) diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index b4aec5e..a0659bb 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -28,6 +28,9 @@ pub struct Server { listener: TcpListener, guard: Guard, log: RefCell>, + /// The live account of the game, shared with the driver (CB-WP-0018 + /// T02). Read at render time so the page shows what has happened. + journal: games_ground::bot::Journal, } impl Server { @@ -41,6 +44,7 @@ impl Server { listener, guard: Guard::mint(port), log: RefCell::new(Vec::new()), + journal: games_ground::bot::Journal::default(), }) } @@ -50,6 +54,47 @@ impl Server { self.guard.page_url() } + /// The journal the driver appends to; hand it to `play_journaled`. + pub fn journal(&self) -> games_ground::bot::Journal { + self.journal.clone() + } + + /// The log as the page renders it, in the recorder's vocabulary. + /// + /// `record::to_step` is reused rather than phrased afresh: what the + /// player reads is what the scenario file will say. The effects are + /// the events the command actually produced, and a command that + /// produced none says so — that is the case CB-WP-0018 was reported + /// for. + fn log_lines(&self) -> Vec { + self.journal + .borrow() + .iter() + .map(|a| { + let step = games_ground::record::to_step(a.actor, &a.command); + let mut what = step.cmd.clone(); + for (k, v) in &step.args { + let rendered = match v { + serde_yaml::Value::String(s) => s.clone(), + other => serde_yaml::to_string(other) + .unwrap_or_default() + .trim() + .to_string(), + }; + what.push_str(&format!(" {k}={rendered}")); + } + cb_render_html::doc::LogLine { + who: match a.actor { + cb_kernel::Actor::Player(p) => format!("P{}", p.0 + 1), + cb_kernel::Actor::System => "the round".to_string(), + }, + what, + effects: a.events.iter().map(event_line).collect(), + } + }) + .collect() + } + /// What was refused, for the evidence a session leaves behind. pub fn refusals(&self) -> Vec { self.log.borrow().clone() @@ -93,7 +138,14 @@ impl Server { match (req.method.as_str(), req.path.as_str()) { ("GET", "/") => { - let page = document(&view, legal, &self.guard.endpoint(), Some(seat), may_pass); + let page = cb_render_html::doc::document_with_log( + &view, + legal, + &self.guard.endpoint(), + Some(seat), + may_pass, + &self.log_lines(), + ); respond(&mut stream, 200, "text/html; charset=utf-8", &page); } ("POST", "/command") => { @@ -184,6 +236,7 @@ impl Server { final_view, message, &self.guard.endpoint(), + &self.log_lines(), ); respond(&mut stream, 200, "text/html; charset=utf-8", &page); } @@ -236,6 +289,67 @@ impl Policy for SeatPolicy { } } +/// One event, in words a player can read. +/// +/// Deliberately terse and derived from the event itself, never from the +/// state afterwards: a log that re-narrates state is a second +/// implementation of the rules and will drift from the first. +fn event_line(e: &games_ground::GroundEvent) -> String { + use games_ground::GroundEvent as E; + let p = |x: &cb_kernel::PlayerId| format!("P{}", x.0 + 1); + match e { + E::ActionSelected { player, selection } => match (selection.target, selection.problem) { + (Some(t), _) => format!("{} chose {:?} on {}", p(player), selection.action, p(&t)), + (_, Some(n)) => format!("{} chose {:?} on problem {n}", p(player), selection.action), + _ => format!("{} chose {:?}", p(player), selection.action), + }, + E::Revealed => "selections revealed".into(), + E::StressSet { player, stress } => format!("{} stress now {stress}", p(player)), + E::FreedomSpent { player } => format!("{} spent freedom", p(player)), + E::FreedomReadied { player } => format!("{} freedom ready", p(player)), + E::RelationFormed { pair, relation } => { + format!("{} and {} \u{2014} {relation:?}", p(&pair.0), p(&pair.1)) + } + E::RelationBroken { pair } => format!("{} and {} no longer tied", p(&pair.0), p(&pair.1)), + E::AttackCancelled { attacker, target } => { + format!("{}'s attack on {} cancelled", p(attacker), p(target)) + } + E::ProblemRevealed { problem } => format!("problem {problem} turned face up"), + E::SolutionDrawn { player, .. } => format!("{} drew a solution", p(player)), + E::SolutionDiscarded { player, card } => { + format!("{} spent a {:?} solution", p(player), card.suit) + } + E::ProblemClaimed { problem, by } => format!("problem {problem} claimed by {}", p(by)), + E::ProblemDenied { problem } => format!("problem {problem} denied"), + E::ProblemProtected { problem } => format!("problem {problem} protected"), + E::ProblemRestored { problem } => format!("problem {problem} restored"), + E::ProtectionGained { player } => format!("{} gained protection", p(player)), + E::BlameRemoved { player, owner } => { + format!("{} cleared {}'s blame", p(player), p(owner)) + } + E::FocusPlaced { owner, target } => format!("{} focused on {}", p(owner), p(target)), + E::FocusFlippedToBlame { owner, target } => { + format!("{}'s focus on {} became blame", p(owner), p(target)) + } + E::DarvoTriggered { player } => format!("{} entered DARVO", p(player)), + E::DarvoAdvanced { player, stage } => format!("{} DARVO \u{2192} {stage:?}", p(player)), + E::DarvoEnded { player } => format!("{} left DARVO", p(player)), + E::DarvoTargetChosen { player, .. } => format!("{} named a DARVO target", p(player)), + E::GroundModeChosen { player, mode, .. } => { + format!("{} grounded as {mode:?}", p(player)) + } + E::SupportAnswered { player, response } => { + format!("{} answered support with {response:?}", p(player)) + } + E::DeckReshuffled { .. } => "the discard was reshuffled into the deck".into(), + E::RoundEnded { round, next_lead } => { + format!("round {round} ended; {} leads next", p(next_lead)) + } + E::StepAdvanced { step } => format!("step \u{2192} {step:?}"), + E::GameEnded { .. } => "the game ended".into(), + } +} + fn read_request(stream: &mut TcpStream) -> Result { let mut buf = Vec::new(); let mut chunk = [0u8; 1024]; @@ -361,8 +475,11 @@ mod tests { /// game that never happened. #[test] fn a_game_that_ended_badly_says_so_and_shows_no_table() { - let page = cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x"); - assert!(page.contains("P1 ran out of input"), "the reason is missing"); + let page = cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x", &[]); + assert!( + page.contains("P1 ran out of input"), + "the reason is missing" + ); assert!(page.contains("without a result")); assert!( !page.contains("relationships"), @@ -492,6 +609,17 @@ mod tests { &last[..last.len().min(300)] ); assert!(last.contains("commands, hash"), "no result on the page"); + // CB-WP-0018 T02: the log is on the page, and it came from the + // journal the driver filled -- not from re-reading the state. + assert!(last.contains("

log

"), "no log section"); + assert!( + !last.contains("nothing has happened yet"), + "a finished game reported an empty log" + ); + assert!( + last.contains("select_action"), + "the log is not in the recorder's vocabulary" + ); // Let the game thread finish: tell it the result has been seen. let body = "down=done&up=done"; diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index dd21eae..61d80c4 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -267,7 +267,13 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( } } - let result = games_ground::bot::play(initial, &mut policies); + // CB-WP-0018 T02: the browser seat's page renders the journal as it + // fills, so a player sees what each command produced -- including the + // commands that produced nothing. + let result = match &server { + Some(srv) => games_ground::bot::play_journaled(initial, &mut policies, Some(srv.journal())), + None => games_ground::bot::play(initial, &mut policies), + }; // A human seat that ran out of input reports *that*, not the // out-of-range index it had to return to get here. @@ -318,11 +324,7 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( // to stdout, and the page's post-`ok` reload got Connection refused. if let Some(srv) = &server { let ended = game.state.project(Viewer::Spectator); - let msg = format!( - "{} commands, hash {}", - game.commands, - &end_hash[..12] - ); + let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]); let _ = srv.serve_end(Some(&ended), &msg, END_LINGER); } diff --git a/workplans/CB-WP-0018-the-browser-is-a-client.md b/workplans/CB-WP-0018-the-browser-is-a-client.md index 7f12c82..0955fed 100644 --- a/workplans/CB-WP-0018-the-browser-is-a-client.md +++ b/workplans/CB-WP-0018-the-browser-is-a-client.md @@ -165,7 +165,7 @@ against a face-down problem, or against a suit the seat cannot match?** ```task id: CB-WP-0018-T02 -status: todo +status: done priority: high ``` @@ -196,6 +196,41 @@ for them and reproduce the silence. So the log must distinguish *"this command produced these events"* from *"this command produced none"* — the second is the one the player needs and the harder one to render. +**Done 2026-08-03.** `bot::Journal` — a shared list of `Applied { actor, +command, events }` the driver appends to, via the new `play_journaled`. +`play` delegates to it with `None`, so nothing existing changed. The +journal exists because `BotGame.events` only appears *after* `play` +returns, which is no use to a page rendered mid-game. + +The log is phrased with `record::to_step` — the recorder's vocabulary, so +what the player reads is what the scenario file will say — and every one +of the 29 `GroundEvent` variants now renders in words rather than `{:?}`. +From a live game: + +``` +P1 select_action action=INVESTIGATE problem=2 + → P1 chose Investigate on problem 2 +the round resolve + → problem 2 turned face up + → P1 drew a solution + → step → Resolve +the round end_round + → round 2 ended; P2 leads next +``` + +**A command that produced no events says `no effect`**, which is the whole +point; the mutation that drops that branch goes red. + +**An honest limitation.** The reported case — SOLVE doing nothing — is +*not* rendered as `no effect`, because the SOLVE is resolved inside the +system's `resolve` command, which does produce events for other seats. The +player now sees the selection and sees no claim follow it, which is a +large improvement on silence but is still an inference. Making it explicit +would mean the renderer deciding *why* a rule did nothing, which is a +second implementation of the rules — exactly what this task's control +forbids. Left as an inference deliberately, and owed to `ground-game` as +the question of whether the move should be offered at all. + ## Task: say where a drop goes, and what it means ```task From a733826d9501584f76a14329b0671affd2c19959 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 3 Aug 2026 02:24:13 +0200 Subject: [PATCH 5/5] CB-WP-0018 T03/T04: explanations, and window 1's verdict T03: input::describe writes a sentence per legal command; data-descs carries them in step with data-targets; the ghost already following the pointer shows the one for whatever legal target is under it, so the explanation lands beside the target with no overlay layer to keep aligned. ADR-0010 D1 binds -- the page renders it, never composes it. Both mutations INITIALLY SURVIVED because the fixture's Attack card had exactly one target, where an off-by-one shift and a truncation are both no-ops. CB-EV-0014's lesson one level in: a fixture too thin to express a failure is how the failure survives. Two attack targets now, both red. T04: chaos rate d4 -> d8, window 2 open at 12 declarations, retiring if an override changes nothing twice running. Window 1's condition was NOT met -- both overrides changed the outcome -- so the mechanism is kept. The weakest part of the decision is that it is a rate change argued from n=2, so window 2 carries a falsifier: no override at all is evidence the rate went too far, not that the mechanism is healthy. InnerLoop.md hit 401 lines and the loadability gate fired; the rationale moved to InnerLoopReference.md, structurally, per the standing precedent that limits are not raised. CB-WP-0017 settled at $9.48/40 against $5.19/23 reported mid-flight, 83% higher. Six for six, always low -- read by re-running the instrument at the moment of quoting, which is CB-EV-0015's correction applied for the first time. make all exits 0. Co-Authored-By: Claude Opus 5 --- crates/cb-render-html/src/doc.rs | 43 ++++- crates/cb-render-html/src/input.rs | 34 ++++ crates/cb-render-html/src/jsrun.rs | 98 +++++++++- .../CB-EV-0016-the-browser-is-a-client.md | 182 ++++++++++++++++++ gates.toml | 6 +- specs/InnerLoop.md | 21 +- specs/InnerLoopReference.md | 32 +++ tools/cb-play/src/hotseat.rs | 2 +- .../CB-WP-0018-the-browser-is-a-client.md | 36 +++- 9 files changed, 424 insertions(+), 30 deletions(-) create mode 100644 evidence/CB-EV-0016-the-browser-is-a-client.md diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 351176f..35c43a8 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -56,7 +56,7 @@ fn cards(list: &[games_ground::SolutionCard]) -> String { /// The only JavaScript in the project. See the module docs. pub const SCRIPT: &str = r#" (function () { - var down = null, held = null, marked = [], ghost = null; + var down = null, held = null, marked = [], ghost = null, ghostLabel = ''; function node(e) { var n = e.target; @@ -70,6 +70,19 @@ pub const SCRIPT: &str = r#" // wrote. This matches on them. It does not compute, infer, filter or // default one -- a script that pattern-matched ids to guess what is // legal would be forbidden even though the visible result is identical. + // ADR-0010 D1 again: `data-descs` is written by Rust, in step with + // `data-targets`. The script pairs them by index and renders one. It + // does not compose a description from an id. + function describeOf(held, key) { + if (!held) { return null; } + var t = held.getAttribute('data-targets'); + var d = held.getAttribute('data-descs'); + if (!t || !d) { return null; } + var i = t.split(' ').indexOf(key); + var all = d.split('|'); + return i >= 0 && i < all.length ? all[i] : null; + } + function mark(n) { var spec = n.getAttribute('data-targets'); if (!spec) { return; } @@ -88,6 +101,7 @@ pub const SCRIPT: &str = r#" if (held) { held.classList.remove('held'); held = null; } if (ghost && ghost.parentNode) { ghost.parentNode.removeChild(ghost); } ghost = null; + ghostLabel = ''; down = null; } @@ -102,7 +116,8 @@ pub const SCRIPT: &str = r#" if (n.getAttribute('data-targets')) { ghost = document.createElement('div'); ghost.id = 'cb-ghost'; - ghost.textContent = n.textContent; + ghostLabel = n.textContent; + ghost.textContent = ghostLabel; ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; document.body.appendChild(ghost); @@ -113,6 +128,11 @@ pub const SCRIPT: &str = r#" if (!ghost) { return; } ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; + // 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.className = over ? 'over' : ''; }); document.addEventListener('pointercancel', clear); @@ -165,6 +185,7 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf} .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} #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; @@ -572,24 +593,32 @@ fn move_section( // wherever the real target set was narrower, which is almost // everywhere: Investigate is legal on problems 2 and 3 but // not 1 (CB-WP-0017). - let targets: Vec = seat + // CB-WP-0018 T03: targets and their meanings, in step, both + // written by Rust. ADR-0010 D1 forbids the page composing the + // second from the first. + let offered: Vec<(String, String)> = seat .map(|seat| { legal .iter() - .filter_map(|c| crate::input::affordance(c, seat)) - .filter(|(f, _)| *f == action_id(a)) - .map(|(_, t)| t) + .filter_map(|c| { + crate::input::affordance(c, seat) + .filter(|(f, _)| *f == action_id(a)) + .map(|(_, t)| (t, crate::input::describe(c, seat))) + }) .collect() }) .unwrap_or_default(); + let targets: Vec = offered.iter().map(|(t, _)| t.clone()).collect(); + let descs: Vec = offered.iter().map(|(_, d)| d.clone()).collect(); if !targets.is_empty() { let _ = write!( s, "
{a:?}
\ + data-targets=\"{targets}\" data-descs=\"{descs}\">{a:?}
\ onto {names}
", id = action_id(a), targets = esc(&targets.join(" ")), + descs = esc(&descs.join("|")), names = esc(&target_names(&targets)), ); } diff --git a/crates/cb-render-html/src/input.rs b/crates/cb-render-html/src/input.rs index 8e2e604..6351740 100644 --- a/crates/cb-render-html/src/input.rs +++ b/crates/cb-render-html/src/input.rs @@ -96,6 +96,40 @@ pub fn affordance(command: &GroundCommand, seat: cb_kernel::PlayerId) -> Option< } } +/// What a drop would mean, in a sentence. +/// +/// **ADR-0010 Decision 1 puts this in Rust.** The page may render it; it +/// may not compose it. A script that assembled "Attack P2" from an id and +/// a seat name would be deriving a game fact, and would be wrong the +/// moment a command meant something the ids do not say. +pub fn describe(command: &GroundCommand, seat: cb_kernel::PlayerId) -> String { + let who = |p: cb_kernel::PlayerId| format!("P{}", p.0 + 1); + match command { + GroundCommand::SelectAction { + action, + target, + problem, + } => { + let head = match action { + Action::Attack => "Attack", + Action::Support => "Support", + Action::Solve => "Solve", + Action::Investigate => "Investigate", + Action::Ground => "Ground", + }; + match (target, problem) { + (Some(t), _) => format!("commit {head} against {}", who(*t)), + (_, Some(n)) => format!("commit {head} on problem {n}"), + _ => format!("commit {head}, untargeted"), + } + } + GroundCommand::SpendFreedom => { + format!("{} spends freedom to act again", who(seat)) + } + other => format!("{other:?}"), + } +} + /// Resolve a pointer fact to an index into the legal list. /// /// Returns `Err` rather than a default when nothing matches: a drag that diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs index 5713ee3..4a84f60 100644 --- a/crates/cb-render-html/src/jsrun.rs +++ b/crates/cb-render-html/src/jsrun.rs @@ -64,13 +64,14 @@ var __all = []; // A DOM node with a real classList and real attributes. CB-WP-0016 found // that a stub too thin to express a failure is how the failure survives; // the previous stub could not express class-toggling at all. -function __mk(key, targets, text) { +function __mk(key, targets, text, descs) { var n = { parentNode: null, textContent: text || key, style: {}, _cls: {}, - _attr: { 'data-drop': key, 'data-targets': targets || null }, + _attr: { 'data-drop': key, 'data-targets': targets || null, + 'data-descs': descs || null }, getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; }, setAttribute: function (a, v) { this._attr[a] = v; }, classList: { @@ -81,7 +82,7 @@ function __mk(key, targets, text) { }; return n; } -function __register(key, targets) { var n = __mk(key, targets); __all.push(n); return n; } +function __register(key, targets, descs) { var n = __mk(key, targets, key, descs); __all.push(n); return n; } var document = { body: __body, @@ -103,7 +104,9 @@ function fetch(url, opts) { // every one of them (CB-EV-0014 section 2). function __down(k) { __handlers['pointerdown']({ target: __find(k), clientX: 1, clientY: 2 }); } function __up(k) { __handlers['pointerup']({ target: __find(k), clientX: 3, clientY: 4 }); } -function __move() { if (__handlers['pointermove']) { __handlers['pointermove']({ clientX: 9, clientY: 9 }); } } +function __move() { if (__handlers['pointermove']) { __handlers['pointermove']({ clientX: 9, clientY: 9, target: __mk(null, null, "") }); } } +function __moveOver(k) { __handlers['pointermove']({ clientX: 5, clientY: 5, target: __find(k) }); } +function __ghostText() { return __body.children.length ? __body.children[0].textContent : ""; } function __cancel() { __handlers['pointercancel']({ target: __find(null) }); } function __find(k) { for (var i = 0; i < __all.length; i++) { @@ -230,12 +233,29 @@ pub fn prepare(ctx: &quick_js::Context, html: &str) -> Result<(), String> { Ok(()) } +/// The `data-descs` of the element carrying `key`, if any. +pub fn descriptions(html: &str, key: &str) -> Option { + let needle = format!("data-drop=\"{key}\""); + let i = html.find(&needle)?; + let tail = &html[i..]; + let end = tail.find('>')?; + let k = tail[..end].find("data-descs=\"")?; + let t = &tail[k + 12..]; + t.find('"').map(|e| t[..e].to_string()) +} + fn register(ctx: &quick_js::Context, html: &str) -> Result<(), String> { for (key, targets) in droppables(html) { - let call = match targets { - Some(t) => format!("__register({}, {});", json_lit(&key), json_lit(&t)), - None => format!("__register({}, null);", json_lit(&key)), - }; + // Descriptions are registered too: the stub could not express + // `data-descs` at all, so the first version of the description + // test failed against a DOM that simply did not carry them. + let descs = descriptions(html, &key); + let call = format!( + "__register({}, {}, {});", + json_lit(&key), + targets.map(|t| json_lit(&t)).unwrap_or("null".into()), + descs.map(|d| json_lit(&d)).unwrap_or("null".into()), + ); ctx.eval(&call) .map_err(|e| format!("register {key}: {e}"))?; } @@ -274,6 +294,15 @@ mod tests { target: Some(PlayerId(1)), problem: None, }, + // TWO attack targets on purpose. With one, an off-by-one in + // the target/description pairing is a no-op and the mutations + // that should catch it survive — a fixture too thin to express + // the failure, which is how the failure survives (CB-EV-0014). + GroundCommand::SelectAction { + action: Action::Attack, + target: Some(PlayerId(2)), + problem: None, + }, ]; crate::doc::document( &crate::testfix::view(Some(PlayerId(0))), @@ -536,6 +565,59 @@ mod tests { } } + /// **ADR-0010 D1 for descriptions.** Dragging over a legal target + /// shows the sentence Rust wrote **for that pair** — not one the page + /// assembled, and not a neighbouring pair's. + #[test] + fn dragging_over_a_target_shows_the_description_rust_wrote_for_it() { + let html = page(); + let ctx = ctx_for(&html); + + // What Rust wrote on the card, read back out of the document. + let card = droppables(&html) + .into_iter() + .find(|(k, _)| k == "action-attack") + .expect("the attack card"); + let targets = card.1.expect("targets"); + let descs = descriptions(&html, "action-attack").expect("descs"); + let i = targets + .split(' ') + .position(|t| t == "seat-1") + .expect("seat-1 is a target"); + let want = descs.split('|').nth(i).expect("a description").to_string(); + assert!(!want.is_empty(), "the description is blank"); + + 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); + + // 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"); + } + + /// Every advertised target has a description; a card offering three + /// drops and two sentences would silently mispair them. + #[test] + fn every_advertised_target_carries_its_own_description() { + let html = page(); + for (key, targets) in droppables(&html) { + let Some(targets) = targets else { continue }; + let descs = descriptions(&html, &key) + .unwrap_or_else(|| panic!("{key} advertises targets but no descriptions")); + assert_eq!( + targets.split(' ').count(), + descs.split('|').count(), + "{key}: {} target(s) but {} description(s)", + targets.split(' ').count(), + descs.split('|').count() + ); + } + } + #[test] fn both_script_blocks_are_extracted_in_order() { let found = scripts(&page()); diff --git a/evidence/CB-EV-0016-the-browser-is-a-client.md b/evidence/CB-EV-0016-the-browser-is-a-client.md new file mode 100644 index 0000000..d78a419 --- /dev/null +++ b/evidence/CB-EV-0016-the-browser-is-a-client.md @@ -0,0 +1,182 @@ +# CB-EV-0016 — the browser is a client, and window 1's verdict + +CB-WP-0018 T04. Measured 2026-08-03 at `e8bb726`+. Pass kind `product`, +tier **M** (structural M — changes the loop's own constraints; chaos d4=3, +no override). **Declaration 1 of chaos window 2**, opened by this pass. + +--- + +## 1. A finished game was indistinguishable from a crash + +Reported: *"after some time i get an empty page back. I guess the game +crashes or ends but that is unclear as the ui disappears."* + +Reproduced before touching anything, by driving a real game to completion +over HTTP: + +``` +move 5 accepted → ok +GET / → [Errno 111] Connection refused +``` + +The game had **ended normally** — 5 rounds, 30 commands — and its entire +result went to a terminal nobody was reading. `next_choice` only accepts +connections *inside* a human decision point, so when `play()` returned the +listener died and the page's post-`ok` reload was refused. + +**The browser was a second-class client.** The outcome, the scores, the +winners, and every error `run_game` can return were invisible to the only +interface a player uses. Now: an 8,998-byte page reading *"GROUND — game +over … 30 commands, hash f6c890a65271"*, with the final table and the log. + +It serves until the page posts `done` — the ending carries a *"close — I +have read this"* control — with a 600 s linger, because a server that +never exits is its own defect and a timeout would race a player reading +the result. + +## 2. The control had to be built twice, and the first was worthless + +`the_end_of_the_game_reaches_the_browser` calls `serve_end` directly. It +passes. **Deleting the call from `run_game` left it green.** + +That is CB-EV-0012's finding recurring almost verbatim — *"every link was +tested and the chain was not"* — and it survived one full round of +mutation here before anyone noticed, because the mutation was run and the +verdict was read as "no coverage gap" rather than "the test is in the +wrong place". + +`a_real_game_played_to_its_end_leaves_the_ending_on_screen` runs the real +`play()` with a browser seat, drives a real game to its end over a real +socket, and requires the last page to be the ending. Under the same +mutation it goes red — and its failure message prints an **empty page**, +which is precisely the symptom that was reported. + +**A weak assertion of mine, caught by itself.** The first version grepped +the ending page for `location.reload`. The page reuses `SCRIPT`, whose +reload is guarded by `t.indexOf('ok') === 0`, and the ending endpoint +answers `closed` — so the grep would have forced a second script into +existence to satisfy a test rather than a requirement. That is the +source-text control shape ADR-0010 D2 demoted three passes ago, reappearing +in my own hands. + +## 3. The card report, and what it actually was + +Reported: *"the cards I play by pulling them on a target will not be +removed … we will need a discard pile."* + +**A discard pile already existed** — `solution_discard` on `GroundState`, +`SolutionDiscarded` removing the card from the hand, and the page already +rendering `deck N remaining / discard …`. Building one would have been +building a thing that was there, and the only reason that did not happen is +that the code was read before the work started. + +What was being dragged are **action** cards. The five GROUND actions are +not cards and are correctly never consumed; solution cards leave the hand +at **Resolve**, because a selection is a face-down commit. + +But the report pointed at something real. Measured live: + +| move | hand | discard | +|---|---|---| +| 1 · Investigate → problem-2 | 2 cards | none | +| 2 · Investigate → problem-3 | **3 cards** | none | +| 3–5 · Solve → problem-1 | **4 cards, unchanged** | none | + +Investigate draws, correctly. **Solve was played three times and did +nothing, three times, in silence** — GR-A02's resolver `continue`s when the +problem is face-down, and `legal_commands` offers Solve on every face-up +problem without consulting the hand. + +**Raised for `ground-game`, not decided here** (INTENT defers game +semantics): *should SOLVE be selectable against a face-down problem, or +against a suit the seat cannot match?* A face-down commit you cannot +fulfil is a plausible bluff in a commit/reveal game with DARVO, which is +exactly why it is not this repo's call. + +## 4. The log, and the limit of what it can honestly say + +`bot::Journal` — `Applied { actor, command, events }` appended by the +driver through the new `play_journaled`; `play` delegates with `None`, so +nothing existing changed. `BotGame.events` is the same information but only +after `play` returns, which is no use to a page rendered mid-game. + +Phrased with `record::to_step`, so *what the player reads is what the +scenario file will say*, and all 29 `GroundEvent` variants render in words +rather than `{:?}`. + +**A command that produced no events says `no effect`**, and the mutation +removing that branch goes red. + +**The honest limit:** the reported SOLVE case does *not* render as `no +effect`, because the SOLVE resolves inside the system's `resolve` command, +which does produce events for other seats. The player now sees the +selection and sees no claim follow it — a large improvement on silence, but +still an inference. Making it explicit would require the renderer to decide +*why* a rule did nothing, which is a second implementation of the rules and +is what this task's own control forbids. Left as an inference deliberately. + +## 5. The explanation, and two mutations that a thin fixture defeated + +Every advertised target now carries a sentence Rust wrote for **that pair** +(`data-descs`, in step with `data-targets`), shown at the pointer while +dragging over it. ADR-0010 D1 binds: the page renders it, never composes +it. + +Both mutations — showing a neighbouring pair's text, and letting targets +and descriptions fall out of step — **initially survived**, because the +test fixture's Attack card had exactly **one** target, where an off-by-one +shift and a truncation are both no-ops. + +That is CB-EV-0014's lesson again, one level in: *a stub too thin to +express a failure is how the failure survives*. The fixture now offers two +attack targets on purpose, and both mutations go red. + +## 6. Window 1's verdict, and a rate change on n=2 + +Twelve declarations, two overrides, one each way, **both changed the +outcome** — so window 1's retirement condition was not met and the +mechanism is kept. The full table is in `InnerLoopReference.md` §Chaos +roll. + +**Rate dropped d4 → d8; window 2 opened at 12 declarations; new retirement +condition: retire if an override changes nothing twice running.** + +**The weakest part of this pass, stated plainly: it is a rate change argued +from n=2.** The alternative — keep d4 for a second window and decide with +four points — was live, and was rejected only because a quarter of all +declarations is a large standing tax to pay for evidence. So window 2 +carries a falsifier: **if it produces no override at all, that is evidence +the rate went too far**, not that the mechanism is healthy. A window that +cannot fire cannot be evaluated, which is the exact failure d10 had. + +## 7. Cost + +| pass | kind | responses | cost | $/response | +|---|---|---|---|---| +| CB-WP-0016 | product | 64 | $14.93 | 0.233 | +| **CB-WP-0017** | product | 40 | **$9.48** | 0.237 | +| CB-WP-0018 | product | *provisional — not quoted* | | | + +Read by **re-running `make status` at the moment of writing**, which is +CB-EV-0015 §6's correction applied for the first time: quoting a figure +remembered from earlier in a session defeats the rule even when the +boundary is right. CB-WP-0017 was reported at $5.19/23 mid-flight and +settled at **$9.48/40** — 83% higher. **Six for six, always low.** + +**Meta budget 0% `[ok]`**, all three trailing passes product. + +## 8. Open + +- **INTENT stage 1: the human check.** Three runs, three defects no test + could reach. Everything in this pass is verified by tests, mutation and a + live socket; nothing perceptual is. +- **For `ground-game`:** should SOLVE be selectable against a face-down + problem or an unmatchable suit? §3. +- **The self-quoting rule** now has both halves recorded but is still not + written into the loop spec. +- **AM-4b's scope defect (408,237 uncounted lines)** and its proc-macro + share. +- **`python3` as a toolchain dependency was never argued.** +- **AM-4a cannot survive stage 2** — 1,741,979 against 161,000. +- **ADR-0007 D3's acquisition rule** and **D5** remain unratified; + ADR-0010 rests on the latter. diff --git a/gates.toml b/gates.toml index 680c6d4..9fde325 100644 --- a/gates.toml +++ b/gates.toml @@ -120,15 +120,15 @@ retire_if = "two passes run with no finding while artifacts keep growing — tha id = "CHAOS" name = "the chaos roll" target = "" -checks = "d4 on each tier declaration, 12-declaration calibration window" +checks = "d8 on each tier declaration, 12-declaration calibration window (window 2, opened 2026-08-03; window 1 ran at d4)" added = "2026-07-30" -review_by = "2026-09-30" +review_by = "2026-11-30" caught = [ "CB-WP-0011: first fire in 6 declarations — d4=4 rolled stage 1 from structural L to S; the deleted survey would have opened on 2D toolkits while the existing text renderer was showing 24 of 41 view fields (CB-EV-0009 §1)", "CB-WP-0017: d4=4 — second override in twelve, and the first to roll UP (structural S → M). It bought ADR-0010: the script's widening from 'it does one thing' to holding a drag, following the pointer and marking other elements would otherwise have landed under a tier-S provenance paragraph, silently outgrowing ADR-0007 D5. The ADR's own finding is that the permitted and forbidden designs are indistinguishable from outside, which demoted the vocabulary grep to a cheap first line and produced the two behavioural controls that replaced it", "CB-WP-0012: d4=1, no override — and the contrast is the entry. Tier L at full weight deleted its own structural trigger: adversarial review withdrew the capability port the declaration was made to build (ADR-0007 D2), and corrected the survey's headline claim by 85x (128x -> 1.5x, CB-EV-0010 §2). Two passes on one subject at two tiers, priced: 0.123 $/response at L against 0.099 at S (CB-EV-0010 §5)", ] -retire_if = "the window closes with no overridden tier producing a different outcome than the argued one — the evaluation this window exists to make possible" +retire_if = "an override changes nothing twice running (window 2 condition, CB-WP-0018 T04). Window 1's condition — no override changing the outcome — was NOT met: both did, so the mechanism was kept and the rate dropped d4 → d8 instead" # VERDICT, CB-EV-0015 §5 (window closed 2026-08-02, 12 declarations, 2 overrides). # Not retired: both overrides changed the outcome. CB-WP-0011 (L→S) bought a # defect in the existing renderer that the deleted survey would have walked diff --git a/specs/InnerLoop.md b/specs/InnerLoop.md index c41965c..1de6699 100644 --- a/specs/InnerLoop.md +++ b/specs/InnerLoop.md @@ -120,17 +120,22 @@ are never skipped for code-producing work. | **M** | Survey and ADR merged into one document; review optional | Touches a canonical interface, adds/updates an external dependency, **or changes whether or how the loop constrains its own operation** — budgets, gates, review requirements, or these tier rules (v1.6, ADR-0006 D5) | | **S** | One provenance paragraph in the commit message | Everything else (utilities, fixes, refactors inside a boundary) | -**The chaos roll.** After deriving the structural tier, roll **d4** -(`shuf -i 1-4 -n 1`). On a **4**, the tier is instead picked uniformly at +**The chaos roll.** After deriving the structural tier, roll **d8** +(`shuf -i 1-8 -n 1`). On an **8**, the tier is instead picked uniformly at random (`shuf -e S M L -n 1`), overriding the structural derivation — up or down. -> **Calibration window, opened 2026-07-31, running to 12 tier -> declarations** (declaration 4 of 12 as of 2026-08-01). The rate was -> raised from d10 to d4 because at d10 the mechanism never fired and -> so prevented its own evaluation. Record the roll every time, -> including when it changes nothing (`tier: L (structural L, chaos 4)`). -> Rationale, cost estimate and the two dead rolls: +> **Window 1 closed 2026-08-02** at 12 declarations, 2 overrides, one each +> way, and **both changed the outcome** — so the mechanism was kept and +> the rate dropped d4 → d8 (CB-EV-0015 §5, CB-EV-0016 §4). +> +> **Window 2, opened 2026-08-03 at d8**, running to 12 declarations. +> Retirement condition: **retire if an override changes nothing twice +> running.** +> +> Record the roll every time, including when it changes nothing +> (`tier: L (structural L, chaos 8)`). Why the rate fell, why n=2 makes +> that the weakest part of the decision, and the dead rolls: > `specs/InnerLoopReference.md` §Chaos roll — calibration. Chaos limits: a rolled-down tier relaxes *process* weight only. Invariants diff --git a/specs/InnerLoopReference.md b/specs/InnerLoopReference.md index bf2b529..e60f773 100644 --- a/specs/InnerLoopReference.md +++ b/specs/InnerLoopReference.md @@ -195,3 +195,35 @@ four implementation rules the pass earned, and the requirement that evidence state what it does not support. Rationale and the failures behind each: `history/260731-inner-loop-retrospective.md`. + +## Chaos roll — window 1's verdict and the d4 → d8 change + +*(CB-WP-0018 T04, 2026-08-03. Full argument in `evidence/CB-EV-0015.md` §5 +and `evidence/CB-EV-0016.md` §4.)* + +Window 1 ran 2026-07-31 → 2026-08-02, twelve declarations, at d4 after an +earlier d10 that never fired and so prevented its own evaluation. + +**Two overrides, one in each direction, and both changed the outcome**, so +window 1's retirement condition — *"the window closes with no overridden +tier producing a different outcome than the argued one"* — was not met: + +| pass | roll | what the override bought | +|---|---|---| +| CB-WP-0011 | structural L → **S** | the deleted survey would have opened on 2D toolkits; the pass instead found the existing text renderer showing 24 of 41 view fields. Priced on the same subject: 0.099 $/response at S against 0.123 at L | +| CB-WP-0017 | structural S → **M** | ADR-0010. At tier S the page's script would have grown from *"it does one thing"* to holding a drag, following the pointer and marking other elements under a one-paragraph commit note, silently outgrowing ADR-0007 D5 | + +**Why the rate fell.** Both were informative *because they were rare*. At +d4 the mechanism overrides a quarter of all declarations, at which point it +stops being a calibration on the tier table and becomes a second tier +table. d8 keeps the mechanism and restores its rarity. + +**The weakest part of this decision, stated plainly:** it is a rate change +argued from **n=2**. The alternative — keep d4 for a second window and +decide with four data points — was live and was rejected only because a +quarter of declarations is a large standing tax to pay for evidence. + +So window 2 carries a falsifier: **if it produces no override at all, that +is evidence the rate went too far**, not evidence the mechanism is +healthy. A window that cannot fire cannot be evaluated, which is the exact +failure d10 had. diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index a0659bb..083c32f 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -19,7 +19,7 @@ use std::rc::Rc; use cb_game_runtime::{Project, Viewer}; use cb_kernel::PlayerId; -use cb_render_html::{document, resolve, Guard, PointerFact, Request}; +use cb_render_html::{resolve, Guard, PointerFact, Request}; use games_ground::bot::{Choice, Policy}; use games_ground::{GroundCommand, GroundState}; diff --git a/workplans/CB-WP-0018-the-browser-is-a-client.md b/workplans/CB-WP-0018-the-browser-is-a-client.md index 0955fed..e125086 100644 --- a/workplans/CB-WP-0018-the-browser-is-a-client.md +++ b/workplans/CB-WP-0018-the-browser-is-a-client.md @@ -2,7 +2,7 @@ id: CB-WP-0018 kind: product title: "The browser is a client: game over, a log, and where a drop goes" -status: todo +status: done --- # Purpose @@ -235,7 +235,7 @@ the question of whether the move should be offered at all. ```task id: CB-WP-0018-T03 -status: todo +status: done priority: high ``` @@ -257,11 +257,23 @@ description a target shows is the one Rust wrote for *that* pair — a mutation that shows a neighbouring pair's text must go red. The set equality property from ADR-0010 D2 must still hold. +**Done 2026-08-03.** `input::describe` writes a sentence per legal +command; `data-descs` carries them in step with `data-targets`; the ghost +already following the pointer shows the one for whatever legal target is +under it, so the explanation appears beside the target without an overlay +layer to keep aligned. + +**Both mutations initially SURVIVED**, because the fixture's Attack card +had exactly **one** target — where an off-by-one shift and a truncation +are both no-ops. That is CB-EV-0014's lesson one level in: *a fixture too +thin to express a failure is how the failure survives*. The fixture now +offers two attack targets on purpose and both go red. + ## Task: close the chaos change, and the evidence ```task id: CB-WP-0018-T04 -status: todo +status: done priority: high ``` @@ -293,3 +305,21 @@ Then `evidence/CB-EV-0016-*.md`: memory — CB-EV-0015 §6 found that quoting a remembered figure defeats the rule even when the boundary is right, and this is the first pass that can apply that correction. + +**Done 2026-08-03.** +[CB-EV-0016](../evidence/CB-EV-0016-the-browser-is-a-client.md). `make +all` exits 0. + +- **Chaos rate d4 → d8, window 2 open at 12 declarations**, retiring if an + override changes nothing twice running. Window 1's condition was not + met — both overrides changed the outcome — so the mechanism is kept. + **The weakest part of the decision is that it is a rate change argued + from n=2**, and window 2 therefore carries a falsifier: no override at + all is evidence the rate went too far. +- The rationale moved to `InnerLoopReference.md` because `InnerLoop.md` + hit 401 lines and the loadability gate fired — fixed structurally, per + the standing precedent that limits are not raised. +- **CB-WP-0017 settled at $9.48/40** against $5.19/23 reported mid-flight, + 83% higher. Six for six, always low. Read by re-running the instrument + at the moment of quoting, which is CB-EV-0015 §6's correction applied + for the first time.