//! 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 { 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::().ok() == digits.parse::().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::>() .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 { let mut config = Config::default(); let mut source: Option = None; let mut eyes = inspect::Eyes::Spectator; let mut play_flags: Vec = Vec::new(); let mut inspect_flags: Vec = Vec::new(); let mut seats: Vec = Vec::new(); let mut all_bots = false; let mut i = 0; let value = |i: usize, argv: &[String], flag: &str| -> Result { 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 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 = 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 { 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 = 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::(&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 = 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 = 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 = 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 = 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 { 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 = 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::(&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::(&parsed), RunOutcome::Passed { .. } )); let _ = std::fs::remove_dir_all(&dir); } }