Some checks failed
ci / check (push) Failing after 3s
Six of seven perceptual defects fixed; item 1 already passed. T01, at the maintainer's instruction: a legal target restyles its EXISTING border rather than drawing a new box. outline + outline-offset drew a second rectangle, which an SVG viewport clips (the missing top and left edges) and which made a seat's highlight card-sized. A border already in the layout cannot move the layout. T02: the ghost was a textContent copy of the card, which is why the line break collapsed and it read as a second card, and why showing the explanation destroyed the label. It is now a pill, the explanation is appended beside the label, and the left-behind element is dimmed and dashed. The stub grew innerHTML so a test can assert BOTH are present -- it could previously only see that something was displayed. T03: NOT reproduced and recorded as not reproduced. The likeliest cause is which element the browser reports -- for touch and pen the pointer is captured to the pointerdown target, making every drop look like a drop-on-itself, which is the other half of the report. elementFromPoint is correct under both explanations. Separately the refusal was written in element ids on the one surface a player reads when something goes wrong; it now speaks the game's words and a test forbids id leakage. T04: seat selections rendered as Debug. The coverage gate then failed my first fix for dropping a field when target and problem were both set -- the aggregate does not produce that shape and the gate was right not to care. T05: the headline reads from group_success. 'Play again' is real, and its first version was useless: run_game bound a fresh listener per game, so a second game moved to a new port and left the tab pointing at a dead one. One listener per session now, and the test asserts the second game is a DIFFERENT deal. Chaos d8=8 fired the first override at the new rate and drew S, changing nothing -- one half of window 2's retirement condition. CB-WP-0019 settled at $38.54/117 against $34.80/107. Eight for eight, and the first under 20%. make all exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
392 lines
14 KiB
Rust
392 lines
14 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>,
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
|
|
// 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)?);
|
|
let _ = writeln!(out.borrow_mut(), " open {}", s.url());
|
|
let _ = out.borrow_mut().flush();
|
|
Some(s)
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
let mut cfg: Config = config.clone();
|
|
loop {
|
|
let (summary, choice) = run_game(&cfg, &input, &out, server.clone())?;
|
|
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>>,
|
|
) -> Result<(Summary, crate::hotseat::EndChoice), 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 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);
|
|
let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]);
|
|
end_choice = 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,
|
|
},
|
|
end_choice,
|
|
))
|
|
}
|