Some checks failed
ci / check (push) Failing after 4s
unplayed was ours Tier S (a fix and a measurement inside a boundary; chaos d8=4 from CB-WP-0029's roll, no override). cb-play built EVERY game with ScoringMode::SharedGround and passed an empty patch. The mode was settable in scenarios and not from the driver, so two of the three shipped modes were unreachable from the only way anyone actually plays. F14 sat open for a week because nobody could reach the thing it was about. --mode added. All three now play out and give DIFFERENT WINNERS FROM IDENTICAL PLAY: shared -> all four seats (mastery 4), common -> P3 alone (top personal scorer), coalitions -> P1+P2 (best Bond network, 4>3>2). Same 37 commands, three answers. AND THEY ANSWER F17'S OPEN QUESTION. I had flagged that ATTACK might earn its place where Blame costs personal score. It does not, in any mode: SHARED GROUND 132/165/190/200 -> identical free but pointless COMMON PROBLEM 59/52/48/44 -> 59/52/48/34 a cost at six seats BONDED COALITIONS 131/134/132/116 -> 59/52/48/34 roughly halved The coalitions row has a mechanism and the data confirms it unprompted. GR-A07 flips a Bond to a Rivalry on Attack, and GR-E04 scores Bond NETWORKS -- so attacking destroys the thing that scores. And the attacking numbers in E04 are IDENTICAL to E03's, which is exactly what that predicts: break every Bond and each seat is a coalition of one, so GR-E04 degenerates into GR-E03. That check was not designed; it fell out. F14 -> applied. F17 strengthened and no longer bounded to co-op: ATTACK has no mode in which it helps, and one where it actively destroys your score. Still framed as a question rather than a verdict. DARVO is the pattern the game is about not falling into, so a self-destructive ATTACK may be the design. What ground-game has to decide is whether the namesake mechanic being unreachable in competent play -- in all three modes -- is intended. make all: exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
480 lines
18 KiB
Rust
480 lines
18 KiB
Rust
//! 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
|
||
--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,
|
||
},
|
||
}
|
||
|
||
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;
|
||
}
|
||
"--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 2–6", 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,
|
||
};
|
||
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}")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
};
|
||
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,
|
||
};
|
||
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,
|
||
};
|
||
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);
|
||
}
|
||
}
|