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>
330 lines
11 KiB
Rust
330 lines
11 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};
|
|
|
|
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>,
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
// 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) {
|
|
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))?);
|
|
}
|
|
}
|
|
|
|
let result = 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 Err(msg),
|
|
(Err(BotError::IllegalChoice { seat, offered, .. }), None) => {
|
|
return Err(format!(
|
|
"{} chose a command outside the {offered} offered",
|
|
seat_name(seat)
|
|
))
|
|
}
|
|
(Err(e), None) => return Err(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);
|
|
|
|
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,
|
|
})
|
|
}
|