diff --git a/crates/cb-game-runtime/src/replay.rs b/crates/cb-game-runtime/src/replay.rs index a42679c..00ab0e5 100644 --- a/crates/cb-game-runtime/src/replay.rs +++ b/crates/cb-game-runtime/src/replay.rs @@ -133,12 +133,18 @@ pub fn write_bundle( Ok(bundle) } -/// Re-execute a bundle and require it to reproduce the recorded hash. +/// Open a bundle for re-execution: the manifest, the restored initial +/// state, and the recorded steps in order. /// -/// Returns `Err` when the bundle is corrupt, incomplete, internally -/// inconsistent, or does not reproduce — all of which are the point. A -/// round-trip that cannot fail proves nothing. -pub fn replay(bundle: &Path) -> Result +/// Extracted from [`replay`] by CB-WP-0011 T02 so the inspector walks a +/// bundle through **the same reader the replay gate uses**. A second +/// reader would let the inspector show states a replay never reached — +/// and it would be a duplicated fact, checked by nothing, in the one +/// place where being wrong is silent. +/// +/// Every control [`replay`] performed before touching the command log is +/// performed here, including the one that makes the seed load-bearing. +pub fn open(bundle: &Path) -> Result<(Manifest, G, Vec), String> where G: ScenarioGame, { @@ -165,17 +171,33 @@ where &std::fs::read(bundle.join(INITIAL)).map_err(|e| err("read initial", e))?, ) .map_err(|e| err("parse initial", e))?; - let mut state: G = serde_json::from_value(initial).map_err(|e| err("restore initial", e))?; + let state: G = serde_json::from_value(initial).map_err(|e| err("restore initial", e))?; // K11 framing: a truncated or corrupt command log is rejected here. let raw = std::fs::read(bundle.join(COMMANDS)).map_err(|e| err("read commands", e))?; let records = parse(&raw).map_err(|e| err("command log", e))?; + let mut steps = Vec::with_capacity(records.len()); + for record in &records { + steps.push(serde_json::from_slice(record).map_err(|e| err("decode step", e))?); + } + + Ok((manifest, state, steps)) +} + +/// Re-execute a bundle and require it to reproduce the recorded hash. +/// +/// Returns `Err` when the bundle is corrupt, incomplete, internally +/// inconsistent, or does not reproduce — all of which are the point. A +/// round-trip that cannot fail proves nothing. +pub fn replay(bundle: &Path) -> Result +where + G: ScenarioGame, +{ + let (manifest, mut state, steps) = open::(bundle)?; let mut applied = 0usize; - for record in &records { - let step: CommandStep = - serde_json::from_slice(record).map_err(|e| err("decode step", e))?; - let (actor, command) = G::parse_command(&step)?; + for step in &steps { + let (actor, command) = G::parse_command(step)?; if let Ok(produced) = state.validate(actor, &command) { for event in produced { state.fold(&event); diff --git a/tools/cb-play/src/inspect.rs b/tools/cb-play/src/inspect.rs index 70f435d..6b2466d 100644 --- a/tools/cb-play/src/inspect.rs +++ b/tools/cb-play/src/inspect.rs @@ -25,8 +25,10 @@ //! needs a rendering port, which needs an ADR, which this pass does not //! have (see the workplan's tier declaration). -use cb_kernel::PlayerId; +use cb_game_runtime::{Project, ScenarioGame}; +use cb_kernel::{Aggregate, PlayerId}; use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView}; +use games_ground::GroundState; pub fn suit_name(s: games_ground::Suit) -> &'static str { match s { @@ -255,6 +257,170 @@ pub fn render(view: &GroundView) -> String { out } +// ------------------------------------------------------------------ walk + +/// Which seat's projection a walk renders. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Eyes { + Seat(PlayerId), + Spectator, +} + +impl Eyes { + pub fn parse(raw: &str) -> Result { + if raw.eq_ignore_ascii_case("spectator") { + return Ok(Eyes::Spectator); + } + raw.parse::() + .map(|n| Eyes::Seat(PlayerId(n))) + .map_err(|_| format!("--as expects a 0-based seat or 'spectator', got {raw:?}")) + } + + fn label(self) -> String { + match self { + Eyes::Seat(p) => format!("{} (their hand only)", seat_name(p)), + Eyes::Spectator => "a spectator (no hands)".into(), + } + } + + fn viewer(self) -> cb_game_runtime::Viewer { + match self { + Eyes::Seat(p) => cb_game_runtime::Viewer::Player(p), + Eyes::Spectator => cb_game_runtime::Viewer::Spectator, + } + } +} + +/// What a completed walk reports. +#[derive(Debug)] +pub struct Walk { + pub source: String, + pub steps: usize, + /// Steps the aggregate refused. A recorded game may legitimately + /// contain them — a scenario can assert a rejection — so they are + /// counted and shown, not treated as an error. + pub rejected: usize, + pub end_state_hash: String, + /// `Some` only for a bundle, which records the hash its own producer + /// computed. A scenario file may or may not pin one. + pub expected_hash: Option, +} + +/// Replay a recorded game and render the table after every step. +/// +/// This is the answer to *"what did the table look like when it went +/// wrong?"* that previously required adding a `dbg!` and re-running. +/// +/// The bundle path goes through `replay::open`, the same reader +/// `make replay-test` uses, so the states shown here are the states a +/// replay reaches — structurally, not by assertion. The hash is then +/// checked anyway, because a structural argument that is never executed +/// is the class of claim this project keeps finding to be wrong. +pub fn walk( + source: &std::path::Path, + eyes: Eyes, + out: &mut W, +) -> Result { + let (mut state, steps, expected_hash) = load(source)?; + + let name = source.display().to_string(); + let _ = writeln!( + out, + "inspecting {name} as {} — {} step(s)", + eyes.label(), + steps.len() + ); + let _ = write!(out, "{}", render(&state.project(eyes.viewer()))); + + let mut rejected = 0usize; + for (i, step) in steps.iter().enumerate() { + let (actor, command) = GroundState::parse_command(step)?; + match state.validate(actor, &command) { + Ok(produced) => { + for event in produced { + state.fold(&event); + } + let _ = writeln!(out, "\n[{}] {} {}", i + 1, step.actor, describe_step(step)); + } + Err(rejection) => { + rejected += 1; + let _ = writeln!( + out, + "\n[{}] {} {} — REJECTED: {rejection:?}", + i + 1, + step.actor, + describe_step(step) + ); + } + } + let _ = write!(out, "{}", render(&state.project(eyes.viewer()))); + } + + let end_state_hash = cb_events::state_hash_hex(&state); + if let Some(expected) = &expected_hash { + if &end_state_hash != expected { + return Err(format!( + "the walk did not reproduce the recorded end state: {end_state_hash} != {expected}" + )); + } + } + let _ = writeln!( + out, + "\nend-state hash {end_state_hash}{}", + match &expected_hash { + Some(_) => " (matches the recording)", + None => " (the source pins no hash)", + } + ); + + Ok(Walk { + source: name, + steps: steps.len(), + rejected, + end_state_hash, + expected_hash, + }) +} + +/// A `.cbreplay` bundle or a scenario YAML. Both already reconstruct a +/// command sequence; neither needed a new format for this. +fn load( + source: &std::path::Path, +) -> Result< + ( + GroundState, + Vec, + Option, + ), + String, +> { + if source.is_dir() { + let (manifest, state, steps) = cb_game_runtime::replay::open::(source)?; + return Ok((state, steps, Some(manifest.end_state_hash))); + } + let yaml = + std::fs::read_to_string(source).map_err(|e| format!("read {}: {e}", source.display()))?; + let file = cb_game_runtime::ScenarioFile::from_yaml(&yaml) + .map_err(|e| format!("parse {}: {e}", source.display()))?; + let state = GroundState::setup(&file.setup, file.seed)?; + Ok((state, file.commands, file.expect.state_hash)) +} + +fn describe_step(step: &cb_game_runtime::CommandStep) -> String { + let mut out = step.cmd.clone(); + for (key, value) in &step.args { + let rendered = match value { + serde_yaml::Value::String(s) => s.clone(), + other => serde_yaml::to_string(other) + .unwrap_or_default() + .trim() + .to_string(), + }; + out.push_str(&format!(" {key}={rendered}")); + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -577,4 +743,136 @@ mod tests { assert!(!out.contains("(you)"), "{out}"); assert!(!out.contains("hand ["), "{out}"); } + + // ------------------------------------------------------- the walk + + /// A recorded game, produced the way a user produces one: play it + /// with bots and ask for a bundle. Fixtures written by hand would + /// test the reader against the writer's assumptions rather than + /// against what the writer actually writes. + fn recorded(tag: &str) -> (std::path::PathBuf, std::path::PathBuf, String) { + let dir = std::env::temp_dir().join(format!("cb-inspect-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmp"); + let config = crate::table::Config { + seed: 42, + players: 3, + human_seats: vec![], + bot: "greedy".into(), + replay_dir: Some(dir.clone()), + record: Some(dir.join("session.yaml")), + }; + let mut sink: Vec = Vec::new(); + let summary = + crate::table::play(&config, "".as_bytes(), &mut sink).expect("the bot game runs"); + ( + summary.bundle.expect("bundle"), + summary.recorded.expect("scenario"), + summary.end_state_hash, + ) + } + + /// The acceptance: an inspector that shows a state the replay never + /// reached is worse than no inspector. Both source kinds are walked, + /// because "it works for bundles" was the shape of the last three + /// half-checked claims in this repo. + #[test] + fn a_walk_reproduces_the_recorded_end_state() { + let (bundle, scenario, hash) = recorded("walk"); + + let mut out: Vec = Vec::new(); + let report = super::walk(&bundle, Eyes::Spectator, &mut out).expect("bundle walks"); + assert_eq!(report.end_state_hash, hash); + assert_eq!(report.expected_hash.as_deref(), Some(hash.as_str())); + assert!( + report.steps > 5, + "a 3-player game is more than {} steps", + report.steps + ); + + // The render must have run once per step plus once for the + // initial state — otherwise the walk "succeeded" having shown + // nothing, which is the harness-does-nothing shape. + let text = String::from_utf8(out).expect("utf8"); + assert_eq!( + text.matches(" problems:").count(), + report.steps + 1, + "one table per step plus the opening one\n{text}" + ); + + let mut out: Vec = Vec::new(); + let from_yaml = super::walk(&scenario, Eyes::Spectator, &mut out).expect("scenario walks"); + assert_eq!(from_yaml.end_state_hash, hash); + + let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir")); + } + + /// The control for the assertion above. A bundle whose recorded hash + /// has been altered must make the walk fail, or the hash comparison + /// is decoration. + #[test] + fn a_walk_that_does_not_reproduce_fails() { + let (bundle, _, hash) = recorded("tamper"); + let manifest = bundle.join("manifest.yaml"); + let text = std::fs::read_to_string(&manifest).expect("read manifest"); + std::fs::write( + &manifest, + text.replace( + &hash, + "0000000000000000000000000000000000000000000000000000000000000000", + ), + ) + .expect("write manifest"); + + let mut out: Vec = Vec::new(); + let err = + super::walk(&bundle, Eyes::Spectator, &mut out).expect_err("tampered bundle must fail"); + assert!(err.contains("did not reproduce"), "wrong reason: {err}"); + let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir")); + } + + /// `--as` is a projection, not a filter applied afterwards. Seat 2 + /// (P3) sees its own hand and counts for the rest. + /// + /// Seated deliberately at the *last* seat: the equivalent stage-0 + /// test was vacuous twice (CB-EV-0007) because it inspected a + /// position where the hidden thing had not yet been written. + #[test] + fn a_seat_walk_shows_that_seat_and_no_other() { + let (bundle, _, _) = recorded("eyes"); + + let mut out: Vec = Vec::new(); + super::walk(&bundle, Eyes::Seat(PlayerId(2)), &mut out).expect("walks"); + let seated = String::from_utf8(out).expect("utf8"); + + let mut out: Vec = Vec::new(); + super::walk(&bundle, Eyes::Spectator, &mut out).expect("walks"); + let spectator = String::from_utf8(out).expect("utf8"); + + // Exactly one seat shows cards, and it is P3. + let tables = seated.matches(" problems:").count(); + assert_eq!( + seated.matches("hand [").count(), + tables, + "P3 shows a hand in every table and nobody else does\n{seated}" + ); + for line in seated.lines().filter(|l| l.contains("hand [")) { + assert!(line.contains("P3 (you)"), "a hand leaked: {line}"); + } + // A spectator sees none at all — the same walk, one argument + // apart, so a render that ignored `Eyes` would fail here. + assert!(!spectator.contains("hand ["), "{spectator}"); + assert!(!spectator.contains("(you)"), "{spectator}"); + assert_ne!(seated, spectator); + + let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir")); + } + + #[test] + fn eyes_parse_and_bad_ones_are_refused() { + assert_eq!(Eyes::parse("2").unwrap(), Eyes::Seat(PlayerId(2))); + assert_eq!(Eyes::parse("SPECTATOR").unwrap(), Eyes::Spectator); + assert!(Eyes::parse("P3").is_err()); + assert!(Eyes::parse("").is_err()); + } } diff --git a/tools/cb-play/src/main.rs b/tools/cb-play/src/main.rs index 98b80e9..e90066d 100644 --- a/tools/cb-play/src/main.rs +++ b/tools/cb-play/src/main.rs @@ -16,8 +16,10 @@ use table::Config; const USAGE: &str = "\ usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random] - [--replay DIR] [--all-bots] + [--replay DIR] [--all-bots] [--record FILE] + 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) @@ -25,10 +27,34 @@ usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random] --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 + +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. "; -fn parse_args(argv: &[String]) -> Result { +/// 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 { 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; @@ -41,18 +67,21 @@ fn parse_args(argv: &[String]) -> Result { 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() @@ -61,25 +90,54 @@ fn parse_args(argv: &[String]) -> Result { 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; } "--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)); } @@ -96,12 +154,12 @@ fn parse_args(argv: &[String]) -> Result { config.players )); } - Ok(config) + Ok(Mode::Play(config)) } fn main() { let argv: Vec = std::env::args().skip(1).collect(); - let config = match parse_args(&argv) { + let mode = match parse_args(&argv) { Ok(c) => c, Err(message) => { eprintln!("{message}"); @@ -109,6 +167,33 @@ fn main() { } }; + 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()) { @@ -265,21 +350,43 @@ mod tests { 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 = parse_args(&args(&["--seed", "9", "--players", "4", "--seat", "2"])).unwrap(); + 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!(parse_args(&args(&[])).unwrap().human_seats, vec![0]); - assert!(parse_args(&args(&["--all-bots"])) - .unwrap() - .human_seats - .is_empty()); + 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()); } diff --git a/workplans/CB-WP-0011-inspectable-table.md b/workplans/CB-WP-0011-inspectable-table.md index bb3b0bd..9e22925 100644 --- a/workplans/CB-WP-0011-inspectable-table.md +++ b/workplans/CB-WP-0011-inspectable-table.md @@ -133,7 +133,7 @@ while writing the gate, before it had ever been committed. ```task id: CB-WP-0011-T02 -status: todo +status: done priority: high state_hub_task_id: "08f8a9c7-3dfe-4f72-b510-df9efa457370" ``` @@ -160,6 +160,35 @@ must not show anyone else's. The stage-0 test that checked this was vacuous twice before it held (CB-EV-0007); seat the assertion where the hidden thing is actually hidden. +**Done 2026-08-02.** `cb-play --inspect PATH [--as SEAT|spectator]` +walks a `.cbreplay` bundle or a scenario YAML and renders the table after +every step. + +The design decision worth recording: `replay::replay` was split, and its +bundle reader extracted as `replay::open`, so **the inspector walks +through the same reader the replay gate uses** — including the control +that makes the recorded seed load-bearing. A second reader would let the +inspector show states a replay never reached, and it would be a +duplicated fact in the one place where being wrong is silent. The hash is +then asserted anyway, because a structural argument that is never +executed is the class of claim this project keeps finding to be wrong. + +The two modes take **disjoint** flags in both directions +(`--inspect --seed 9` and a bare `--as 2` are both refused, exit 64): a +flag accepted and ignored is how a user comes to believe they inspected +seed 9 when they inspected whatever the recording holds. + +Three controls, each red for its stated reason: + +| control | result | +|---|---| +| the hash comparison removed | `a_walk_that_does_not_reproduce_fails` fails — the tampered bundle walks clean | +| `Eyes::Seat` resolved to `Viewer::Spectator` | `P3 shows a hand in every table and nobody else does` | +| the per-step render dropped | `one table per step plus the opening one` | + +The third is the one that matters: without it a walk that rendered +nothing at all would still have reported a matching hash and passed. + ## Task: evidence, and what the chaos roll cost ```task