clay-borg/tools/cb-play/src/main.rs
tegwick d30938b259
Some checks failed
ci / check (push) Failing after 3s
CB-WP-0047: all four boards, and every mode named on the page
The modes were already implemented; nothing had ever COMPARED them. The
scenarios were not implemented at all: edition::deal has taken a
scenario_id since it was written and the only caller passed the literal
"SCN_01", so 15 of 20 Problem cards had never been dealt by anything.
The seam was the whole mechanism and it sat unused, with nothing red
because nothing asked.

Scenario is now state (serde default SCN_01, so all 26 recordings replay
unchanged), selected by preset `scn-03-4p` with `standard-Np` still
meaning SCN_01, and by --scenario/SCENARIO= accepting ids, numbers or
titles, validated against the edition rather than a pattern.

The threshold now comes off the Scenario card, closing F25's hardcoded
5/7/9. The first version of that control was worthless and mutation said
so: all four scenarios print 5/7/9, so reverting to the bands left it
green. Split threshold_from() so it can be handed a card that disagrees.

The header read `scoring CommonProblem` where the Mode card is titled
COMMON PROBLEM, PERSONAL EDGE -- the defect CB-WP-0034 deleted from the
move buttons, still standing on the line that says what winning means.
The coverage probe was matching that Debug output and went red when it
was fixed: third instance (CB-WP-0024, CB-WP-0034). Page now carries the
premise, the mode's rules text, and the tiebreak.

scenario-panel plays 4x3x3. Findings: SCN_01 and SCN_02 are the same
board (identical cells, pinned by a characterisation test); SCN_04 is
the hard board at 2p (52% vs 67/73%, the only deck needing two Repair);
and group success is EXACTLY equal across all three modes in all 36
cells, because greedy never reads state.mode -- filed F27, the two
competitive modes are scoring lenses over cooperative play.

F28: SHARED GROUND's mastery subtracts penalties from the claimed COUNT
where the mode card's shared score is claimed VALUE. Raised, not fixed;
scoring is ground-game's to rule on.

Also fixes design.py reporting a backticked path as no reproduction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:51:46 +02:00

