clay-borg/tools/cb-play/src/hotseat.rs
tegwick 302fc95c97
Some checks failed
ci / check (push) Failing after 4s
GR-E03 and GR-E04 played to the end — F14 closed, and the reason they were
unplayed was ours

Tier S (a fix and a measurement inside a boundary; chaos d8=4 from
CB-WP-0029's roll, no override).

cb-play built EVERY game with ScoringMode::SharedGround and passed an
empty patch. The mode was settable in scenarios and not from the driver,
so two of the three shipped modes were unreachable from the only way
anyone actually plays. F14 sat open for a week because nobody could reach
the thing it was about.

--mode added. All three now play out and give DIFFERENT WINNERS FROM
IDENTICAL PLAY: shared -> all four seats (mastery 4), common -> P3 alone
(top personal scorer), coalitions -> P1+P2 (best Bond network, 4>3>2).
Same 37 commands, three answers.

AND THEY ANSWER F17'S OPEN QUESTION. I had flagged that ATTACK might earn
its place where Blame costs personal score. It does not, in any mode:

  SHARED GROUND     132/165/190/200 -> identical      free but pointless
  COMMON PROBLEM     59/52/48/44    -> 59/52/48/34    a cost at six seats
  BONDED COALITIONS 131/134/132/116 -> 59/52/48/34    roughly halved

The coalitions row has a mechanism and the data confirms it unprompted.
GR-A07 flips a Bond to a Rivalry on Attack, and GR-E04 scores Bond
NETWORKS -- so attacking destroys the thing that scores. And the attacking
numbers in E04 are IDENTICAL to E03's, which is exactly what that
predicts: break every Bond and each seat is a coalition of one, so GR-E04
degenerates into GR-E03. That check was not designed; it fell out.

F14 -> applied. F17 strengthened and no longer bounded to co-op: ATTACK
has no mode in which it helps, and one where it actively destroys your
score.

Still framed as a question rather than a verdict. DARVO is the pattern the
game is about not falling into, so a self-destructive ATTACK may be the
design. What ground-game has to decide is whether the namesake mechanic
being unreachable in competent play -- in all three modes -- is intended.

make all: exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:38:27 +02:00

1367 lines
56 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>>,
}
/// One thing a player said, and where they said it (ADR-0014 D3).
#[derive(Debug, Clone)]
pub struct TrialNote {
pub n: usize,
pub round: u8,
pub step: String,
pub state_hash: String,
pub text: String,
}
/// Write the trial log: readable Markdown with one machine-readable block.
///
/// **Reusing `FindingRegister.md`'s idiom deliberately** (ADR-0014 D2) —
/// a table between HTML-comment markers, so a human reads the file and a
/// tool reads the block, and neither needs the other's cooperation.
pub fn write_trial_log(path: &std::path::Path, notes: &[TrialNote]) -> Result<(), String> {
let mut s = String::new();
s.push_str("# Trial log\n\n");
s.push_str(
"What the player said while playing, bound to the position they said it at\n\
(CB-WP-0027, ADR-0014). The recording beside this file is the position;\n\
`state_hash` is what reaches it.\n\n\
**These are raw notes and they stay here.** Nothing in this file travels to\n\
`ground-game` (ADR-0014 D4) — a note reaches them only by being promoted to a\n\
register finding, by a human, with the wording chosen then.\n\n",
);
s.push_str("<!-- trial-log:begin -->\n\n");
s.push_str("| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n");
for note in notes {
// Pipes and newlines would break the table, so they are replaced
// rather than escaped: the note is prose, and a reader losing a
// literal `|` matters less than a log nothing can parse.
let text = note.text.replace(['\n', '\r'], " ").replace('|', "/");
let _ = writeln!(
s,
"| {} | {} | {} | {} | {} |",
note.n, note.round, note.step, note.state_hash, text
);
}
s.push_str("\n<!-- 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()),
})
}
/// The URL to open, token and all. Printed once at start; the page
/// cannot mint one for itself.
pub fn url(&self) -> String {
self.guard.page_url()
}
/// Append a note to the trial log, bound to the position it was
/// written at (ADR-0014 D2/D3).
///
/// **The state hash is the binding.** Round and step orient a reader;
/// the hash is what lets one *reach* the position, because it is the
/// same value that makes a session comparable to its replay.
fn record_note(&self, state: &GroundState, note: &Note) -> Result<(), String> {
let Some(path) = self.trial.as_ref() else {
// No trial log configured: refuse rather than drop. A note
// that vanishes is worse than a note that was never offered,
// and this is the failure mode the whole pass exists to avoid.
return Err("no trial log for this session — start with --trial".into());
};
let hash = cb_events::state_hash_hex(state);
let mut log = self.notes.borrow_mut();
let n = log.len() + 1;
log.push(TrialNote {
n,
round: state.round,
step: format!("{:?}", state.step),
state_hash: hash[..12].to_string(),
text: note.text.clone(),
});
write_trial_log(path, &log)
}
/// Set what the meta panel shows about the session (CB-WP-0027 T02).
///
/// Called by the driver between games, so the running tally is visible
/// **while playing** rather than only on the ending page — which is
/// where it was, and a tally you only see once the game is over
/// informs nothing.
pub fn set_meta(&self, lines: Vec<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 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,
&self.log_lines(),
// CB-WP-0027 T02: the meta panel's own content.
// The running tally lives with the driver, so the
// server is handed the lines rather than the state.
&self.meta.borrow(),
);
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
}
("POST", "/command") => {
let fact = match PointerFact::parse(&req.body) {
Ok(f) => f,
Err(e) => {
respond(&mut stream, 400, "text/plain", &e);
continue;
}
};
if may_pass && fact.down == "pass" && fact.up == "pass" {
respond(&mut stream, 200, "text/plain", "ok: passed");
return Ok(Choice::Pass);
}
match resolve(&fact, legal, seat) {
Ok(i) => {
respond(&mut stream, 200, "text/plain", "ok");
return Ok(Choice::Command(i));
}
// Not an error: the player dragged somewhere that
// means nothing. Say so and keep the turn.
Err(why) => respond(&mut stream, 200, "text/plain", &why),
}
}
// CB-WP-0027 T03 / ADR-0014 D1. A SECOND CHANNEL, and it
// cannot carry a move: `Note::parse` returns a `Note`,
// `resolve` takes a `PointerFact`, and there is no
// conversion between them. The game does not advance here
// and the loop keeps waiting for the seat's actual move.
("POST", "/note") => match Note::parse(&req.body) {
Ok(note) => match self.record_note(state, &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.
pub fn serve_end(
&self,
final_view: Option<&games_ground::view::GroundView>,
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 page = cb_render_html::doc::ending(
final_view,
message,
&self.guard.endpoint(),
&self.log_lines(),
&series_lines(tally),
);
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
}
("POST", "/command") => {
// CB-WP-0020 T05: the browser could not start a second
// game without going back to a terminal, which made it
// a strictly worse client than the CLI it replaces.
let again = req.body.contains("again");
respond(
&mut stream,
200,
"text/plain",
// CB-WP-0024 T01: the reply is what the player
// reads. "closed" alone left them looking at a
// live table wondering why nothing happened.
// The `closed` prefix is what the page's script
// matches on to seal itself.
if again {
"ok: dealing"
} else {
"closed \u{2014} the session has ended and the server has stopped. \
You can close this tab."
},
);
break Ok(if again {
EndChoice::Again
} else {
EndChoice::Closed
});
}
_ => respond(&mut stream, 404, "text/plain", "the game is over"),
}
};
self.listener.set_nonblocking(false).ok();
outcome
}
}
/// The running series, phrased for a reader.
///
/// **Two lines, because the rules define one game and not a series**
/// (CB-WP-0024 T04). `personal` sums per seat; `winners` counts games
/// won; they answer different questions and GROUND says which is *the*
/// series score nowhere at all. Both are shown and both are named, so
/// nothing here quietly becomes canon.
pub fn series_lines(t: &crate::table::MatchTally) -> Vec<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),
"30 commands, hash f6c890a65271",
std::time::Duration::from_secs(20),
&crate::table::MatchTally::default(),
)
.expect("serve_end");
let replies = client.join().expect("client");
assert!(replies[0].contains("200 OK"), "{}", replies[0]);
// CB-WP-0028 T06: the heading follows the OUTCOME, so this asks
// the view rather than hardcoding a word. The old assertion said
// "game over" and went red when a won game started saying
// "solved" -- which was the feature working.
let want = match view.outcome.as_ref() {
Some(o) if o.group_success => "game solved",
Some(_) => "game over",
None => "the game stopped",
};
assert!(
replies[0].contains(want),
"the heading did not match the outcome; expected {want:?}"
);
assert!(
replies[0].contains("30 commands"),
"the result did not reach the page"
);
// It must not auto-reload into a refused connection, which is how
// a completed game became a blank tab in the first place.
//
// Asserted on BEHAVIOUR, not on source text. The first draft
// grepped the page for "location.reload" and failed: the ending
// page reuses `SCRIPT`, whose reload is guarded by
// `t.indexOf('ok') === 0`. Grepping for the string would have
// forced a second script to satisfy a test rather than a
// requirement — the exact weak-control shape ADR-0010 D2 demoted.
// What matters is that the endpoint cannot answer "ok".
assert!(
!replies[1].contains("ok"),
"the ending endpoint answered something the page reloads on: {}",
replies[1]
);
assert!(replies[1].contains("closed"), "{}", replies[1]);
}
/// **CB-WP-0020 T05.** "play again" must deal a second game, not just
/// answer politely and exit — the browser could not start a second
/// game without going back to a terminal, which made it a strictly
/// worse client than the CLI it replaces.
#[test]
fn play_again_deals_a_second_game() {
let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let out = SharedOut(buf.clone());
let game = std::thread::spawn(move || {
crate::table::play(
&crate::table::Config {
seed: 3,
players: 3,
human_seats: vec![0],
bot: "random".into(),
replay_dir: None,
record: None,
serve: Some(0),
trial: None,
mode: games_ground::ScoringMode::SharedGround,
},
std::io::Cursor::new(Vec::new()),
out,
)
});
let (port, token) = wait_for_url(&buf);
// Play one game out, ask for another, play that one out too.
let mut deals = 0;
for round in 0..2 {
drive_to_end(port, &token);
deals += 1;
let want = if round == 0 { "again" } else { "done" };
let body = format!("down={want}&up={want}");
let reply = http(
port,
&format!(
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
),
);
if round == 0 {
assert!(reply.contains("dealing"), "play again refused: {reply}");
}
}
assert_eq!(deals, 2, "the second game was never dealt");
game.join().expect("game thread").expect("the games ran");
// Two games, and they must not be the same deal — "again" that
// re-dealt the identical game would satisfy a naive count.
let text = String::from_utf8_lossy(&buf.lock().expect("out")).to_string();
let hashes: Vec<&str> = text
.match_indices("game over \u{2014} ")
.map(|(i, _)| &text[i..i + 60])
.collect();
assert!(hashes.len() >= 2, "only {} game(s) finished", hashes.len());
assert_ne!(hashes[0], hashes[1], "play again re-dealt the same game");
}
/// A game that ended badly must say so rather than draw a table for a
/// game that never happened.
#[test]
fn a_game_that_ended_badly_says_so_and_shows_no_table() {
let page =
cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x", &[], &[]);
assert!(
page.contains("P1 ran out of input"),
"the reason is missing"
);
assert!(page.contains("without a result"));
assert!(
!page.contains("relationships"),
"a failed game drew a table anyway"
);
}
/// A writer the test can read while the game is still running.
#[derive(Clone)]
struct SharedOut(std::sync::Arc<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"
)
}
fn post(token: &str, body: &str) -> String {
format!(
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\
Sec-Fetch-Site: same-origin\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
)
}
fn ground_only() -> Vec<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, "down=action-ground&up=table")],
);
let choice = server
.next_choice(&state(), PlayerId(0), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0));
let replies = client.join().expect("client thread");
assert!(replies[0].contains("200 OK"), "{}", replies[0]);
assert!(replies[0].contains("GROUND"), "the page was not the table");
// Through the parser, not a raw attribute match: this assertion
// read `id="action-ground"` and CB-WP-0016 moved drop keys to
// `data-drop`, so a substring test drifts silently on the next
// rename while still describing itself as checking the page.
assert!(
cb_render_html::doc::drop_keys(&replies[0]).contains("action-ground"),
"the offered action was not a drop target on the page"
);
assert!(replies[1].contains("200 OK"));
assert!(server.refusals().is_empty());
}
/// ADR-0007 control 1, end to end rather than in the unit: a page
/// without the token gets nothing, and the seat keeps its turn.
///
/// M-D1-MUT: make `admit` return `Ok(())` unconditionally and this
/// goes red — the token-less request is served the table. Run
/// 2026-08-02.
#[test]
fn a_token_less_request_over_the_socket_is_refused_and_the_turn_continues() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.url().rsplit("t=").next().unwrap().to_string();
let client = converse(
port,
vec![
"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"
.to_string(),
post(&token, "down=action-ground&up=table"),
],
);
// The turn survives the refusal and is decided by the good request.
let choice = server
.next_choice(&state(), PlayerId(0), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0));
let replies = client.join().expect("client thread");
assert!(replies[0].contains("403 Forbidden"), "{}", replies[0]);
assert!(
!replies[0].contains("GROUND"),
"the table leaked to a token-less request"
);
assert!(replies[1].contains("200 OK"));
assert_eq!(
server.refusals(),
vec!["refused: no session token".to_string()]
);
}
/// A drag that means nothing must not end the turn, and must say so.
#[test]
fn a_meaningless_drag_keeps_the_turn() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.url().rsplit("t=").next().unwrap().to_string();
let client = converse(
port,
vec![
post(&token, "down=seat-1&up=seat-2"),
post(&token, "down=action-ground&up=table"),
],
);
let choice = server
.next_choice(&state(), PlayerId(0), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0));
let replies = client.join().expect("client thread");
assert!(replies[0].contains("200 OK"));
// CB-WP-0020 T03: the refusal is in the game's words now, not the
// DOM's. It read `action-attack -> action-attack is not a legal
// move here` on the surface a player reads when something goes
// wrong — element ids, the same defect the log had.
assert!(
replies[0].contains("not a move you can make")
|| replies[0].contains("needs to be dropped on"),
"a meaningless drag must be told it meant nothing, in words: {}",
replies[0]
);
assert!(
!replies[0].contains("action-"),
"the refusal leaked an element id: {}",
replies[0]
);
assert!(replies[1].contains("200 OK"));
}
/// **The loop, closed.** The real server serves the real page; a real
/// JavaScript engine runs the page's own script and produces a pointer
/// gesture; what it produces goes over a real socket; and the seat's
/// choice comes back.
///
/// Before ADR-0009 every link in that chain was tested and the chain
/// was not. The page was asserted against as a parsed document and the
/// socket was driven by synthetic HTTP that this test suite wrote
/// itself — so a page whose JavaScript sent something else entirely
/// would have passed everything.
#[test]
fn a_gesture_in_javascript_becomes_a_move_in_the_game() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.url().rsplit("t=").next().unwrap().to_string();
let tok = token.clone();
let client = std::thread::spawn(move || {
let token = tok;
// 1. fetch the page the server actually serves
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(get(&token).as_bytes()).expect("write");
let mut page = String::new();
let _ = s.read_to_string(&mut page);
assert!(page.contains("200 OK"), "{page}");
// 2. run ITS script, in a real engine, with a real gesture
let posts = cb_render_html::jsrun::gesture(&page, "action-ground", "table")
.expect("the served page's script runs");
assert_eq!(posts.len(), 1, "{posts:?}");
// 3. send exactly what the JavaScript produced — not what this
// test thinks it should have produced
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(post(&token, &posts[0].body).as_bytes())
.expect("write");
let mut reply = String::new();
let _ = s.read_to_string(&mut reply);
(posts[0].clone(), reply)
});
let choice = server
.next_choice(&state(), PlayerId(0), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0));
let (posted, reply) = client.join().expect("client thread");
assert_eq!(posted.body, "down=action-ground&up=table");
// The endpoint the JS used carries the server's own token, which
// the page cannot mint — so this also proves the token round-trips
// through the emitted document.
assert!(posted.url.contains(&token), "{}", posted.url);
assert!(reply.contains("200 OK"), "{reply}");
assert!(server.refusals().is_empty(), "{:?}", server.refusals());
}
/// **Hot-seat**: two seats, one browser, one listener — and each seat
/// is shown its own hand and nobody else's.
///
/// This is the deliverable that was closest to being claimed on the
/// strength of the code path existing. `SeatPolicy` gives every human
/// seat a handle on one shared `Server`, so turn-taking "obviously"
/// works; nothing drove more than one seat until this test.
///
/// The property that matters is not that two turns happen. It is that
/// the *projection follows the seat* — the same tab, asked twice,
/// must show two different hands.
#[test]
fn two_seats_take_turns_through_one_listener_and_see_different_hands() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.url().rsplit("t=").next().unwrap().to_string();
let tok = token.clone();
let client = std::thread::spawn(move || {
let mut pages = Vec::new();
for _ in 0..2 {
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(get(&tok).as_bytes()).expect("write");
let mut page = String::new();
let _ = s.read_to_string(&mut page);
pages.push(page);
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(post(&tok, "down=action-ground&up=table").as_bytes())
.expect("write");
let mut reply = String::new();
let _ = s.read_to_string(&mut reply);
}
pages
});
let state = state();
for seat in [0u8, 1] {
let choice = server
.next_choice(&state, PlayerId(seat), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0), "seat P{}", seat + 1);
}
let pages = client.join().expect("client thread");
assert_eq!(pages.len(), 2);
for (i, page) in pages.iter().enumerate() {
let text = cb_render_html::text_of(page);
assert!(
text.contains(&format!("viewing as P{}", i + 1)),
"turn {i} was not served to P{}",
i + 1
);
// K13: exactly one open hand per page, and it is this seat's.
assert_eq!(
text.matches("cards)").count(),
1,
"turn {i} showed {} open hands",
text.matches("cards)").count()
);
}
// And the two turns were genuinely different views, not the same
// page served twice — which is how this test would pass vacuously.
assert_ne!(pages[0], pages[1], "both turns served an identical page");
}
}