clay-borg/tools/cb-play/src/table.rs

372 lines
13 KiB
Rust
Raw Normal View History

//! 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};
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>,
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
/// 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>,
}
impl Default for Config {
fn default() -> Self {
Self {
seed: 1,
players: 3,
human_seats: vec![0],
bot: "greedy".into(),
replay_dir: None,
record: None,
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
serve: None,
}
}
}
/// 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 {
let _ = s.serve_end(None, &msg, END_LINGER);
}
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);
run_game(config, &input, &out)
}
fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
config: &Config,
input: &'a std::cell::RefCell<R>,
out: &'a std::cell::RefCell<W>,
) -> Result<Summary, String> {
let setup = Setup {
players: config.players,
preset: format!("standard-{}p", config.players),
patch: Default::default(),
};
let initial = <GroundState as cb_game_runtime::ScenarioGame>::setup(&setup, config.seed)?;
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();
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
// 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 server = match config.serve {
Some(port) => {
let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?);
let _ = writeln!(out.borrow_mut(), " open {}", s.url());
let _ = out.borrow_mut().flush();
Some(s)
}
None => None,
};
let mut policies: Vec<Box<dyn Policy + 'a>> = Vec::new();
for seat in 0..config.players {
if config.human_seats.contains(&seat) {
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
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]
);
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
// 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.
if let Some(srv) = &server {
let ended = game.state.project(Viewer::Spectator);
let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]);
let _ = srv.serve_end(Some(&ended), &msg, END_LINGER);
}
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,
})
}