655 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! cb-play — play GROUND headless, against bots (CB-WP-0008 T02;
//! precursor of `cb play`).
//!
//! INTENT stage 0 asks for a **CLI player**. Everything else on that list
//! shipped in CB-WP-0001..0006; until this binary existed, the engine was
//! correct, measured and replayable, and nothing could play it.
//!
//! Exit codes: 0 the game finished, 1 it did not. There is no partial
//! success — a session that ends without an outcome is a failure, and
//! saying otherwise is how a stalled game passes for a played one.
mod hotseat;
mod inspect;
mod table;
use table::Config;
const USAGE: &str = "\
usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random]
[--replay DIR] [--all-bots] [--record FILE] [--serve PORT]
cb-play --inspect PATH [--as SEAT|spectator]
play:
--seed N game seed (default 1); the same seed replays identically
--players N 2..6 seats (default 3)
--seat N a seat you play, 0-based, repeatable (default 0)
--all-bots no human seats; watch a game play itself
--bot KIND policy for every other seat: greedy (default) or random
--replay DIR write a .cbreplay bundle of the finished game to DIR
--record FILE write the finished game as a scenario YAML
--trial FILE write a trial log: what the player said, bound to where
--variant V rules package: ground-darvo-r0 (default) or
h1-problem-stress (experimental; ground-game H1)
--pace P speed (default) or interactive: how much ornamentation
is performed. Never changes the game -- the recording is
byte-identical either way (specs/Ornamentation.md)
--mode M scoring mode: shared (GR-E02), common (GR-E03),
coalitions (GR-E04). Default shared.
--serve PORT play human seats in a browser on 127.0.0.1:PORT instead
of on the terminal; 0 lets the OS pick. Prints a URL
carrying a per-process token — without it the page is
refused (ADR-0007).
inspect a recorded game — a .cbreplay bundle directory or a scenario YAML:
--inspect PATH render the table after every recorded step
--as WHO whose projection to render: a 0-based seat, or
spectator (default). A seat sees only its own hand.
The two modes take disjoint flags: inspecting reads a recording, it does
not deal one, so a seed or a seat count would be silently ignored.
";
/// The two things this binary does. Separate rather than one `Config`
/// with optional halves: `--inspect --seed 9` is not a request anyone
/// can mean, and a flag that is accepted and ignored is worse than one
/// that is refused.
enum Mode {
Play(Config),
Inspect {
source: std::path::PathBuf,
eyes: inspect::Eyes,
},
}
/// A scenario as the player names it, as the edition names it.
///
/// Accepts `2`, `02`, `scn-02`, `SCN_02` and the title's first word, so
/// that `--scenario confidence` works — the ids are the edition's
/// vocabulary and *"Broken Confidence"* is the player's.
///
/// **Validated against the edition, never against a pattern.** `SCN_09`
/// looks exactly like an id; the error names what the edition has.
fn normalise_scenario(v: &str) -> Result<String, String> {
let known = games_ground::edition::scenarios()?;
let want = v.trim().to_ascii_lowercase();
let digits: String = want.chars().filter(|c| c.is_ascii_digit()).collect();
let by_id = |s: &games_ground::edition::ScenarioText| {
s.id.to_ascii_lowercase() == want
|| (!digits.is_empty()
&& s.id
.rsplit('_')
.next()
.is_some_and(|n| n.parse::<u32>().ok() == digits.parse::<u32>().ok()))
};
if let Some(s) = known.iter().find(|s| by_id(s)) {
return Ok(s.id.clone());
}
// By title, so the four cards can be named by what is printed on them.
if let Some(s) = known
.iter()
.find(|s| s.title.to_ascii_lowercase().contains(&want) && !want.is_empty())
{
return Ok(s.id.clone());
}
Err(format!(
"unknown --scenario {v:?} (the edition has {})",
known
.iter()
.map(|s| format!("{} {:?}", s.id, s.title))
.collect::<Vec<_>>()
.join(", ")
))
}
/// `scn-02-4p`, or `standard-4p` for the scenario every recording plays.
///
/// **`SCN_01` keeps the old preset string.** Twenty-six recorded
/// scenarios name `standard-Np`; emitting `scn-01-Np` for the same board
/// would have made every one of them unreplayable to no purpose.
pub fn scenario_preset(scenario: &str, players: u8) -> String {
if scenario == "SCN_01" {
return format!("standard-{players}p");
}
format!(
"scn-{}-{players}p",
scenario.rsplit('_').next().unwrap_or(scenario)
)
}
fn parse_args(argv: &[String]) -> Result<Mode, String> {
let mut config = Config::default();
let mut source: Option<std::path::PathBuf> = None;
let mut eyes = inspect::Eyes::Spectator;
let mut play_flags: Vec<String> = Vec::new();
let mut inspect_flags: Vec<String> = Vec::new();
let mut seats: Vec<u8> = Vec::new();
let mut all_bots = false;
let mut i = 0;
let value = |i: usize, argv: &[String], flag: &str| -> Result<String, String> {
argv.get(i + 1)
.cloned()
.ok_or_else(|| format!("{flag} needs a value"))
};
while i < argv.len() {
let flag = argv[i].as_str();
match flag {
"--seed" => {
play_flags.push(flag.into());
config.seed = value(i, argv, flag)?
.parse()
.map_err(|e| format!("--seed: {e}"))?;
i += 2;
}
"--players" => {
play_flags.push(flag.into());
config.players = value(i, argv, flag)?
.parse()
.map_err(|e| format!("--players: {e}"))?;
i += 2;
}
"--seat" => {
play_flags.push(flag.into());
seats.push(
value(i, argv, flag)?
.parse()
.map_err(|e| format!("--seat: {e}"))?,
);
i += 2;
}
"--bot" => {
play_flags.push(flag.into());
config.bot = value(i, argv, flag)?;
i += 2;
}
"--record" => {
play_flags.push(flag.into());
config.record = Some(value(i, argv, flag)?.into());
i += 2;
}
// CB-WP-0027: a trial is a recorded session PLUS what the
// player said while playing (GameDesign §5, ADR-0014).
// GR-E02..E04, so the other two shipped modes are reachable.
"--mode" => {
play_flags.push(flag.into());
let v = value(i, argv, flag)?;
config.mode = match v.to_ascii_lowercase().as_str() {
"shared" | "sharedground" | "coop" => games_ground::ScoringMode::SharedGround,
"common" | "commonproblem" | "semi" => games_ground::ScoringMode::CommonProblem,
"coalitions" | "bondedcoalitions" | "coalition" => {
games_ground::ScoringMode::BondedCoalitions
}
other => {
return Err(format!(
"unknown --mode {other:?} (shared, common, coalitions)"
))
}
};
i += 2;
}
// CB-WP-0036 T02. A SEPARATE axis from --mode: that one is
// ScoringMode, a rule of the game; this is how much of what
// the rules cannot see gets performed (Ornamentation §4).
//
// **The flag exists before anything reads it, deliberately.**
// It is the seam `clay-animate` attaches to, and a seam is
// cheap now where a retrofit would not be.
// CB-WP-0038: ground-game names these in
// `editions/catalog.yaml`; `--variant` takes that id.
// CB-WP-0047: WHICH SCENARIO. `deal` has taken a scenario id
// since it was written; the call site passed the literal
// "SCN_01", so three of four decks were unreachable from the
// driver — the same shape as `--mode` before F14.
"--scenario" => {
play_flags.push(flag.into());
let v = value(i, argv, flag)?;
config.scenario = normalise_scenario(&v)?;
i += 2;
}
"--variant" => {
play_flags.push(flag.into());
config.variant = value(i, argv, flag)?.parse()?;
i += 2;
}
"--pace" => {
play_flags.push(flag.into());
config.pace = value(i, argv, flag)?.parse()?;
i += 2;
}
"--trial" => {
play_flags.push(flag.into());
config.trial = Some(value(i, argv, flag)?.into());
i += 2;
}
"--replay" => {
play_flags.push(flag.into());
config.replay_dir = Some(value(i, argv, flag)?.into());
i += 2;
}
"--serve" => {
play_flags.push(flag.into());
config.serve = Some(
value(i, argv, flag)?
.parse()
.map_err(|e| format!("--serve: {e}"))?,
);
i += 2;
}
"--all-bots" => {
all_bots = true;
play_flags.push(flag.into());
i += 1;
}
"--inspect" => {
source = Some(value(i, argv, flag)?.into());
inspect_flags.push(flag.into());
i += 2;
}
"--as" => {
eyes = inspect::Eyes::parse(&value(i, argv, flag)?)?;
inspect_flags.push(flag.into());
i += 2;
}
"-h" | "--help" => return Err(USAGE.into()),
other => return Err(format!("unknown flag {other:?}\n\n{USAGE}")),
}
}
if let Some(source) = source {
if !play_flags.is_empty() {
return Err(format!(
"--inspect reads a recording; it cannot also {}\n\n{USAGE}",
play_flags.join(", ")
));
}
return Ok(Mode::Inspect { source, eyes });
}
if !inspect_flags.is_empty() {
return Err(format!(
"{} only means something with --inspect\n\n{USAGE}",
inspect_flags.join(", ")
));
}
if !(2..=6).contains(&config.players) {
return Err(format!("GR-O01: {} players is outside 26", config.players));
}
config.human_seats = if all_bots {
vec![]
} else if seats.is_empty() {
vec![0]
} else {
seats
};
if let Some(bad) = config.human_seats.iter().find(|s| **s >= config.players) {
return Err(format!(
"--seat {bad} is not one of {} seats",
config.players
));
}
Ok(Mode::Play(config))
}
fn main() {
let argv: Vec<String> = std::env::args().skip(1).collect();
let mode = match parse_args(&argv) {
Ok(c) => c,
Err(message) => {
eprintln!("{message}");
std::process::exit(64);
}
};
let config = match mode {
Mode::Play(c) => c,
Mode::Inspect { source, eyes } => {
let mut out = std::io::stdout().lock();
match inspect::walk(&source, eyes, &mut out) {
Ok(walk) => {
println!(
" {}: {} step(s), {} rejected, hash {}{}",
walk.source,
walk.steps,
walk.rejected,
walk.end_state_hash,
match walk.expected_hash {
Some(_) => " — reproduces the recording",
None => " — the source pins no hash to check",
}
);
return;
}
Err(message) => {
eprintln!("cb-play: {message}");
std::process::exit(1);
}
}
}
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
match table::play(&config, stdin.lock(), stdout.lock()) {
Ok(summary) => {
println!(
" {} round(s), {} commands, hash {}",
summary.rounds,
summary.scenario.commands.len(),
summary.end_state_hash
);
if let Some(path) = summary.bundle {
println!(" bundle {}", path.display());
}
if let Some(path) = summary.recorded {
println!(" recorded {}", path.display());
}
}
Err(message) => {
eprintln!("cb-play: {message}");
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cb_game_runtime::{run, RunOutcome};
use games_ground::GroundState;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
/// A scripted transcript plays a full game to `GameEnded`. "0"
/// always takes the first legal command, which is a real player input
/// and needs no knowledge of the board.
///
/// Run at **both GR-O01 boundaries and the middle** (T03): the CLI is
/// where a seat-count assumption would show up as a prompt nobody can
/// answer.
#[test]
fn a_scripted_transcript_plays_a_full_game() {
for players in [2u8, 3, 6] {
let config = Config {
seed: 42,
players,
human_seats: vec![0],
bot: "greedy".into(),
replay_dir: None,
record: None,
serve: None,
trial: None,
mode: games_ground::ScoringMode::SharedGround,
scenario: "SCN_01".into(),
pace: table::Pace::Speed,
variant: games_ground::Variant::Baseline,
};
let script = "0\n".repeat(400);
let mut out: Vec<u8> = Vec::new();
let summary = table::play(&config, script.as_bytes(), &mut out)
.unwrap_or_else(|e| panic!("{players}p scripted game failed: {e}"));
assert_eq!(summary.rounds, 5, "{players}p: GR-R09 runs five rounds");
let text = String::from_utf8(out).expect("utf8");
assert!(text.contains("OUTCOME"), "{players}p: no outcome reported");
assert!(text.contains("you are P1"), "{players}p: no prompt");
// The transcript replays identically — through the scenario
// runner, not through a second call to the same driver.
match run::<GroundState>(&summary.scenario) {
RunOutcome::Passed { .. } => {}
RunOutcome::Failed { reason, .. } => {
panic!("{players}p replay failed: {reason}")
}
}
}
}
/// **Ornamentation §5, I3** — the invariant the clay-borg /
/// clay-animate split rests on.
///
/// > The same seed and the same decisions produce a **byte-identical
/// > recording** at any pace.
///
/// Asserted rather than intended. If this ever fails, something that
/// was called ornamentation has become mechanism, and the boundary in
/// `specs/Ornamentation.md` has stopped being real — which §7 names
/// as wrong at the root rather than patchable at the edges.
#[test]
fn pace_cannot_change_the_game() {
let at = |pace| {
let config = Config {
seed: 42,
players: 3,
human_seats: vec![0],
bot: "greedy".into(),
replay_dir: None,
record: None,
serve: None,
trial: None,
mode: games_ground::ScoringMode::SharedGround,
scenario: "SCN_01".into(),
pace,
variant: games_ground::Variant::Baseline,
};
let script = "0\n".repeat(400);
let mut out: Vec<u8> = Vec::new();
let s = table::play(&config, script.as_bytes(), &mut out).expect("game");
(
serde_yaml::to_string(&s.scenario).expect("yaml"),
s.end_state_hash,
)
};
let (speed_yaml, speed_hash) = at(table::Pace::Speed);
let (inter_yaml, inter_hash) = at(table::Pace::Interactive);
// The recording, byte for byte.
assert_eq!(
speed_yaml, inter_yaml,
"the recording differs by pace, so a renderer has become mechanism"
);
// And the state hash, which is what §1.1 uses to tell the two
// categories apart in the first place.
assert_eq!(speed_hash, inter_hash, "pace moved the state hash");
}
/// `--pace` parses, defaults to speed, and refuses what it cannot do.
///
/// **Speed is the default** because `sim`, `trials`, the benchmarks
/// and every bot game run at it (Ornamentation §4).
#[test]
fn pace_parses_and_defaults_to_speed() {
assert_eq!(Config::default().pace, table::Pace::Speed);
assert_eq!(
play_args(&["--pace", "interactive"]).expect("parse").pace,
table::Pace::Interactive
);
assert_eq!(
play_args(&["--pace", "speed"]).expect("parse").pace,
table::Pace::Speed
);
// A misspelling must not silently mean the default: pace is the
// seam clay-animate attaches to, and a typo that quietly selects
// Speed would look like the renderer being broken.
let e = match play_args(&["--pace", "cinematic"]) {
Err(e) => e,
Ok(_) => panic!("a misspelt pace was accepted"),
};
assert!(e.contains("cinematic"), "{e}");
assert!(e.contains("speed") && e.contains("interactive"), "{e}");
}
/// K13 at the boundary that matters: what the human is *shown* must
/// not contain another seat's face-down selection.
///
/// The human seat is **P3**, deliberately. Seats are asked in order,
/// so a P1 human is always prompted before anyone has selected and
/// never sees a hidden selection at all — a test seated there passes
/// without exercising the rule.
#[test]
fn the_prompt_never_shows_a_hidden_selection() {
let config = Config {
seed: 42,
players: 3,
human_seats: vec![2],
bot: "greedy".into(),
replay_dir: None,
record: None,
serve: None,
trial: None,
mode: games_ground::ScoringMode::SharedGround,
scenario: "SCN_01".into(),
pace: table::Pace::Speed,
variant: games_ground::Variant::Baseline,
};
let mut out: Vec<u8> = Vec::new();
table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game");
let text = String::from_utf8(out).expect("utf8");
// Every rendered Select-step block must show other seats as
// face-down. A block naming a seat's action before Reveal is a
// leak; `step Select` and `Shown` never co-occur.
let mut checked = 0;
for block in text.split("\nround ") {
if !block.contains("step Select") {
continue;
}
if let Some(line) = block
.lines()
.find(|l| l.trim_start().starts_with("selections:"))
{
let others: Vec<&str> = line
.split(" ")
.filter(|s| s.starts_with("P1") || s.starts_with("P2"))
.collect();
for seat in others {
checked += 1;
assert!(
seat.contains("face-down"),
"a Select-step prompt showed {seat:?}: {line}"
);
}
}
}
// The loop above passes trivially if it never found a selections
// line to check — the harness-does-nothing class, which is why
// this counts what it inspected.
assert!(
checked >= 4,
"only {checked} other-seat entries inspected across the session"
);
}
/// A session whose input runs out must say so, not finish the game
/// on the human's behalf.
#[test]
fn running_out_of_input_fails_loudly() {
let config = Config::default();
let mut out: Vec<u8> = Vec::new();
let err = table::play(&config, "0\n".as_bytes(), &mut out)
.expect_err("an exhausted transcript must not finish a game");
assert!(err.contains("input ended"), "got {err:?}");
}
/// `--all-bots` is the flag that makes a game watchable, and the
/// control that proves the human path is not the only path.
#[test]
fn all_bots_needs_no_input_at_all() {
let config = Config {
seed: 42,
players: 3,
human_seats: vec![],
bot: "random".into(),
replay_dir: None,
record: None,
serve: None,
trial: None,
mode: games_ground::ScoringMode::SharedGround,
scenario: "SCN_01".into(),
pace: table::Pace::Speed,
variant: games_ground::Variant::Baseline,
};
let mut out: Vec<u8> = Vec::new();
let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game");
assert_eq!(summary.rounds, 5);
}
/// `parse_args` in play mode. Panics on an inspect-mode result, so a
/// flag that quietly switched modes could not pass for a play flag.
fn play_args(list: &[&str]) -> Result<Config, String> {
match parse_args(&args(list))? {
Mode::Play(c) => Ok(c),
Mode::Inspect { .. } => panic!("{list:?} parsed as inspect, not play"),
}
}
#[test]
fn flags_parse_and_bad_ones_are_refused() {
let c = play_args(&["--seed", "9", "--players", "4", "--seat", "2"]).unwrap();
assert_eq!((c.seed, c.players, c.human_seats.clone()), (9, 4, vec![2]));
assert_eq!(play_args(&[]).unwrap().human_seats, vec![0]);
assert!(play_args(&["--all-bots"]).unwrap().human_seats.is_empty());
// GR-O01's range, refused at the door rather than at setup.
assert!(parse_args(&args(&["--players", "7"])).is_err());
assert!(parse_args(&args(&["--players", "1"])).is_err());
// A seat nobody occupies would silently never be prompted.
assert!(parse_args(&args(&["--players", "3", "--seat", "3"])).is_err());
assert!(parse_args(&args(&["--seed"])).is_err());
// The two modes take disjoint flags in both directions. A flag
// accepted and ignored is how a user believes they inspected
// seed 9 when they inspected whatever the recording holds.
assert!(parse_args(&args(&["--inspect", "x", "--seed", "9"])).is_err());
assert!(parse_args(&args(&["--as", "2"])).is_err());
assert!(matches!(
parse_args(&args(&["--inspect", "x", "--as", "2"])).unwrap(),
Mode::Inspect { eyes, .. } if eyes == inspect::Eyes::Seat(cb_kernel::PlayerId(2))
));
assert!(matches!(
parse_args(&args(&["--inspect", "x"])).unwrap(),
Mode::Inspect {
eyes: inspect::Eyes::Spectator,
..
}
));
assert!(parse_args(&args(&["--nope"])).is_err());
}
/// A bundle is written only when asked, and it replays.
#[test]
fn a_bundle_is_written_and_replays() {
let dir = std::env::temp_dir().join(format!("cb-play-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("tmp");
let config = Config {
seed: 42,
players: 3,
human_seats: vec![],
bot: "greedy".into(),
replay_dir: Some(dir.clone()),
record: Some(dir.join("session.yaml")),
serve: None,
trial: None,
mode: games_ground::ScoringMode::SharedGround,
scenario: "SCN_01".into(),
pace: table::Pace::Speed,
variant: games_ground::Variant::Baseline,
};
let mut out: Vec<u8> = Vec::new();
let summary = table::play(&config, "".as_bytes(), &mut out).expect("game");
let bundle = summary.bundle.expect("bundle path");
assert!(bundle.join("manifest.yaml").exists());
assert!(bundle.join("commands.log").exists());
let report = cb_game_runtime::replay::replay::<GroundState>(&bundle)
.unwrap_or_else(|e| panic!("bundle does not replay: {e}"));
assert_eq!(report.hash, summary.end_state_hash);
// The recorded scenario is a file the runner can execute, which
// is what makes a played session a regression test.
let text = std::fs::read_to_string(summary.recorded.expect("record path")).expect("read");
let parsed = cb_game_runtime::ScenarioFile::from_yaml(&text).expect("parse");
assert!(matches!(
run::<GroundState>(&parsed),
RunOutcome::Passed { .. }
));
let _ = std::fs::remove_dir_all(&dir);
}
}