Some checks failed
ci / check (push) Failing after 4s
--pace speed|interactive, defaulting to Speed. Nothing reads it yet, and that is the point: it is the seam clay-animate attaches to, and a seam is cheap now where a retrofit would not be. A misspelt pace is refused rather than defaulting, because quietly falling back to Speed would look exactly like the renderer being broken. I3 is asserted rather than intended: the same scripted game at both paces must produce a byte-identical serialised recording and the same end state hash. Mutation-proven — leak the pace into the seed and it fails with "the recording differs by pace, so a renderer has become mechanism". specs/OrnamentRegister.md carries four declarations. This reverses the reasoning written in T03 earlier, which said the first declarations would come from F18's unvendored files: instances already existed. Hand order is what prompted the category, and "who deals" was the maintainer's own example. O3 is the interesting one — seat ORDER is mechanism because GR-R08 rotates Lead, while where a seat is drawn is not. I5 is executable: check_ornament_falsifier fails any row still declared that names no falsifier, mutation-proven red on O1. Presence, never adequacy, and the finding text says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
571 lines
22 KiB
Rust
571 lines
22 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
|
||
--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,
|
||
},
|
||
}
|
||
|
||
fn parse_args(argv: &[String]) -> Result<Mode, String> {
|
||
let mut config = Config::default();
|
||
let mut source: Option<std::path::PathBuf> = None;
|
||
let mut eyes = inspect::Eyes::Spectator;
|
||
let mut play_flags: Vec<String> = Vec::new();
|
||
let mut inspect_flags: Vec<String> = Vec::new();
|
||
let mut seats: Vec<u8> = Vec::new();
|
||
let mut all_bots = false;
|
||
let mut i = 0;
|
||
let value = |i: usize, argv: &[String], flag: &str| -> Result<String, String> {
|
||
argv.get(i + 1)
|
||
.cloned()
|
||
.ok_or_else(|| format!("{flag} needs a value"))
|
||
};
|
||
while i < argv.len() {
|
||
let flag = argv[i].as_str();
|
||
match flag {
|
||
"--seed" => {
|
||
play_flags.push(flag.into());
|
||
config.seed = value(i, argv, flag)?
|
||
.parse()
|
||
.map_err(|e| format!("--seed: {e}"))?;
|
||
i += 2;
|
||
}
|
||
"--players" => {
|
||
play_flags.push(flag.into());
|
||
config.players = value(i, argv, flag)?
|
||
.parse()
|
||
.map_err(|e| format!("--players: {e}"))?;
|
||
i += 2;
|
||
}
|
||
"--seat" => {
|
||
play_flags.push(flag.into());
|
||
seats.push(
|
||
value(i, argv, flag)?
|
||
.parse()
|
||
.map_err(|e| format!("--seat: {e}"))?,
|
||
);
|
||
i += 2;
|
||
}
|
||
"--bot" => {
|
||
play_flags.push(flag.into());
|
||
config.bot = value(i, argv, flag)?;
|
||
i += 2;
|
||
}
|
||
"--record" => {
|
||
play_flags.push(flag.into());
|
||
config.record = Some(value(i, argv, flag)?.into());
|
||
i += 2;
|
||
}
|
||
// CB-WP-0027: a trial is a recorded session PLUS what the
|
||
// player said while playing (GameDesign §5, ADR-0014).
|
||
// GR-E02..E04, so the other two shipped modes are reachable.
|
||
"--mode" => {
|
||
play_flags.push(flag.into());
|
||
let v = value(i, argv, flag)?;
|
||
config.mode = match v.to_ascii_lowercase().as_str() {
|
||
"shared" | "sharedground" | "coop" => games_ground::ScoringMode::SharedGround,
|
||
"common" | "commonproblem" | "semi" => games_ground::ScoringMode::CommonProblem,
|
||
"coalitions" | "bondedcoalitions" | "coalition" => {
|
||
games_ground::ScoringMode::BondedCoalitions
|
||
}
|
||
other => {
|
||
return Err(format!(
|
||
"unknown --mode {other:?} (shared, common, coalitions)"
|
||
))
|
||
}
|
||
};
|
||
i += 2;
|
||
}
|
||
// CB-WP-0036 T02. A SEPARATE axis from --mode: that one is
|
||
// ScoringMode, a rule of the game; this is how much of what
|
||
// the rules cannot see gets performed (Ornamentation §4).
|
||
//
|
||
// **The flag exists before anything reads it, deliberately.**
|
||
// It is the seam `clay-animate` attaches to, and a seam is
|
||
// cheap now where a retrofit would not be.
|
||
"--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<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,
|
||
pace: table::Pace::Speed,
|
||
};
|
||
let script = "0\n".repeat(400);
|
||
let mut out: Vec<u8> = Vec::new();
|
||
let summary = table::play(&config, script.as_bytes(), &mut out)
|
||
.unwrap_or_else(|e| panic!("{players}p scripted game failed: {e}"));
|
||
|
||
assert_eq!(summary.rounds, 5, "{players}p: GR-R09 runs five rounds");
|
||
let text = String::from_utf8(out).expect("utf8");
|
||
assert!(text.contains("OUTCOME"), "{players}p: no outcome reported");
|
||
assert!(text.contains("you are P1"), "{players}p: no prompt");
|
||
|
||
// The transcript replays identically — through the scenario
|
||
// runner, not through a second call to the same driver.
|
||
match run::<GroundState>(&summary.scenario) {
|
||
RunOutcome::Passed { .. } => {}
|
||
RunOutcome::Failed { reason, .. } => {
|
||
panic!("{players}p replay failed: {reason}")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **Ornamentation §5, I3** — the invariant the clay-borg /
|
||
/// clay-animate split rests on.
|
||
///
|
||
/// > The same seed and the same decisions produce a **byte-identical
|
||
/// > recording** at any pace.
|
||
///
|
||
/// Asserted rather than intended. If this ever fails, something that
|
||
/// was called ornamentation has become mechanism, and the boundary in
|
||
/// `specs/Ornamentation.md` has stopped being real — which §7 names
|
||
/// as wrong at the root rather than patchable at the edges.
|
||
#[test]
|
||
fn pace_cannot_change_the_game() {
|
||
let at = |pace| {
|
||
let config = Config {
|
||
seed: 42,
|
||
players: 3,
|
||
human_seats: vec![0],
|
||
bot: "greedy".into(),
|
||
replay_dir: None,
|
||
record: None,
|
||
serve: None,
|
||
trial: None,
|
||
mode: games_ground::ScoringMode::SharedGround,
|
||
pace,
|
||
};
|
||
let script = "0\n".repeat(400);
|
||
let mut out: Vec<u8> = Vec::new();
|
||
let s = table::play(&config, script.as_bytes(), &mut out).expect("game");
|
||
(
|
||
serde_yaml::to_string(&s.scenario).expect("yaml"),
|
||
s.end_state_hash,
|
||
)
|
||
};
|
||
let (speed_yaml, speed_hash) = at(table::Pace::Speed);
|
||
let (inter_yaml, inter_hash) = at(table::Pace::Interactive);
|
||
|
||
// The recording, byte for byte.
|
||
assert_eq!(
|
||
speed_yaml, inter_yaml,
|
||
"the recording differs by pace, so a renderer has become mechanism"
|
||
);
|
||
// And the state hash, which is what §1.1 uses to tell the two
|
||
// categories apart in the first place.
|
||
assert_eq!(speed_hash, inter_hash, "pace moved the state hash");
|
||
}
|
||
|
||
/// `--pace` parses, defaults to speed, and refuses what it cannot do.
|
||
///
|
||
/// **Speed is the default** because `sim`, `trials`, the benchmarks
|
||
/// and every bot game run at it (Ornamentation §4).
|
||
#[test]
|
||
fn pace_parses_and_defaults_to_speed() {
|
||
assert_eq!(Config::default().pace, table::Pace::Speed);
|
||
assert_eq!(
|
||
play_args(&["--pace", "interactive"]).expect("parse").pace,
|
||
table::Pace::Interactive
|
||
);
|
||
assert_eq!(
|
||
play_args(&["--pace", "speed"]).expect("parse").pace,
|
||
table::Pace::Speed
|
||
);
|
||
// A misspelling must not silently mean the default: pace is the
|
||
// seam clay-animate attaches to, and a typo that quietly selects
|
||
// Speed would look like the renderer being broken.
|
||
let e = match play_args(&["--pace", "cinematic"]) {
|
||
Err(e) => e,
|
||
Ok(_) => panic!("a misspelt pace was accepted"),
|
||
};
|
||
assert!(e.contains("cinematic"), "{e}");
|
||
assert!(e.contains("speed") && e.contains("interactive"), "{e}");
|
||
}
|
||
|
||
/// K13 at the boundary that matters: what the human is *shown* must
|
||
/// not contain another seat's face-down selection.
|
||
///
|
||
/// The human seat is **P3**, deliberately. Seats are asked in order,
|
||
/// so a P1 human is always prompted before anyone has selected and
|
||
/// never sees a hidden selection at all — a test seated there passes
|
||
/// without exercising the rule.
|
||
#[test]
|
||
fn the_prompt_never_shows_a_hidden_selection() {
|
||
let config = Config {
|
||
seed: 42,
|
||
players: 3,
|
||
human_seats: vec![2],
|
||
bot: "greedy".into(),
|
||
replay_dir: None,
|
||
record: None,
|
||
serve: None,
|
||
trial: None,
|
||
mode: games_ground::ScoringMode::SharedGround,
|
||
pace: table::Pace::Speed,
|
||
};
|
||
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,
|
||
pace: table::Pace::Speed,
|
||
};
|
||
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,
|
||
pace: table::Pace::Speed,
|
||
};
|
||
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);
|
||
}
|
||
}
|