clay-borg/tools/cb-play/src/table.rs
tegwick 499d9fe3d7
Some checks failed
ci / check (push) Failing after 4s
CB-WP-0031: the comment box outlives the game
The note channel closed at the moment it is worth most — a player who has
just seen the outcome is the one with something to say, and that reading
was collectable at every moment of the game except the one after it.

Two independent defects: doc::ending never rendered the box, and
serve_end had no POST /note arm, so even a hand-built post fell through to
404. Fixing either alone leaves the channel shut, so the test asserts both
and is mutation-proven to fail on each half separately.

A post-game note binds to the final position but is not an observation
made at the last decision point. RoundStep::End is the last step of a
ROUND, not the end of the game, so record_note now takes the step as an
argument and the post-game path passes "after the end" — otherwise an
after-the-fact reading is filed as an in-play one, which is the
wrong-subject family ADR-0018 was written for.

A note does not end the session: every other POST in that loop breaks it,
and a player must be able to write a second one and then still play again.

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

483 lines
18 KiB
Rust

//! The playable loop: render one seat's projection, offer it the legal
//! commands, read one, apply it (CB-WP-0008 T02).
//!
//! Everything a human sees comes from `GroundState::project` — K13's
//! projection, whose first consumer this is. The process itself holds
//! full state, because it is the referee: legality is decided by
//! `validate`, and hidden information is withheld at the *rendering*
//! boundary, which is where it leaks.
//!
//! **Stated non-goal:** no TUI, no colour, no readline. Stage 1 is the
//! inspectable 2D table; this is the smallest thing that makes the rules
//! playable. Dressing it up now would be inventing a UI before a player
//! has used one.
use cb_game_runtime::{Project, Setup, Viewer};
use cb_kernel::{Actor, PlayerId};
use games_ground::bot::{BotError, Choice, GreedyPolicy, Policy, RandomPolicy};
use games_ground::record::to_step;
use games_ground::{GroundCommand, GroundState};
use crate::inspect::{render, seat_name};
use std::io::{BufRead, Write};
#[derive(Clone)]
pub struct Config {
pub seed: u64,
pub players: u8,
/// Seats a human plays, 0-based (`PlayerId(0)` is P1).
pub human_seats: Vec<u8>,
pub bot: String,
/// Where to write a `.cbreplay` bundle of the finished game.
pub replay_dir: Option<std::path::PathBuf>,
/// Where to write the finished game as a scenario file. A session
/// somebody played becomes a regression test.
pub record: Option<std::path::PathBuf>,
/// Serve human seats in a browser instead of on the terminal
/// (ADR-0007). `Some(0)` lets the OS pick the port.
pub serve: Option<u16>,
/// Where the trial log goes (CB-WP-0027). Without it the note channel
/// refuses rather than dropping what the player wrote.
pub trial: Option<std::path::PathBuf>,
/// GR-E02..E04: which scoring mode to play.
///
/// **The driver could not choose one until now**, which is a large
/// part of why GR-E03 and GR-E04 had never been played to the end
/// (F14). `setup` builds SharedGround and `run_game` passed an empty
/// patch, so two of the three shipped modes were unreachable from the
/// only way anyone actually plays.
pub mode: games_ground::ScoringMode,
}
impl Default for Config {
fn default() -> Self {
Self {
seed: 1,
players: 3,
human_seats: vec![0],
bot: "greedy".into(),
replay_dir: None,
record: None,
serve: None,
trial: None,
mode: games_ground::ScoringMode::SharedGround,
}
}
}
/// What carries across games in one browser session (CB-WP-0024 T04).
///
/// `play` already owned the session — one listener, an advancing seed,
/// `run_game` in a loop — and kept only the last summary, so a second
/// game started with no memory of the first.
///
/// ## Two tallies, because the rules define one game and not a series
///
/// `OutcomeView` offers `personal` (per seat), `group_success` (per
/// table) and `winners`. Summing `personal` and counting `winners` answer
/// **different questions**, and GROUND's dataset says nothing about how
/// several games combine — there is no series in the rules.
///
/// So both are kept and both are labelled, rather than one being picked
/// and quietly presented as *the* score. Registered as a note (kind
/// `underdetermined`) in `specs/FindingRegister.md`: it may not be
/// reported to ground-game until something demonstrates a problem, which
/// is GameDesign §3.1's rule and the reason this is not being sent as a
/// finding.
#[derive(Debug, Clone, Default)]
pub struct MatchTally {
pub games: u32,
/// Sum of each seat's per-game `personal` score.
pub personal: std::collections::BTreeMap<cb_kernel::PlayerId, i32>,
/// Games the table cleared its threshold.
pub group_successes: u32,
/// How often each seat appeared in `winners`.
pub wins: std::collections::BTreeMap<cb_kernel::PlayerId, u32>,
}
impl MatchTally {
/// Test-visible alias for [`Self::record`]. The real call site is the
/// driver; this exists so the series tests drive the same code rather
/// than a reimplementation of it beside it.
#[cfg(test)]
pub fn record_for_test(&mut self, o: &games_ground::view::OutcomeView) {
self.record(o);
}
fn record(&mut self, o: &games_ground::view::OutcomeView) {
self.games += 1;
for (seat, score) in &o.personal {
*self.personal.entry(*seat).or_insert(0) += *score;
}
if o.group_success {
self.group_successes += 1;
}
for w in &o.winners {
*self.wins.entry(*w).or_insert(0) += 1;
}
}
}
/// What a finished game reports back.
/// The end-state hash is what makes a session comparable to its replay.
#[derive(Debug)]
pub struct Summary {
pub rounds: u8,
pub end_state_hash: String,
pub scenario: cb_game_runtime::ScenarioFile,
pub bundle: Option<std::path::PathBuf>,
pub recorded: Option<std::path::PathBuf>,
}
// ------------------------------------------------------------- rendering
//
// The table itself is rendered by `inspect`. What stays here is the
// vocabulary a *chooser* needs: how a legal command is described in the
// menu. Rendering the state and naming a move are different jobs, and
// T02 needs the first without the second.
fn describe(command: &GroundCommand) -> String {
// Reuse the recorder's vocabulary rather than inventing a third one:
// what the player reads is what the scenario file will say.
let step = to_step(Actor::System, command);
let mut out = step.cmd.clone();
for (key, value) in &step.args {
let rendered = match value {
serde_yaml::Value::String(s) => s.clone(),
other => serde_yaml::to_string(other)
.unwrap_or_default()
.trim()
.to_string(),
};
out.push_str(&format!(" {key}={rendered}"));
}
out
}
// --------------------------------------------------------------- policies
/// A seat driven from stdin. Implements the same `Policy` the bots do, so
/// a human and a bot are interchangeable and the driver stays one loop.
pub struct HumanPolicy<'a, R: BufRead, W: Write> {
input: &'a std::cell::RefCell<R>,
out: &'a std::cell::RefCell<W>,
/// `Policy::choose` cannot fail, so an unreadable input is parked
/// here and turned into a loud error by [`play`]. Returning some
/// default move instead would let a broken session play itself.
///
/// A shared slot rather than a trait method: widening `Policy` so one
/// implementor can fail would push a CLI concern into every bot.
failure: Failure,
}
/// Where a human seat parks the reason it could not answer.
pub type Failure = std::rc::Rc<std::cell::RefCell<Option<String>>>;
impl<'a, R: BufRead, W: Write> HumanPolicy<'a, R, W> {
pub fn new(
input: &'a std::cell::RefCell<R>,
out: &'a std::cell::RefCell<W>,
failure: Failure,
) -> Self {
Self {
input,
out,
failure,
}
}
}
impl<R: BufRead, W: Write> Policy for HumanPolicy<'_, R, W> {
fn name(&self) -> &'static str {
"human"
}
fn choose(
&mut self,
state: &GroundState,
seat: PlayerId,
legal: &[GroundCommand],
may_pass: bool,
) -> Choice {
let mut w = self.out.borrow_mut();
let _ = write!(w, "{}", render(&state.project(Viewer::Player(seat))));
let _ = writeln!(w, " you are {}", seat_name(seat));
for (i, cmd) in legal.iter().enumerate() {
let _ = writeln!(w, " [{i}] {}", describe(cmd));
}
let _ = writeln!(
w,
" choose a number{}:",
if may_pass { " or `pass`" } else { "" }
);
let _ = w.flush();
drop(w);
let mut line = String::new();
loop {
line.clear();
match self.input.borrow_mut().read_line(&mut line) {
Ok(0) => {
*self.failure.borrow_mut() =
Some(format!("input ended while {} had to act", seat_name(seat)));
// Out of range on purpose: the driver reports it.
return Choice::Command(usize::MAX);
}
Err(e) => {
*self.failure.borrow_mut() = Some(format!("read error: {e}"));
return Choice::Command(usize::MAX);
}
Ok(_) => {}
}
let word = line.trim();
if word.is_empty() {
continue;
}
if word.eq_ignore_ascii_case("pass") {
return Choice::Pass;
}
match word.parse::<usize>() {
Ok(i) => return Choice::Command(i),
Err(_) => {
let mut w = self.out.borrow_mut();
let _ = writeln!(w, " not a number: {word:?}");
let _ = w.flush();
}
}
}
}
}
/// How long the terminal page stays available for an abandoned tab.
///
/// A server that never exits is its own defect; a short timeout races a
/// player reading the result. So it serves until the page posts `done`,
/// with this only as the bound.
const END_LINGER: std::time::Duration = std::time::Duration::from_secs(600);
/// Show the browser that the game ended badly, then return the error.
///
/// CB-WP-0018 T01: this path used to `return Err(...)` straight to a
/// terminal nobody was reading, and the browser got a refused connection —
/// identical to a normal win. An error the only interface cannot see is
/// not reported.
fn end_badly<T>(
server: &Option<std::rc::Rc<crate::hotseat::Server>>,
msg: String,
) -> Result<T, String> {
if let Some(s) = server {
// A game that ended badly has no outcome, so it contributes
// nothing to the series and the panel stays absent. Passing an
// empty tally rather than the live one is deliberate: a crashed
// game must not be counted as a played one.
let _ = s.serve_end(None, None, &msg, END_LINGER, &MatchTally::default());
}
Err(msg)
}
fn bot_policy<'a>(kind: &str, seed: u64) -> Result<Box<dyn Policy + 'a>, String> {
match kind {
"greedy" => Ok(Box::new(GreedyPolicy)),
"random" => Ok(Box::new(RandomPolicy::new(seed))),
other => Err(format!("unknown bot policy {other:?} (greedy, random)")),
}
}
// ----------------------------------------------------------------- driver
/// Play one game. The human seats read from `input`; the rest are bots.
pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<Summary, String> {
// The shared reader and writer must outlive the policy objects that
// borrow them, so ownership stays here and the game runs one frame in.
let input = std::cell::RefCell::new(input);
let out = std::cell::RefCell::new(out);
// CB-WP-0020 T05: a browser session can deal a second game. The seed
// advances, because replaying the identical deal is not "again".
// CB-WP-0020 T05: ONE listener for the session, not one per game.
// The first version bound a fresh port inside `run_game`, so "play
// again" moved the game to a new URL and the player's tab was left
// pointing at a dead port. Caught by `play_again_deals_a_second_game`.
let server = match config.serve {
Some(port) => {
let s = std::rc::Rc::new(
crate::hotseat::Server::bind(port)?.with_trial(config.trial.clone()),
);
let _ = writeln!(out.borrow_mut(), " open {}", s.url());
let _ = out.borrow_mut().flush();
Some(s)
}
None => None,
};
let mut cfg: Config = config.clone();
// CB-WP-0024 T04: the tally outlives the game, which is the whole
// point — it is declared here, beside the listener and the seed,
// because those are the other two things that survive `play again`.
let mut tally = MatchTally::default();
loop {
// CB-WP-0027 T02: the panel shows the series *during* the next
// game, not only after it.
if let Some(s) = &server {
s.set_meta(crate::hotseat::series_lines(&tally));
}
let (summary, choice) = run_game(&cfg, &input, &out, server.clone(), &mut tally)?;
if choice != crate::hotseat::EndChoice::Again {
return Ok(summary);
}
cfg.seed = cfg.seed.wrapping_add(1);
}
}
fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
config: &Config,
input: &'a std::cell::RefCell<R>,
out: &'a std::cell::RefCell<W>,
server: Option<std::rc::Rc<crate::hotseat::Server>>,
tally: &mut MatchTally,
) -> Result<(Summary, crate::hotseat::EndChoice), String> {
let setup = Setup {
players: config.players,
preset: format!("standard-{}p", config.players),
patch: Default::default(),
};
let mut initial = <GroundState as cb_game_runtime::ScenarioGame>::setup(&setup, config.seed)?;
// GR-E02..E04. Set before the hash is taken, so a recorded session
// replays in the mode it was played in.
initial.mode = config.mode;
let initial_json = serde_json::to_value(&initial).map_err(|e| e.to_string())?;
let initial_hash = cb_events::state_hash_hex(&initial);
// Human seats and bot seats fill one policy vector; the driver does
// not know which is which, which is the point.
let failure: Failure = Default::default();
// ADR-0007: a browser seat and a CLI seat are both just a Policy, so
// the driver cannot tell them apart — which is the property that lets
// a browser game replay as a scenario like any other.
let mut policies: Vec<Box<dyn Policy + 'a>> = Vec::new();
for seat in 0..config.players {
if config.human_seats.contains(&seat) {
match &server {
Some(s) => policies.push(Box::new(crate::hotseat::SeatPolicy::new(
s.clone(),
failure.clone(),
))),
None => policies.push(Box::new(HumanPolicy::new(input, out, failure.clone()))),
}
} else {
policies.push(bot_policy(&config.bot, config.seed + u64::from(seat))?);
}
}
// CB-WP-0018 T02: the browser seat's page renders the journal as it
// fills, so a player sees what each command produced -- including the
// commands that produced nothing.
let result = match &server {
Some(srv) => games_ground::bot::play_journaled(initial, &mut policies, Some(srv.journal())),
None => games_ground::bot::play(initial, &mut policies),
};
// A human seat that ran out of input reports *that*, not the
// out-of-range index it had to return to get here.
let human_failure = failure.borrow().clone();
let game = match (result, human_failure) {
(_, Some(msg)) => return end_badly(&server, msg),
(Err(BotError::IllegalChoice { seat, offered, .. }), None) => {
return end_badly(
&server,
format!(
"{} chose a command outside the {offered} offered",
seat_name(seat)
),
)
}
(Err(e), None) => return end_badly(&server, e.to_string()),
(Ok(game), None) => game,
};
let end_hash = cb_events::state_hash_hex(&game.state);
let mut w = out.borrow_mut();
let _ = write!(w, "{}", render(&game.state.project(Viewer::Spectator)));
let _ = writeln!(
w,
" game over — {} commands, hash {}",
game.commands,
&end_hash[..12]
);
// ADR-0007 control 1 leaves evidence rather than only a 403: a
// session that was probed says so, so a player finds out from the
// transcript rather than from nothing at all.
if let Some(s) = &server {
let refusals = s.refusals();
if refusals.is_empty() {
let _ = writeln!(w, " no requests were refused this session");
} else {
let _ = writeln!(w, " {} request(s) refused:", refusals.len());
for r in &refusals {
let _ = writeln!(w, " {r}");
}
}
}
let _ = w.flush();
drop(w);
// CB-WP-0018 T01: the browser sees the end of its own game. Measured
// before this: a game ended at 5 rounds / 30 commands, the result went
// to stdout, and the page's post-`ok` reload got Connection refused.
let mut end_choice = crate::hotseat::EndChoice::Closed;
if let Some(srv) = &server {
let ended = game.state.project(Viewer::Spectator);
// Recorded BEFORE the page is served, so the ending page shows a
// tally that includes the game being looked at. A tally that
// lagged by one game would be worse than none.
if let Some(o) = &ended.outcome {
tally.record(o);
}
let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]);
end_choice = srv.serve_end(Some(&ended), Some(&game.state), &msg, END_LINGER, tally)?;
}
let scenario = games_ground::record::to_scenario(
"ground/cb-play-session",
config.seed,
config.players,
&game.steps,
Some(end_hash.clone()),
);
let bundle = match &config.replay_dir {
None => None,
Some(dir) => {
let end_json = serde_json::to_value(&game.state).map_err(|e| e.to_string())?;
Some(cb_game_runtime::replay::write_bundle(
dir,
&scenario,
&initial_json,
&initial_hash,
&end_json,
&end_hash,
"recorded by cb-play",
)?)
}
};
let recorded = match &config.record {
None => None,
Some(path) => {
let yaml = serde_yaml::to_string(&scenario).map_err(|e| e.to_string())?;
std::fs::write(path, yaml).map_err(|e| format!("write {}: {e}", path.display()))?;
Some(path.clone())
}
};
Ok((
Summary {
rounds: game.rounds,
end_state_hash: end_hash,
scenario,
bundle,
recorded,
},
end_choice,
))
}