clay-borg/tools/cb-play/src/hotseat.rs
tegwick edab11c0e4
Some checks failed
ci / check (push) Failing after 3s
CB-WP-0033: a game is the unit
make trials reported "positions unreachable: 4, target 0 — the recording
exists but the position moved". All four were false. A recording holds one
hash, the final state, and reachability asked whether the note's hash was
in that file — so a mid-game note could never match, and a post-game note
from any but the last game could not either. Instance 8 of the ADR-0018
family: vary only WHEN a note was written and the answer flips, with
nothing having moved.

The root cause was not the metric. play again reused state belonging to a
game: it overwrote the previous game's recording (data loss), never
cleared the journal (game 2's log opened with game 1's commands), and so a
note's command index pointed into a recording without those commands.
Fixing reachability alone would have gone green while a session still
destroyed its own evidence.

A note now binds by (game, after) — an index into the recording's own
commands list, which a reader can replay to. The hash keeps a job as the
integrity check at the end of a game, where it can actually fail. Game 1
keeps the path it was given, so GameDesign §5's documented invocation is
unchanged; later games get -2, -3 and nothing is overwritten. Legacy
5-column logs stay readable and are reported as legacy, never as orphans —
an unsubstantiated orphan claim is the defect being fixed.

All three fixes mutation-proven, including at the call site via a real
two-game session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:41:11 +02:00

1788 lines
74 KiB
Rust

//! 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<Vec<String>>,
/// 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<Vec<String>>,
/// Where the trial log is written, if this session is a trial.
trial: Option<std::path::PathBuf>,
/// 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<Vec<TrialNote>>,
/// 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<usize>,
}
/// 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("<!-- trial-log:begin -->\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<!-- trial-log:end -->\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<std::path::PathBuf>) -> Self {
self.trial = path;
self
}
pub fn bind(port: u16) -> Result<Self, String> {
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<String>) {
*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<cb_render_html::doc::LogNote> {
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<cb_render_html::doc::LogLine> {
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<String> {
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<Choice, String> {
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), &note) {
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<EndChoice, String> {
let deadline = std::time::Instant::now() + linger;
self.listener
.set_nonblocking(true)
.map_err(|e| format!("nonblocking: {e}"))?;
let outcome = loop {
if std::time::Instant::now() >= deadline {
break Ok(EndChoice::Closed);
}
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", &note) {
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<String> {
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<PlayerId, String>| {
m.iter()
.map(|(p, v)| format!("P{} {v}", p.0 + 1))
.collect::<Vec<_>>()
.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<PlayerId>) -> 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<Server>,
failure: Rc<RefCell<Option<String>>>,
}
impl SeatPolicy {
pub fn new(server: Rc<Server>, failure: Rc<RefCell<Option<String>>>) -> 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<String, String> {
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<usize> {
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<std::sync::Mutex<Vec<u8>>>);
impl Write for SharedOut {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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<std::sync::Mutex<Vec<u8>>>) -> (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("<h2>log</h2>"), "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<String>) -> std::thread::JoinHandle<Vec<String>> {
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<GroundCommand> {
vec![GroundCommand::SelectAction {
action: Action::Ground,
target: None,
problem: None,
}]
}
fn state() -> GroundState {
<GroundState as cb_game_runtime::ScenarioGame>::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");
}
}