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