CB-WP-0011-T02: cb-play --inspect walks a recorded game
Renders the table after every step of a .cbreplay bundle or a scenario YAML, from any seat's projection or a spectator's. This is the first thing in the project that answers 'what did the table look like when it went wrong?' without adding a dbg! and re-running. INTERFACE CHANGE (flagged per InnerLoop chaos limits -- this is a tier-S pass that touched a runtime crate): cb-game-runtime gains replay::open, extracted out of replay::replay. Dev-only, behind the scenarios feature, no type changed. The point of the extraction is that the inspector and the replay gate share one bundle reader, controls included, so the inspector cannot show a state a replay never reached. Three M-D1-MUT controls, each red for its stated reason. The load-bearing one asserts one rendered table per step: without it, a walk that rendered nothing would still report a matching hash.
This commit is contained in:
parent
d2ca1046c0
commit
b11fc91fd4
4 changed files with 478 additions and 22 deletions
|
|
@ -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<Self, String> {
|
||||
if raw.eq_ignore_ascii_case("spectator") {
|
||||
return Ok(Eyes::Spectator);
|
||||
}
|
||||
raw.parse::<u8>()
|
||||
.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<String>,
|
||||
}
|
||||
|
||||
/// 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<W: std::io::Write>(
|
||||
source: &std::path::Path,
|
||||
eyes: Eyes,
|
||||
out: &mut W,
|
||||
) -> Result<Walk, String> {
|
||||
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<cb_game_runtime::CommandStep>,
|
||||
Option<String>,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
if source.is_dir() {
|
||||
let (manifest, state, steps) = cb_game_runtime::replay::open::<GroundState>(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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue