clay-borg/tools/cb-play/src/main.rs

445 lines
17 KiB
Rust
Raw Normal View History

//! 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.
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
mod hotseat;
mod inspect;
mod table;
use table::Config;
const USAGE: &str = "\
usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random]
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
[--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
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
--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;
}
"--replay" => {
play_flags.push(flag.into());
config.replay_dir = Some(value(i, argv, flag)?.into());
i += 2;
}
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
"--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,
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
serve: None,
};
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,
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
serve: None,
};
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,
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
serve: None,
};
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")),
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
serve: None,
};
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);
}
}