//! 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>, /// Which game of the session is being played, 1-based (ADR-0019 D1). /// The journal and the recording are per game; the notes and the /// tally are per session, and conflating the two is what made a /// second game overwrite the first one's evidence. game: std::cell::Cell, } /// One thing a player said, and where they said it (ADR-0014 D3). #[derive(Debug, Clone)] pub struct TrialNote { pub n: usize, /// Which game of the session, 1-based (ADR-0019 D3). pub game: usize, /// Commands played when this was written (CB-WP-0032), so the page /// can put the comment where it was made — and, since ADR-0019 D3, /// so a reader can reach the position: it is an index into the /// recording's own `commands:` list. /// /// **CB-WP-0032 kept this out of the file** because `trials.py` /// silently skipped any row that was not five cells, so a new column /// would have emptied every log at once. That parser now raises and /// accepts both widths, which is what made this column safe to add. pub after: usize, pub round: u8, pub step: String, pub state_hash: String, pub text: String, } /// Where game `n`'s recording goes (ADR-0019 D2). /// /// **Game 1 keeps the path it was given.** GameDesign §5 documents that /// exact invocation as the trial protocol, and a scheme that renamed /// every file would have made the spec wrong for the common case on the /// day it landed. Games 2, 3 … get `-2`, `-3` before the extension. /// /// Nothing is overwritten: a recording is evidence, and `play again` /// deleting the previous game's evidence is a data-loss defect on its own. pub fn game_path(base: &std::path::Path, n: usize) -> std::path::PathBuf { if n <= 1 { return base.to_path_buf(); } let stem = base .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); let mut name = format!("{stem}-{n}"); if let Some(ext) = base.extension() { name.push('.'); name.push_str(&ext.to_string_lossy()); } base.with_file_name(name) } /// 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"); // ADR-0019 D3/D4: `game` and `after` are the binding a reader can act // on -- replay `after` commands of game `game` and you are standing // where the player stood. `state_hash` stays as an integrity check at // the end of a game, where it is the one thing the recording carries. s.push_str( "| n | game | after | 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.game, note.after, 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()), game: std::cell::Cell::new(1), }) } /// 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. /// `step` is passed in rather than read off the state (CB-WP-0031), /// because a note written after the game has ended is not a note taken /// at the final decision point and must not be logged as one. The /// position it binds to is genuinely the final state — `round` and /// `state_hash` are that state's — but *when the player said it* is a /// different fact, and conflating them would file an after-the-fact /// reading as an in-play observation. /// /// `"after the end"` cannot collide with a real step: `RoundStep`'s /// `Debug` values are bare identifiers (`Select`, `Reveal`, `Resolve`, /// `End`), and **`End` is the last step of a round, not the end of the /// game** — which is exactly the confusion this label avoids. fn record_note(&self, state: &GroundState, step: &str, 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 after = self.journal.borrow().len(); let game = self.game.get(); let mut log = self.notes.borrow_mut(); let n = log.len() + 1; log.push(TrialNote { n, game, after, round: state.round, step: step.to_string(), state_hash: hash[..12].to_string(), text: note.text.clone(), }); write_trial_log(path, &log) } /// Begin the next game (ADR-0019 D1). /// /// **Clearing the journal is the point.** It is shared with the /// driver and nothing ever emptied it, so a second game's log opened /// with the first game's commands still in it — and a note's `after`, /// counted against that journal, indexed into a recording that did /// not contain those commands. /// /// The notes are NOT cleared: the trial log is the session's record /// (ADR-0019 D4). Only the log *view* is per game. pub fn next_game(&self) { self.journal.borrow_mut().clear(); self.game.set(self.game.get() + 1); } /// Which game is being played, 1-based. pub fn game(&self) -> usize { self.game.get() } /// 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 player's own comments, positioned for the log (CB-WP-0032). fn log_notes(&self) -> Vec { let game = self.game.get(); self.notes .borrow() .iter() // THIS game's comments. A note from game 1 placed by its // command index into game 2's log would sit against a move // nobody made (ADR-0019 D1). .filter(|n| n.game == game) .map(|n| cb_render_html::doc::LogNote { after: n.after, round: n.round, step: n.step.clone(), text: n.text.clone(), }) .collect() } /// 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, cb_render_html::doc::Account { lines: &self.log_lines(), notes: &self.log_notes(), }, // 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, &format!("{:?}", state.step), ¬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. /// /// **`final_state` is what makes the comment box work here** /// (CB-WP-0031). This loop had no `/note` arm at all, so a note posted /// from the ending page fell through to `404 the game is over` — and /// the ending page did not render the box in the first place, so the /// channel closed at the moment a player has just learned how it went. /// `None` means the game stopped without a position, and then the box /// is not offered rather than offered and refused. pub fn serve_end( &self, final_view: Option<&games_ground::view::GroundView>, final_state: Option<&GroundState>, 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 note_to = final_state.map(|_| self.guard.note_endpoint()); let page = cb_render_html::doc::ending( final_view, message, &self.guard.endpoint(), note_to.as_deref(), cb_render_html::doc::Account { lines: &self.log_lines(), notes: &self.log_notes(), }, &series_lines(tally), ); respond(&mut stream, 200, "text/html; charset=utf-8", &page); } // CB-WP-0031. The same second channel as in `next_choice`, // and it still cannot carry a move — here it could not // anyway, because the game is finished. // // **It does not `break`.** Every other POST here ends the // loop; a note must leave the session exactly as it found // it, so the player can write a second one, or read the // log again, or then press `play again`. ("POST", "/note") => { let Some(state) = final_state else { respond( &mut stream, 409, "text/plain", "the game stopped without a final position, so a note \ has nothing to bind to", ); continue; }; match Note::parse(&req.body) { Ok(note) => match self.record_note(state, "after the end", ¬e) { Ok(()) => 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), } } ("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), Some(&state()), "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-0031. The comment box must survive the end of the game. /// /// **Two things go wrong independently and this asserts both.** The /// ending page did not render the form, and `serve_end` had no /// `/note` arm — so even a hand-built POST fell through to /// `404 the game is over`. Fixing either alone leaves the channel /// shut, so a test that only checked the markup would have passed /// against a server that still refused. /// /// **And the note must not end the session.** Every other POST in /// this loop breaks it; a player writing what they thought must be /// able to write a second one and then still press `play again`, /// which is what the trailing command here proves. #[test] fn a_note_can_be_written_after_the_game_has_ended() { let dir = std::env::temp_dir().join(format!("cb-note-end-{}", std::process::id())); let log = dir.join("trial.md"); let server = Server::bind(0).expect("bind").with_trial(Some(log.clone())); 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"), // The body is its own binding and the length is computed // from it. Writing `Content-Length: 21` by hand beside a // 22-byte body is a test that fails for a reason having // nothing to do with the feature. post(&token, "/note", "note=so+that+is+why+it"), // CB-WP-0032: and it comes BACK, in the log. format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"), post(&token, "/command", "down=done&up=done"), ], ); let view = state().project(Viewer::Spectator); server .serve_end( Some(&view), Some(&state()), "30 commands", std::time::Duration::from_secs(20), &crate::table::MatchTally::default(), ) .expect("serve_end"); let replies = client.join().expect("client"); assert!( replies[0].contains("id=\"cb-note\""), "the ending page offered no comment box" ); // 303 back to the TOKEN-CARRYING path. Redirecting to bare `/` // is the defect that made a SAVED note read as a failed one. assert!( replies[1].contains("303"), "the note was refused: {}", replies[1] ); assert!( replies[1].contains(&format!("t={token}")), "the redirect dropped the token, which reads as a refusal: {}", replies[1] ); // CB-WP-0032. Written, then read back on the very next page — // the round trip, not just the write. assert!( replies[2].contains("so that is why it"), "the note was saved but never shown back: {}", replies[2] ); assert!( replies[2].contains("class=\"note\""), "the note was shown without marking it as the player's" ); // The session survived the note. assert!( replies[3].contains("closed"), "the note ended the session, so nothing could follow it: {}", replies[2] ); let written = std::fs::read_to_string(&log).expect("trial log"); assert!(written.contains("so that is why it"), "{written}"); // The WHEN is not the final step. `RoundStep::End` is the last // step of a round; filing an after-the-fact reading under it // would claim the player said this at a decision point. assert!( written.contains("after the end"), "a post-game note was logged as an in-play one: {written}" ); let _ = std::fs::remove_dir_all(&dir); } /// The other half of the same decision: no position, no box. /// /// A crashed game has nothing to bind a note to, and offering a box /// that would answer 409 is worse than not offering one. #[test] fn a_game_that_stopped_offers_no_comment_box() { let mut s = String::new(); let page = cb_render_html::doc::ending( None, "it broke", "/c", None, cb_render_html::doc::Account::of(&[]), &[], ); s.push_str(&page); assert!( !s.contains("id=\"cb-note\""), "offered a box with nothing to bind to" ); assert!( s.contains("nothing to bind a note to"), "did not say why: {s}" ); } /// ADR-0019 D2. Game 1 keeps the path it was given. #[test] fn only_later_games_get_a_suffixed_recording() { let base = std::path::Path::new("trials/2026-08-07-x.yaml"); // GameDesign §5 documents this exact invocation; renaming it // would have made the spec wrong for the common case. assert_eq!(game_path(base, 1), base); assert_eq!( game_path(base, 2), std::path::Path::new("trials/2026-08-07-x-2.yaml") ); assert_eq!( game_path(base, 3), std::path::Path::new("trials/2026-08-07-x-3.yaml") ); // Distinct paths are the whole point: nothing may be overwritten. assert_ne!(game_path(base, 2), game_path(base, 3)); // And the suffix must not COMPOUND. The driver derives every // game's path from the ORIGINAL, because deriving it from the // previous game's would put game 3 in `x-2-3.yaml`. assert_eq!( game_path(&game_path(base, 2), 3), std::path::Path::new("trials/2026-08-07-x-2-3.yaml"), "this is the shape the driver must avoid, and does by keeping the base" ); } /// ADR-0019 D1. The journal belongs to a GAME. /// /// **Nothing ever cleared it.** It is shared with the driver and /// lives on the `Server`, which outlives every game, so a second /// game's log opened with the first game's commands still in it — and /// a note's `after`, counted against that journal, indexed into a /// recording that never contained those commands. #[test] fn a_second_game_does_not_inherit_the_first_ones_log() { let server = Server::bind(0).expect("bind"); let j = server.journal(); j.borrow_mut().push(games_ground::bot::Applied { actor: cb_kernel::Actor::System, command: GroundCommand::EndRound, events: vec![], }); assert_eq!(server.game(), 1); assert_eq!(server.journal().borrow().len(), 1); server.next_game(); assert_eq!(server.game(), 2); assert!( server.journal().borrow().is_empty(), "game 2 opened holding game 1's commands" ); } /// A comment belongs to the game it was made in (ADR-0019 D1). /// /// Placed by command index into a LATER game's log, a note from game 1 /// would sit against a move nobody made — a position stated /// confidently and wrong, which is the family ADR-0018 names. #[test] fn a_comment_from_an_earlier_game_is_not_shown_in_a_later_one() { let dir = std::env::temp_dir().join(format!("cb-g-{}", std::process::id())); let server = Server::bind(0) .expect("bind") .with_trial(Some(dir.join("t.md"))); let st = state(); server .record_note( &st, "Select", &Note { text: "in game one".into(), }, ) .expect("note 1"); assert_eq!(server.log_notes().len(), 1, "its own game shows it"); server.next_game(); assert!( server.log_notes().is_empty(), "game 1's comment leaked into game 2's log" ); server .record_note( &st, "Select", &Note { text: "in game two".into(), }, ) .expect("note 2"); let shown = server.log_notes(); assert_eq!(shown.len(), 1); assert_eq!(shown[0].text, "in game two"); // But the trial log keeps BOTH: it is the session's record, and // the game column is what tells them apart (ADR-0019 D4). let written = std::fs::read_to_string(dir.join("t.md")).expect("trial log"); assert!(written.contains("in game one"), "the session lost a note"); assert!(written.contains("in game two")); assert!( written.contains("| 1 | 1 |"), "game column missing: {written}" ); assert!( written.contains("| 2 | 2 |"), "second note not in game 2: {written}" ); let _ = std::fs::remove_dir_all(&dir); } /// **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()); // ADR-0019 D2: a real recording, so this covers the CALL SITE. // The unit test proves `game_path` names files apart; only this // proves the driver uses it, which is where the data loss was. let dir = std::env::temp_dir().join(format!("cb-again-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("tmp"); let rec = dir.join("s.yaml"); let rec_for_thread = rec.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: Some(rec_for_thread), 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"); // ADR-0019 D2. BOTH recordings survive. `play again` used to // write game 2 over game 1's file, destroying the evidence a // trial exists to produce -- and every note bound to it. let second = dir.join("s-2.yaml"); assert!(rec.exists(), "game 1's recording is gone"); assert!(second.exists(), "game 2 was never recorded separately"); assert_ne!( std::fs::read_to_string(&rec).expect("g1"), std::fs::read_to_string(&second).expect("g2"), "the two recordings hold the same game" ); let _ = std::fs::remove_dir_all(&dir); } /// 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", None, cb_render_html::doc::Account::of(&[]), &[], ); 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" ) } /// One form POST. **`Content-Length` is derived from the body**, which /// is the whole reason to have this rather than hand-written request /// literals: the first draft of the post-game note test wrote /// `Content-Length: 21` beside a 22-byte body, the server read 21 of /// them, and the note came back `400 unrecognised field` — a failure /// that looked exactly like the feature being broken. /// /// `path` because the note channel is a second endpoint (CB-WP-0031). fn post(token: &str, path: &str, body: &str) -> String { format!( "POST {path}?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, "/command", "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, "/command", "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, "/command", "down=seat-1&up=seat-2"), post(&token, "/command", "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, "/command", &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, "/command", "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"); } }