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
|
|
|
|
//! 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-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
|
|
|
|
mod inspect;
|
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
|
|
|
|
mod table;
|
|
|
|
|
|
|
|
|
|
|
|
use table::Config;
|
|
|
|
|
|
|
|
|
|
|
|
const USAGE: &str = "\
|
|
|
|
|
|
usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random]
|
|
|
|
|
|
[--replay DIR] [--all-bots]
|
|
|
|
|
|
|
|
|
|
|
|
--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
|
|
|
|
|
|
";
|
|
|
|
|
|
|
|
|
|
|
|
fn parse_args(argv: &[String]) -> Result<Config, String> {
|
|
|
|
|
|
let mut config = Config::default();
|
|
|
|
|
|
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" => {
|
|
|
|
|
|
config.seed = value(i, argv, flag)?
|
|
|
|
|
|
.parse()
|
|
|
|
|
|
.map_err(|e| format!("--seed: {e}"))?;
|
|
|
|
|
|
i += 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
"--players" => {
|
|
|
|
|
|
config.players = value(i, argv, flag)?
|
|
|
|
|
|
.parse()
|
|
|
|
|
|
.map_err(|e| format!("--players: {e}"))?;
|
|
|
|
|
|
i += 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
"--seat" => {
|
|
|
|
|
|
seats.push(
|
|
|
|
|
|
value(i, argv, flag)?
|
|
|
|
|
|
.parse()
|
|
|
|
|
|
.map_err(|e| format!("--seat: {e}"))?,
|
|
|
|
|
|
);
|
|
|
|
|
|
i += 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
"--bot" => {
|
|
|
|
|
|
config.bot = value(i, argv, flag)?;
|
|
|
|
|
|
i += 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
"--record" => {
|
|
|
|
|
|
config.record = Some(value(i, argv, flag)?.into());
|
|
|
|
|
|
i += 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
"--replay" => {
|
|
|
|
|
|
config.replay_dir = Some(value(i, argv, flag)?.into());
|
|
|
|
|
|
i += 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
"--all-bots" => {
|
|
|
|
|
|
all_bots = true;
|
|
|
|
|
|
i += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
"-h" | "--help" => return Err(USAGE.into()),
|
|
|
|
|
|
other => return Err(format!("unknown flag {other:?}\n\n{USAGE}")),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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(config)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn main() {
|
|
|
|
|
|
let argv: Vec<String> = std::env::args().skip(1).collect();
|
|
|
|
|
|
let config = match parse_args(&argv) {
|
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
|
Err(message) => {
|
|
|
|
|
|
eprintln!("{message}");
|
|
|
|
|
|
std::process::exit(64);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0008-T03: prove the 2-6 player range
GR-O01 states 2-6 players; every scenario in the corpus was 3-player.
Now all five counts play to GameEnded under both policies and reproduce
at the same seed, with scenarios at both boundaries and the CLI
transcript run at 2p, 3p and 6p.
Nothing broke — the rules are seat-count-generic. What the boundaries
exposed is arithmetic: with the standard preset's placeholder Problem
values (value = priority), the best total any game can reach is 3 at 2p,
6 at 3-4p, 10 at 5-6p, against GR-E01 thresholds of 5, 7 and 9. Group
success is unreachable below five seats regardless of play, and no
scenario noticed because none had played to scoring with everything
claimed.
GR-S01 calls the fixture a stand-in for scenario Problem data, so this
is evidence the stand-in is not neutral, not that GR-E01 is wrong. It is
pinned by a passing scenario, an arithmetic test, and a provisional
marker owned by ground-game so it ages in `make coverage`. The test
states its own delete-by: it is expected to fail when Problem values
become real data, and that failure is the signal to delete it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:20:04 +02:00
|
|
|
|
/// 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.
|
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
|
|
|
|
#[test]
|
|
|
|
|
|
fn a_scripted_transcript_plays_a_full_game() {
|
CB-WP-0008-T03: prove the 2-6 player range
GR-O01 states 2-6 players; every scenario in the corpus was 3-player.
Now all five counts play to GameEnded under both policies and reproduce
at the same seed, with scenarios at both boundaries and the CLI
transcript run at 2p, 3p and 6p.
Nothing broke — the rules are seat-count-generic. What the boundaries
exposed is arithmetic: with the standard preset's placeholder Problem
values (value = priority), the best total any game can reach is 3 at 2p,
6 at 3-4p, 10 at 5-6p, against GR-E01 thresholds of 5, 7 and 9. Group
success is unreachable below five seats regardless of play, and no
scenario noticed because none had played to scoring with everything
claimed.
GR-S01 calls the fixture a stand-in for scenario Problem data, so this
is evidence the stand-in is not neutral, not that GR-E01 is wrong. It is
pinned by a passing scenario, an arithmetic test, and a provisional
marker owned by ground-game so it ages in `make coverage`. The test
states its own delete-by: it is expected to fail when Problem values
become real data, and that failure is the signal to delete it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:20:04 +02:00
|
|
|
|
for players in [2u8, 3, 6] {
|
|
|
|
|
|
let config = Config {
|
|
|
|
|
|
seed: 42,
|
|
|
|
|
|
players,
|
|
|
|
|
|
human_seats: vec![0],
|
|
|
|
|
|
bot: "greedy".into(),
|
|
|
|
|
|
replay_dir: None,
|
|
|
|
|
|
record: 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}"));
|
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
|
|
|
|
|
CB-WP-0008-T03: prove the 2-6 player range
GR-O01 states 2-6 players; every scenario in the corpus was 3-player.
Now all five counts play to GameEnded under both policies and reproduce
at the same seed, with scenarios at both boundaries and the CLI
transcript run at 2p, 3p and 6p.
Nothing broke — the rules are seat-count-generic. What the boundaries
exposed is arithmetic: with the standard preset's placeholder Problem
values (value = priority), the best total any game can reach is 3 at 2p,
6 at 3-4p, 10 at 5-6p, against GR-E01 thresholds of 5, 7 and 9. Group
success is unreachable below five seats regardless of play, and no
scenario noticed because none had played to scoring with everything
claimed.
GR-S01 calls the fixture a stand-in for scenario Problem data, so this
is evidence the stand-in is not neutral, not that GR-E01 is wrong. It is
pinned by a passing scenario, an arithmetic test, and a provisional
marker owned by ground-game so it ages in `make coverage`. The test
states its own delete-by: it is expected to fail when Problem values
become real data, and that failure is the signal to delete it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:20:04 +02:00
|
|
|
|
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");
|
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
|
|
|
|
|
CB-WP-0008-T03: prove the 2-6 player range
GR-O01 states 2-6 players; every scenario in the corpus was 3-player.
Now all five counts play to GameEnded under both policies and reproduce
at the same seed, with scenarios at both boundaries and the CLI
transcript run at 2p, 3p and 6p.
Nothing broke — the rules are seat-count-generic. What the boundaries
exposed is arithmetic: with the standard preset's placeholder Problem
values (value = priority), the best total any game can reach is 3 at 2p,
6 at 3-4p, 10 at 5-6p, against GR-E01 thresholds of 5, 7 and 9. Group
success is unreachable below five seats regardless of play, and no
scenario noticed because none had played to scoring with everything
claimed.
GR-S01 calls the fixture a stand-in for scenario Problem data, so this
is evidence the stand-in is not neutral, not that GR-E01 is wrong. It is
pinned by a passing scenario, an arithmetic test, and a provisional
marker owned by ground-game so it ages in `make coverage`. The test
states its own delete-by: it is expected to fail when Problem values
become real data, and that failure is the signal to delete it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:20:04 +02:00
|
|
|
|
// 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}")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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,
|
|
|
|
|
|
};
|
|
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
let mut out: Vec<u8> = Vec::new();
|
|
|
|
|
|
let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game");
|
|
|
|
|
|
assert_eq!(summary.rounds, 5);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn flags_parse_and_bad_ones_are_refused() {
|
|
|
|
|
|
let c = parse_args(&args(&["--seed", "9", "--players", "4", "--seat", "2"])).unwrap();
|
|
|
|
|
|
assert_eq!((c.seed, c.players, c.human_seats.clone()), (9, 4, vec![2]));
|
|
|
|
|
|
assert_eq!(parse_args(&args(&[])).unwrap().human_seats, vec![0]);
|
|
|
|
|
|
assert!(parse_args(&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());
|
|
|
|
|
|
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")),
|
|
|
|
|
|
};
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|