//! Hot-seat play in a browser (ADR-0007, INTENT stage 1). //! //! One loopback listener, one tab, seats taking turns. The server blocks //! inside [`Policy::choose`] until the page reports a pointer fact that //! resolves to a legal command — so the game loop is the same //! `games_ground::bot::play` loop the CLI and the bots run through, and a //! browser seat is indistinguishable from a CLI seat to the driver. //! //! Every control in ADR-0007 §Decision 5 lives in `cb-render-html`; this //! module is the socket and the turn-taking. It deliberately holds no //! game logic beyond "which seat is being asked" — [`cb_render_html`] //! decides what a drag means, and the aggregate decides whether the //! result is legal. use std::cell::RefCell; use std::fmt::Write as _; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::rc::Rc; use cb_game_runtime::{Project, Viewer}; use cb_kernel::PlayerId; use cb_render_html::{resolve, Guard, Note, PointerFact, Request}; use games_ground::bot::{Choice, Policy}; use games_ground::{GroundCommand, GroundState}; /// The shared listener. One per process; every human seat borrows it. 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, /// What the meta panel shows about the session (CB-WP-0027 T02). /// Written by the driver between games, read at render time. meta: RefCell>, /// Where the trial log is written, if this session is a trial. trial: Option, /// Notes so far, so the log is rewritten whole rather than appended /// to — a partial write leaves a file that neither a human nor /// `make trials` can read. notes: RefCell>, } /// One thing a player said, and where they said it (ADR-0014 D3). #[derive(Debug, Clone)] pub struct TrialNote { pub n: usize, pub round: u8, pub step: String, pub state_hash: String, pub text: String, } /// Write the trial log: readable Markdown with one machine-readable block. /// /// **Reusing `FindingRegister.md`'s idiom deliberately** (ADR-0014 D2) — /// a table between HTML-comment markers, so a human reads the file and a /// tool reads the block, and neither needs the other's cooperation. pub fn write_trial_log(path: &std::path::Path, notes: &[TrialNote]) -> Result<(), String> { let mut s = String::new(); s.push_str("# Trial log\n\n"); s.push_str( "What the player said while playing, bound to the position they said it at\n\ (CB-WP-0027, ADR-0014). The recording beside this file is the position;\n\ `state_hash` is what reaches it.\n\n\ **These are raw notes and they stay here.** Nothing in this file travels to\n\ `ground-game` (ADR-0014 D4) — a note reaches them only by being promoted to a\n\ register finding, by a human, with the wording chosen then.\n\n", ); s.push_str("\n\n"); s.push_str("| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n"); for note in notes { // Pipes and newlines would break the table, so they are replaced // rather than escaped: the note is prose, and a reader losing a // literal `|` matters less than a log nothing can parse. let text = note.text.replace(['\n', '\r'], " ").replace('|', "/"); let _ = writeln!( s, "| {} | {} | {} | {} | {} |", note.n, note.round, note.step, note.state_hash, text ); } s.push_str("\n\n"); if let Some(dir) = path.parent() { std::fs::create_dir_all(dir).map_err(|e| format!("trial dir: {e}"))?; } std::fs::write(path, s).map_err(|e| format!("write trial log: {e}")) } impl Server { /// Where this session's trial log goes. Without one, the note /// channel refuses rather than dropping what the player wrote. pub fn with_trial(mut self, path: Option) -> Self { self.trial = path; self } pub fn bind(port: u16) -> Result { let listener = cb_render_html::serve::bind(port).map_err(|e| format!("bind: {e}"))?; let port = listener .local_addr() .map_err(|e| format!("local_addr: {e}"))? .port(); Ok(Self { listener, guard: Guard::mint(port), log: RefCell::new(Vec::new()), journal: games_ground::bot::Journal::default(), meta: RefCell::new(Vec::new()), trial: None, notes: RefCell::new(Vec::new()), }) } /// The URL to open, token and all. Printed once at start; the page /// cannot mint one for itself. pub fn url(&self) -> String { self.guard.page_url() } /// Append a note to the trial log, bound to the position it was /// written at (ADR-0014 D2/D3). /// /// **The state hash is the binding.** Round and step orient a reader; /// the hash is what lets one *reach* the position, because it is the /// same value that makes a session comparable to its replay. fn record_note(&self, state: &GroundState, note: &Note) -> Result<(), String> { let Some(path) = self.trial.as_ref() else { // No trial log configured: refuse rather than drop. A note // that vanishes is worse than a note that was never offered, // and this is the failure mode the whole pass exists to avoid. return Err("no trial log for this session — start with --trial".into()); }; let hash = cb_events::state_hash_hex(state); let mut log = self.notes.borrow_mut(); let n = log.len() + 1; log.push(TrialNote { n, round: state.round, step: format!("{:?}", state.step), state_hash: hash[..12].to_string(), text: note.text.clone(), }); write_trial_log(path, &log) } /// Set what the meta panel shows about the session (CB-WP-0027 T02). /// /// Called by the driver between games, so the running tally is visible /// **while playing** rather than only on the ending page — which is /// where it was, and a tally you only see once the game is over /// informs nothing. pub fn set_meta(&self, lines: Vec) { *self.meta.borrow_mut() = lines; } /// 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() } /// Serve until the page reports something that resolves to a move. /// /// Requests that fail a control are answered and the loop continues — /// a refused request must not end someone's game, and must not be /// silently indistinguishable from one that did nothing. pub fn next_choice( &self, state: &GroundState, seat: PlayerId, legal: &[GroundCommand], may_pass: bool, ) -> Result { let view = state.project(Viewer::Player(seat)); loop { let (mut stream, _) = self.listener.accept().map_err(|e| format!("accept: {e}"))?; let raw = match read_request(&mut stream) { Ok(r) => r, Err(e) => { respond(&mut stream, 400, "text/plain", &e); continue; } }; let req = match Request::parse(&raw) { Ok(r) => r, Err(refusal) => { self.log.borrow_mut().push(refusal.to_string()); respond(&mut stream, 400, "text/plain", &refusal.to_string()); 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::document_with_log( &view, legal, cb_render_html::Endpoints { command: &self.guard.endpoint(), note: &self.guard.note_endpoint(), }, Some(seat), may_pass, &self.log_lines(), // CB-WP-0027 T02: the meta panel's own content. // The running tally lives with the driver, so the // server is handed the lines rather than the state. &self.meta.borrow(), ); respond(&mut stream, 200, "text/html; charset=utf-8", &page); } ("POST", "/command") => { let fact = match PointerFact::parse(&req.body) { Ok(f) => f, Err(e) => { respond(&mut stream, 400, "text/plain", &e); continue; } }; if may_pass && fact.down == "pass" && fact.up == "pass" { respond(&mut stream, 200, "text/plain", "ok: passed"); return Ok(Choice::Pass); } match resolve(&fact, legal, seat) { Ok(i) => { respond(&mut stream, 200, "text/plain", "ok"); return Ok(Choice::Command(i)); } // Not an error: the player dragged somewhere that // means nothing. Say so and keep the turn. Err(why) => respond(&mut stream, 200, "text/plain", &why), } } // CB-WP-0027 T03 / ADR-0014 D1. A SECOND CHANNEL, and it // cannot carry a move: `Note::parse` returns a `Note`, // `resolve` takes a `PointerFact`, and there is no // conversion between them. The game does not advance here // and the loop keeps waiting for the seat's actual move. ("POST", "/note") => match Note::parse(&req.body) { Ok(note) => match self.record_note(state, ¬e) { Ok(()) => { // 303 so the browser re-GETs the table rather // than leaving a form POST in history — a // reload would otherwise re-submit the note. // // WITH THE TOKEN. Redirecting to bare `/` sent // the browser to a request control 1 refuses, // so the note was saved and the player was // shown "no session token" — which reads as // the note having failed. respond_seeother(&mut stream, &self.guard.page_path()); } Err(e) => respond(&mut stream, 500, "text/plain", &e), }, Err(why) => respond(&mut stream, 400, "text/plain", &why), }, _ => respond(&mut stream, 404, "text/plain", "no such thing here"), } } } /// 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, tally: &crate::table::MatchTally, ) -> Result { let deadline = std::time::Instant::now() + linger; self.listener .set_nonblocking(true) .map_err(|e| format!("nonblocking: {e}"))?; let outcome = loop { if std::time::Instant::now() >= deadline { break Ok(EndChoice::Closed); } 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(), &self.log_lines(), &series_lines(tally), ); respond(&mut stream, 200, "text/html; charset=utf-8", &page); } ("POST", "/command") => { // CB-WP-0020 T05: the browser could not start a second // game without going back to a terminal, which made it // a strictly worse client than the CLI it replaces. let again = req.body.contains("again"); respond( &mut stream, 200, "text/plain", // CB-WP-0024 T01: the reply is what the player // reads. "closed" alone left them looking at a // live table wondering why nothing happened. // The `closed` prefix is what the page's script // matches on to seal itself. if again { "ok: dealing" } else { "closed \u{2014} the session has ended and the server has stopped. \ You can close this tab." }, ); break Ok(if again { EndChoice::Again } else { EndChoice::Closed }); } _ => respond(&mut stream, 404, "text/plain", "the game is over"), } }; self.listener.set_nonblocking(false).ok(); outcome } } /// The running series, phrased for a reader. /// /// **Two lines, because the rules define one game and not a series** /// (CB-WP-0024 T04). `personal` sums per seat; `winners` counts games /// won; they answer different questions and GROUND says which is *the* /// series score nowhere at all. Both are shown and both are named, so /// nothing here quietly becomes canon. pub fn series_lines(t: &crate::table::MatchTally) -> Vec { if t.games <= 1 { // One game is not a series, and a "cumulative" panel over a single // result would just restate the outcome above it. return Vec::new(); } let seats = |m: std::collections::BTreeMap| { m.iter() .map(|(p, v)| format!("P{} {v}", p.0 + 1)) .collect::>() .join(" ") }; let personal: std::collections::BTreeMap<_, _> = t .personal .iter() .map(|(p, v)| (*p, format!("{v:+}"))) .collect(); let wins: std::collections::BTreeMap<_, _> = t .personal .keys() .map(|p| (*p, t.wins.get(p).copied().unwrap_or(0).to_string())) .collect(); vec![ format!("{} games this session", t.games), format!("summed personal score — {}", seats(personal)), format!("games won — {}", seats(wins)), format!( "table cleared its threshold {} of {} games", t.group_successes, t.games ), ] } #[cfg(test)] mod series { use crate::table::MatchTally; use cb_kernel::PlayerId; use games_ground::view::OutcomeView; fn outcome(p1: i32, p2: i32, success: bool, winners: Vec) -> OutcomeView { OutcomeView { total: 0, threshold: 5, group_success: success, personal: [(PlayerId(0), p1), (PlayerId(1), p2)].into_iter().collect(), coalitions: vec![], mastery: None, winners, } } fn after(games: &[OutcomeView]) -> MatchTally { let mut t = MatchTally::default(); for o in games { t.record_for_test(o); } t } /// The weakest possible assertion, and the one that catches a tally /// reset by `play again`: two games must produce a total that is /// neither game's score alone. #[test] fn two_games_accumulate_rather_than_replacing() { let t = after(&[ outcome(3, 1, true, vec![PlayerId(0)]), outcome(2, 4, false, vec![PlayerId(1)]), ]); assert_eq!(t.games, 2); assert_eq!(t.personal[&PlayerId(0)], 5, "P1's scores did not sum"); assert_eq!(t.personal[&PlayerId(1)], 5, "P2's scores did not sum"); assert_eq!(t.group_successes, 1, "only one game cleared its threshold"); assert_eq!(t.wins[&PlayerId(0)], 1); assert_eq!(t.wins[&PlayerId(1)], 1); } /// **The two tallies disagree, and that is the point.** Summed /// personal score says the seats are level; games won says the same; /// but a third game separates them, and which one is "the series /// score" is a question GROUND does not answer. Showing one alone /// would be picking a winner the rules never named. #[test] fn the_two_tallies_can_disagree_about_who_is_ahead() { let t = after(&[ outcome(9, 0, false, vec![PlayerId(0)]), outcome(0, 1, false, vec![PlayerId(1)]), outcome(0, 1, false, vec![PlayerId(1)]), ]); assert!( t.personal[&PlayerId(0)] > t.personal[&PlayerId(1)], "P1 leads on summed score" ); assert!( t.wins[&PlayerId(1)] > t.wins.get(&PlayerId(0)).copied().unwrap_or(0), "P2 leads on games won — the two answers point at different seats" ); } /// One game is not a series: the panel stays absent rather than /// restating the outcome directly above it. #[test] fn a_single_game_shows_no_series_panel() { assert!(super::series_lines(&after(&[outcome(1, 1, true, vec![])])).is_empty()); assert!( !super::series_lines(&after(&[ outcome(1, 1, true, vec![]), outcome(1, 1, true, vec![]) ])) .is_empty(), "two games must produce a panel" ); } /// Both tallies must be named on the page. An unlabelled number would /// become "the score" by default, which is the canonisation this /// deliberately avoids. #[test] fn the_page_says_which_tally_is_which() { let lines = super::series_lines(&after(&[ outcome(1, 2, true, vec![PlayerId(1)]), outcome(3, 0, false, vec![PlayerId(0)]), ])); let all = lines.join(" | "); assert!(all.contains("summed personal score"), "{all}"); assert!(all.contains("games won"), "{all}"); assert!(all.contains("cleared its threshold"), "{all}"); } } /// What the player asked for on the ending page. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EndChoice { Closed, Again, } /// One seat's view of the shared server. pub struct SeatPolicy { server: Rc, failure: Rc>>, } impl SeatPolicy { pub fn new(server: Rc, failure: Rc>>) -> Self { Self { server, failure } } } impl Policy for SeatPolicy { fn name(&self) -> &'static str { "browser" } fn choose( &mut self, state: &GroundState, seat: PlayerId, legal: &[GroundCommand], may_pass: bool, ) -> Choice { match self.server.next_choice(state, seat, legal, may_pass) { Ok(c) => c, Err(e) => { // Same shape as the CLI's out-of-input path: record the // real reason and return something the driver will // reject, rather than inventing a move. *self.failure.borrow_mut() = Some(e); Choice::Command(usize::MAX) } } } } /// 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]; loop { let n = stream.read(&mut chunk).map_err(|e| format!("read: {e}"))?; if n == 0 { break; } buf.extend_from_slice(&chunk[..n]); // Once the head is in, read exactly the declared body and stop — // otherwise a keep-alive connection blocks here forever. if let Some(head_end) = find(&buf, b"\r\n\r\n") { let head = String::from_utf8_lossy(&buf[..head_end]).to_string(); let want: usize = head .split("\r\n") .find_map(|l| { let (k, v) = l.split_once(':')?; k.eq_ignore_ascii_case("content-length") .then(|| v.trim().parse().ok())? }) .unwrap_or(0); if buf.len() >= head_end + 4 + want { break; } } if buf.len() > 64 * 1024 { return Err("request too large".to_string()); } } String::from_utf8(buf).map_err(|_| "request is not UTF-8".to_string()) } fn find(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|w| w == needle) } /// 303 See Other, so the browser re-GETs the table after a form POST /// rather than leaving the POST in history — a reload would otherwise /// re-submit the note and duplicate it. fn respond_seeother(stream: &mut TcpStream, to: &str) { let head = format!("HTTP/1.1 303 See Other\r\nLocation: {to}\r\nContent-Length: 0\r\n\r\n"); let _ = stream.write_all(head.as_bytes()); let _ = stream.flush(); } fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &str) { let reason = match status { 200 => "OK", 400 => "Bad Request", 403 => "Forbidden", _ => "Not Found", }; let head = format!( "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\ Content-Length: {}\r\nConnection: close\r\n\ X-Content-Type-Options: nosniff\r\n\r\n", body.len() ); let _ = stream.write_all(head.as_bytes()); let _ = stream.write_all(body.as_bytes()); let _ = stream.flush(); } #[cfg(test)] mod tests { use super::*; use games_ground::Action; /// One client thread issuing requests **in order**, each on its own /// `Connection: close` socket and each fully read before the next is /// 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), &crate::table::MatchTally::default(), ) .expect("serve_end"); let replies = client.join().expect("client"); assert!(replies[0].contains("200 OK"), "{}", replies[0]); // CB-WP-0028 T06: the heading follows the OUTCOME, so this asks // the view rather than hardcoding a word. The old assertion said // "game over" and went red when a won game started saying // "solved" -- which was the feature working. let want = match view.outcome.as_ref() { Some(o) if o.group_success => "game solved", Some(_) => "game over", None => "the game stopped", }; assert!( replies[0].contains(want), "the heading did not match the outcome; expected {want:?}" ); 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]); } /// **CB-WP-0020 T05.** "play again" must deal a second game, not just /// answer politely and exit — the browser could not start a second /// game without going back to a terminal, which made it a strictly /// worse client than the CLI it replaces. #[test] fn play_again_deals_a_second_game() { let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let out = SharedOut(buf.clone()); let game = std::thread::spawn(move || { crate::table::play( &crate::table::Config { seed: 3, players: 3, human_seats: vec![0], bot: "random".into(), replay_dir: None, record: None, serve: Some(0), trial: None, mode: games_ground::ScoringMode::SharedGround, }, std::io::Cursor::new(Vec::new()), out, ) }); let (port, token) = wait_for_url(&buf); // Play one game out, ask for another, play that one out too. let mut deals = 0; for round in 0..2 { drive_to_end(port, &token); deals += 1; let want = if round == 0 { "again" } else { "done" }; let body = format!("down={want}&up={want}"); let reply = http( port, &format!( "POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\ Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}", body.len() ), ); if round == 0 { assert!(reply.contains("dealing"), "play again refused: {reply}"); } } assert_eq!(deals, 2, "the second game was never dealt"); game.join().expect("game thread").expect("the games ran"); // Two games, and they must not be the same deal — "again" that // re-dealt the identical game would satisfy a naive count. let text = String::from_utf8_lossy(&buf.lock().expect("out")).to_string(); let hashes: Vec<&str> = text .match_indices("game over \u{2014} ") .map(|(i, _)| &text[i..i + 60]) .collect(); assert!(hashes.len() >= 2, "only {} game(s) finished", hashes.len()); assert_ne!(hashes[0], hashes[1], "play again re-dealt the same game"); } /// A game that ended badly must say so rather than draw a table for a /// game that never happened. #[test] 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(()) } } /// Block until the server prints its URL, then return (port, token). fn wait_for_url(buf: &std::sync::Arc>>) -> (u16, String) { loop { let text = String::from_utf8_lossy(&buf.lock().expect("out")).to_string(); if let Some(i) = text.find("http://127.0.0.1:") { let rest = &text[i..]; let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); let url = &rest[..end]; let port = url["http://127.0.0.1:".len()..] .split('/') .next() .expect("port") .parse() .expect("port number"); return (port, url.split("t=").nth(1).expect("token").to_string()); } std::thread::sleep(std::time::Duration::from_millis(20)); } } /// Play the offered moves until the page stops offering any, which is /// the ending. Returns the last page. fn drive_to_end(port: u16, token: &str) -> String { let mut last = String::new(); for _ in 0..60 { last = http( port, &format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"), ); let keys = cb_render_html::jsrun::droppables(&last); let spatial = keys .iter() .find(|(k, t)| k.starts_with("action-") && t.is_some()) .map(|(k, t)| { ( k.clone(), t.as_ref() .expect("checked") .split(' ') .next() .expect("t") .to_string(), ) }); let button = keys .iter() .find(|(k, _)| k.starts_with("cmd-") || k == "pass") .map(|(k, _)| (k.clone(), k.clone())); let Some((down, up)) = spatial.or(button) else { return last; }; let body = format!("down={down}&up={up}"); http( port, &format!( "POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\ Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}", body.len() ), ); } last } fn http(port: u16, raw: &str) -> String { let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect"); s.write_all(raw.as_bytes()).expect("write"); 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), trial: None, mode: games_ground::ScoringMode::SharedGround, }, 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"); // 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"; 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 .into_iter() .map(|raw| { 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 }) .collect() }) } fn get(token: &str) -> String { format!( "GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ Sec-Fetch-Site: same-origin\r\n\r\n" ) } fn post(token: &str, body: &str) -> String { format!( "POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ Sec-Fetch-Site: same-origin\r\nContent-Length: {}\r\n\r\n{body}", body.len() ) } fn ground_only() -> Vec { vec![GroundCommand::SelectAction { action: Action::Ground, target: None, problem: None, }] } fn state() -> GroundState { ::setup( &cb_game_runtime::Setup { players: 3, preset: "standard-3p".to_string(), patch: Default::default(), }, 7, ) .expect("setup") } /// The whole loop, with a real socket and no browser: the page is /// served, a pointer fact is posted, and the seat's choice comes back. #[test] fn a_drag_posted_over_the_socket_becomes_a_choice() { let server = Server::bind(0).expect("bind"); let port = server.listener.local_addr().unwrap().port(); let token = server.url().rsplit("t=").next().unwrap().to_string(); let client = converse( port, vec![get(&token), post(&token, "down=action-ground&up=table")], ); let choice = server .next_choice(&state(), PlayerId(0), &ground_only(), false) .expect("a choice"); assert_eq!(choice, Choice::Command(0)); let replies = client.join().expect("client thread"); assert!(replies[0].contains("200 OK"), "{}", replies[0]); assert!(replies[0].contains("GROUND"), "the page was not the table"); // Through the parser, not a raw attribute match: this assertion // read `id="action-ground"` and CB-WP-0016 moved drop keys to // `data-drop`, so a substring test drifts silently on the next // rename while still describing itself as checking the page. assert!( cb_render_html::doc::drop_keys(&replies[0]).contains("action-ground"), "the offered action was not a drop target on the page" ); assert!(replies[1].contains("200 OK")); assert!(server.refusals().is_empty()); } /// ADR-0007 control 1, end to end rather than in the unit: a page /// without the token gets nothing, and the seat keeps its turn. /// /// M-D1-MUT: make `admit` return `Ok(())` unconditionally and this /// goes red — the token-less request is served the table. Run /// 2026-08-02. #[test] fn a_token_less_request_over_the_socket_is_refused_and_the_turn_continues() { let server = Server::bind(0).expect("bind"); let port = server.listener.local_addr().unwrap().port(); let token = server.url().rsplit("t=").next().unwrap().to_string(); let client = converse( port, vec![ "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nSec-Fetch-Site: same-origin\r\n\r\n" .to_string(), post(&token, "down=action-ground&up=table"), ], ); // The turn survives the refusal and is decided by the good request. let choice = server .next_choice(&state(), PlayerId(0), &ground_only(), false) .expect("a choice"); assert_eq!(choice, Choice::Command(0)); let replies = client.join().expect("client thread"); assert!(replies[0].contains("403 Forbidden"), "{}", replies[0]); assert!( !replies[0].contains("GROUND"), "the table leaked to a token-less request" ); assert!(replies[1].contains("200 OK")); assert_eq!( server.refusals(), vec!["refused: no session token".to_string()] ); } /// A drag that means nothing must not end the turn, and must say so. #[test] fn a_meaningless_drag_keeps_the_turn() { let server = Server::bind(0).expect("bind"); let port = server.listener.local_addr().unwrap().port(); let token = server.url().rsplit("t=").next().unwrap().to_string(); let client = converse( port, vec![ post(&token, "down=seat-1&up=seat-2"), post(&token, "down=action-ground&up=table"), ], ); let choice = server .next_choice(&state(), PlayerId(0), &ground_only(), false) .expect("a choice"); assert_eq!(choice, Choice::Command(0)); let replies = client.join().expect("client thread"); assert!(replies[0].contains("200 OK")); // CB-WP-0020 T03: the refusal is in the game's words now, not the // DOM's. It read `action-attack -> action-attack is not a legal // move here` on the surface a player reads when something goes // wrong — element ids, the same defect the log had. assert!( replies[0].contains("not a move you can make") || replies[0].contains("needs to be dropped on"), "a meaningless drag must be told it meant nothing, in words: {}", replies[0] ); assert!( !replies[0].contains("action-"), "the refusal leaked an element id: {}", replies[0] ); assert!(replies[1].contains("200 OK")); } /// **The loop, closed.** The real server serves the real page; a real /// JavaScript engine runs the page's own script and produces a pointer /// gesture; what it produces goes over a real socket; and the seat's /// choice comes back. /// /// Before ADR-0009 every link in that chain was tested and the chain /// was not. The page was asserted against as a parsed document and the /// socket was driven by synthetic HTTP that this test suite wrote /// itself — so a page whose JavaScript sent something else entirely /// would have passed everything. #[test] fn a_gesture_in_javascript_becomes_a_move_in_the_game() { let server = Server::bind(0).expect("bind"); let port = server.listener.local_addr().unwrap().port(); let token = server.url().rsplit("t=").next().unwrap().to_string(); let tok = token.clone(); let client = std::thread::spawn(move || { let token = tok; // 1. fetch the page the server actually serves let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect"); s.write_all(get(&token).as_bytes()).expect("write"); let mut page = String::new(); let _ = s.read_to_string(&mut page); assert!(page.contains("200 OK"), "{page}"); // 2. run ITS script, in a real engine, with a real gesture let posts = cb_render_html::jsrun::gesture(&page, "action-ground", "table") .expect("the served page's script runs"); assert_eq!(posts.len(), 1, "{posts:?}"); // 3. send exactly what the JavaScript produced — not what this // test thinks it should have produced let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect"); s.write_all(post(&token, &posts[0].body).as_bytes()) .expect("write"); let mut reply = String::new(); let _ = s.read_to_string(&mut reply); (posts[0].clone(), reply) }); let choice = server .next_choice(&state(), PlayerId(0), &ground_only(), false) .expect("a choice"); assert_eq!(choice, Choice::Command(0)); let (posted, reply) = client.join().expect("client thread"); assert_eq!(posted.body, "down=action-ground&up=table"); // The endpoint the JS used carries the server's own token, which // the page cannot mint — so this also proves the token round-trips // through the emitted document. assert!(posted.url.contains(&token), "{}", posted.url); assert!(reply.contains("200 OK"), "{reply}"); assert!(server.refusals().is_empty(), "{:?}", server.refusals()); } /// **Hot-seat**: two seats, one browser, one listener — and each seat /// is shown its own hand and nobody else's. /// /// This is the deliverable that was closest to being claimed on the /// strength of the code path existing. `SeatPolicy` gives every human /// seat a handle on one shared `Server`, so turn-taking "obviously" /// works; nothing drove more than one seat until this test. /// /// The property that matters is not that two turns happen. It is that /// the *projection follows the seat* — the same tab, asked twice, /// must show two different hands. #[test] fn two_seats_take_turns_through_one_listener_and_see_different_hands() { let server = Server::bind(0).expect("bind"); let port = server.listener.local_addr().unwrap().port(); let token = server.url().rsplit("t=").next().unwrap().to_string(); let tok = token.clone(); let client = std::thread::spawn(move || { let mut pages = Vec::new(); for _ in 0..2 { let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect"); s.write_all(get(&tok).as_bytes()).expect("write"); let mut page = String::new(); let _ = s.read_to_string(&mut page); pages.push(page); let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect"); s.write_all(post(&tok, "down=action-ground&up=table").as_bytes()) .expect("write"); let mut reply = String::new(); let _ = s.read_to_string(&mut reply); } pages }); let state = state(); for seat in [0u8, 1] { let choice = server .next_choice(&state, PlayerId(seat), &ground_only(), false) .expect("a choice"); assert_eq!(choice, Choice::Command(0), "seat P{}", seat + 1); } let pages = client.join().expect("client thread"); assert_eq!(pages.len(), 2); for (i, page) in pages.iter().enumerate() { let text = cb_render_html::text_of(page); assert!( text.contains(&format!("viewing as P{}", i + 1)), "turn {i} was not served to P{}", i + 1 ); // K13: exactly one open hand per page, and it is this seat's. assert_eq!( text.matches("cards)").count(), 1, "turn {i} showed {} open hands", text.matches("cards)").count() ); } // And the two turns were genuinely different views, not the same // page served twice — which is how this test would pass vacuously. assert_ne!(pages[0], pages[1], "both turns served an identical page"); } }