CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
//! 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};
|
CB-WP-0011-T01: the inspector shows everything, and a gate says so
The renderer moves out of the play loop into inspect.rs and grows from
24 to 42 of the 43 leaf paths a populated GroundView carries. What it
had been dropping was the whole DARVO state machine, the whole GROUND
practice, the scoring mode, Focus tokens, the discard pile, per-seat
protection, and every part of the outcome except the headline.
The load-bearing half is every_view_field_is_classified, which walks
the serialized view for leaf paths and requires each to be listed as
rendered (with a token the output must contain) or omitted (with a
reason). Paths rather than keys: 'problem' occurs under a DARVO target,
a GROUND choice and a Selection, and a key-set walk would let one of
the three vouch for the other two.
Four M-D1-MUT controls, each red for its stated reason. The
unclassified-field control fired for real on the first run --
players.*.hand, a field the gate's own author had missed.
2026-08-02 02:38:38 +02:00
|
|
|
|
|
|
|
|
use crate::inspect::{render, seat_name};
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
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>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for Config {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
seed: 1,
|
|
|
|
|
players: 3,
|
|
|
|
|
human_seats: vec![0],
|
|
|
|
|
bot: "greedy".into(),
|
|
|
|
|
replay_dir: None,
|
|
|
|
|
record: 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
|
CB-WP-0011-T01: the inspector shows everything, and a gate says so
The renderer moves out of the play loop into inspect.rs and grows from
24 to 42 of the 43 leaf paths a populated GroundView carries. What it
had been dropping was the whole DARVO state machine, the whole GROUND
practice, the scoring mode, Focus tokens, the discard pile, per-seat
protection, and every part of the outcome except the headline.
The load-bearing half is every_view_field_is_classified, which walks
the serialized view for leaf paths and requires each to be listed as
rendered (with a token the output must contain) or omitted (with a
reason). Paths rather than keys: 'problem' occurs under a DARVO target,
a GROUND choice and a Selection, and a key-set walk would let one of
the three vouch for the other two.
Four M-D1-MUT controls, each red for its stated reason. The
unclassified-field control fired for real on the first run --
players.*.hand, a field the gate's own author had missed.
2026-08-02 02:38:38 +02:00
|
|
|
//
|
|
|
|
|
// 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.
|
CB-WP-0008-T02: cb-play — INTENT stage 0's CLI player
A human seat is a Policy like any bot, so the CLI adds no second driver:
HumanPolicy renders the projection, lists the legal commands and reads an
index or `pass`. `make play` runs it; `--all-bots` watches one.
K13's Project trait gains its first implementor after six passes with
none. Hidden: other seats' face-down selections until Reveal, hands and
deck (counts only), a face-down Problem's suit and value, and the seed —
not secret content, but a seat holding it can compute the deck.
A played session becomes an artifact: --record writes it as a scenario
the runner executes, --replay writes a .cbreplay bundle. record.rs is the
inverse of parse_command and its warrant is a round-trip test over every
command shape.
The acceptance test for the projection passed vacuously twice. First it
asserted the text contained "face-down", which every render does because
of Problems. Counted, it then reported zero inspected entries: seats are
asked in order, so a human at P1 is prompted before anyone has selected.
Seated at P3 it inspects ten entries and dies when the projection is
mutated to reveal everything. Counting what the harness examined caught
both, which is the second time that remedy has worked where a stronger
predicate would not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:43:53 +02:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
let mut policies: Vec<Box<dyn Policy + 'a>> = Vec::new();
|
|
|
|
|
for seat in 0..config.players {
|
|
|
|
|
if config.human_seats.contains(&seat) {
|
|
|
|
|
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]
|
|
|
|
|
);
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|