//! Turn a played game back into a scenario (CB-WP-0008 T02). //! //! `ScenarioGame::parse_command` reads a `CommandStep` into a typed //! command. This is its inverse, so a game somebody played becomes a //! scenario the runner can re-execute and a `.cbreplay` bundle can carry. //! //! **The round trip is the test**, and it is the only reason this is //! trustworthy: `parse_command(to_step(actor, cmd)) == (actor, cmd)` for //! every command shape. An encoder checked only against hand-written //! expectations agrees with itself. use crate::{Action, GroundChoice, GroundCommand, GroundMode, SupportResponse}; use cb_game_runtime::{CommandStep, Setup}; use cb_kernel::{Actor, PlayerId}; use std::collections::BTreeMap; fn seat(p: PlayerId) -> serde_yaml::Value { serde_yaml::Value::String(format!("P{}", p.0 + 1)) } fn num(n: u64) -> serde_yaml::Value { serde_yaml::Value::Number(n.into()) } fn text(s: &str) -> serde_yaml::Value { serde_yaml::Value::String(s.to_string()) } fn actor_name(actor: Actor) -> String { match actor { Actor::Player(p) => format!("P{}", p.0 + 1), Actor::System => "SYSTEM".into(), } } fn action_name(action: Action) -> &'static str { match action { Action::Investigate => "INVESTIGATE", Action::Solve => "SOLVE", Action::Support => "SUPPORT", Action::Attack => "ATTACK", Action::Ground => "GROUND", } } fn mode_name(mode: GroundMode) -> &'static str { match mode { GroundMode::Gr => "GR", GroundMode::Ou => "OU", GroundMode::Nd => "ND", } } fn response_name(r: SupportResponse) -> &'static str { match r { SupportResponse::AcceptBond => "accept_bond", SupportResponse::DeclineBond => "decline_bond", SupportResponse::FlipToBond => "flip_to_bond", SupportResponse::BreakRivalry => "break_rivalry", } } /// One issued command as a scenario step. pub fn to_step(actor: Actor, command: &GroundCommand) -> CommandStep { let mut args: BTreeMap = BTreeMap::new(); let cmd = match command { GroundCommand::SelectAction { action, target, problem, } => { args.insert("action".into(), text(action_name(*action))); if let Some(t) = target { args.insert("target".into(), seat(*t)); } if let Some(p) = problem { args.insert("problem".into(), num(u64::from(*p))); } "select_action" } GroundCommand::SpendFreedom => "spend_freedom", GroundCommand::ChooseGroundMode { mode, choice } => { args.insert("mode".into(), text(mode_name(*mode))); match choice { None => {} Some(GroundChoice::RestoreProblem { problem }) => { args.insert("choice".into(), text("restore_problem")); args.insert("problem".into(), num(u64::from(*problem))); } Some(GroundChoice::ProtectProblem { problem }) => { args.insert("choice".into(), text("protect_problem")); args.insert("problem".into(), num(u64::from(*problem))); } Some(GroundChoice::CancelAttack { attacker }) => { args.insert("choice".into(), text("cancel_attack")); args.insert("seat".into(), num(u64::from(attacker.0))); } Some(GroundChoice::RemoveBlame { owner }) => { args.insert("choice".into(), text("remove_blame")); args.insert("seat".into(), num(u64::from(owner.0))); } Some(GroundChoice::BreakRelation { with }) => { args.insert("choice".into(), text("break_relation")); args.insert("seat".into(), num(u64::from(with.0))); } Some(GroundChoice::RejectReverse) => { args.insert("choice".into(), text("reject_reverse")); } } "choose_ground_mode" } GroundCommand::RespondToSupport { response } => { args.insert("response".into(), text(response_name(*response))); "respond_to_support" } GroundCommand::ChooseDarvoTarget { target } => { if let Some(p) = target.problem { args.insert("problem".into(), num(u64::from(p))); } if let Some(t) = target.player { args.insert("target".into(), seat(t)); } "choose_darvo_target" } GroundCommand::Reveal => "reveal", GroundCommand::Resolve => "resolve", GroundCommand::EndRound => "end_round", }; CommandStep { actor: actor_name(actor), cmd: cmd.into(), args, } } /// A whole played game as a scenario file. /// /// `covers` is deliberately **empty**: a recorded game exercises whatever /// it happened to exercise, and claiming rule coverage from it would /// inflate AM-1 with rules nobody asserted. `expect.state_hash` carries /// the end-state hash, which is the assertion that a replay must satisfy. pub fn to_scenario( name: &str, seed: u64, players: u8, steps: &[(Actor, GroundCommand)], end_state_hash: Option, ) -> cb_game_runtime::ScenarioFile { cb_game_runtime::ScenarioFile { scenario: name.to_string(), description: "recorded by cb-play (CB-WP-0008 T02)".into(), covers: vec![], provisional: false, provisional_owner: String::new(), provisional_raised: String::new(), // A recording is evidence of what happened, not a claim about an // undetermined rule, so it carries no ruling and encodes no // U-item. Set explicitly rather than by `..Default::default()`: // a recorded game silently inheriting a U-item claim would be a // reproduction pointing at a finding it has nothing to do with. ruled: String::new(), ruled_by: String::new(), ruled_note: String::new(), encodes_u_item: String::new(), seed, setup: Setup { players, preset: format!("standard-{players}p"), patch: BTreeMap::new(), }, commands: steps.iter().map(|(a, c)| to_step(*a, c)).collect(), expect: cb_game_runtime::scenario::Expect { state_hash: end_state_hash, ..Default::default() }, } } #[cfg(test)] mod tests { use super::*; use crate::{DarvoTarget, GroundState}; use cb_game_runtime::ScenarioGame; /// A game somebody played becomes a scenario the runner re-executes /// to the same hash. This is the acceptance clause "the same /// transcript replays identically", taken through the *scenario /// runner* rather than through a second call to the same driver — /// which would only prove the driver is deterministic. #[test] fn a_recorded_bot_game_replays_as_a_scenario() { use crate::bot::{play, GreedyPolicy, Policy}; use cb_events::state_hash_hex; use cb_game_runtime::{run, RunOutcome, Setup}; let setup = Setup { players: 3, preset: "standard-3p".into(), patch: BTreeMap::new(), }; let game = play( GroundState::setup(&setup, 42).expect("preset"), &mut (0..3) .map(|_| Box::new(GreedyPolicy) as Box) .collect::>(), ) .expect("bot game"); let hash = state_hash_hex(&game.state); let scenario = to_scenario( "ground/recorded-bot-game", 42, 3, &game.steps, Some(hash.clone()), ); assert_eq!(scenario.commands.len(), game.steps.len()); match run::(&scenario) { RunOutcome::Passed { .. } => {} RunOutcome::Failed { reason, .. } => { panic!("recorded game did not replay: {reason}") } } // Control: the recorded hash is what the replay is checked // against, so a wrong hash must fail. Without this the pass above // is satisfied by a scenario that asserts nothing. let tampered = to_scenario( "ground/recorded-bot-game", 42, 3, &game.steps, Some("0".repeat(hash.len())), ); assert!( matches!(run::(&tampered), RunOutcome::Failed { .. }), "a tampered end-state hash must fail the replay" ); } /// Every command shape must survive encode → parse unchanged. This is /// the whole warrant for the encoder: nothing else compares it to the /// reader it must agree with. #[test] fn every_command_shape_round_trips() { let p2 = PlayerId(1); let cases: Vec<(Actor, GroundCommand)> = vec![ ( Actor::Player(PlayerId(0)), GroundCommand::SelectAction { action: Action::Investigate, target: None, problem: Some(3), }, ), ( Actor::Player(PlayerId(0)), GroundCommand::SelectAction { action: Action::Attack, target: Some(p2), problem: None, }, ), ( Actor::Player(PlayerId(2)), GroundCommand::SelectAction { action: Action::Ground, target: None, problem: None, }, ), (Actor::Player(PlayerId(0)), GroundCommand::SpendFreedom), ( Actor::Player(PlayerId(0)), GroundCommand::ChooseGroundMode { mode: GroundMode::Gr, choice: None, }, ), ( Actor::Player(PlayerId(0)), GroundCommand::ChooseGroundMode { mode: GroundMode::Ou, choice: Some(GroundChoice::RestoreProblem { problem: 2 }), }, ), ( Actor::Player(PlayerId(0)), GroundCommand::ChooseGroundMode { mode: GroundMode::Ou, choice: Some(GroundChoice::ProtectProblem { problem: 1 }), }, ), ( Actor::Player(PlayerId(0)), GroundCommand::ChooseGroundMode { mode: GroundMode::Ou, choice: Some(GroundChoice::CancelAttack { attacker: p2 }), }, ), ( Actor::Player(PlayerId(0)), GroundCommand::ChooseGroundMode { mode: GroundMode::Nd, choice: Some(GroundChoice::RemoveBlame { owner: p2 }), }, ), ( Actor::Player(PlayerId(0)), GroundCommand::ChooseGroundMode { mode: GroundMode::Nd, choice: Some(GroundChoice::BreakRelation { with: p2 }), }, ), ( Actor::Player(PlayerId(0)), GroundCommand::ChooseGroundMode { mode: GroundMode::Nd, choice: Some(GroundChoice::RejectReverse), }, ), ( Actor::Player(PlayerId(1)), GroundCommand::RespondToSupport { response: SupportResponse::AcceptBond, }, ), ( Actor::Player(PlayerId(1)), GroundCommand::RespondToSupport { response: SupportResponse::BreakRivalry, }, ), ( Actor::Player(PlayerId(0)), GroundCommand::ChooseDarvoTarget { target: DarvoTarget { problem: Some(2), player: None, }, }, ), ( Actor::Player(PlayerId(0)), GroundCommand::ChooseDarvoTarget { target: DarvoTarget { problem: None, player: Some(p2), }, }, ), (Actor::System, GroundCommand::Reveal), (Actor::System, GroundCommand::Resolve), (Actor::System, GroundCommand::EndRound), ]; for (actor, command) in &cases { let step = to_step(*actor, command); let parsed = GroundState::parse_command(&step) .unwrap_or_else(|e| panic!("{step:?} does not parse back: {e}")); assert_eq!(parsed, (*actor, command.clone()), "round trip: {step:?}"); } // Positive control: the case list must cover every variant. A // shape added to GroundCommand and forgotten here would encode // untested — the failure this test exists to prevent. let shapes: std::collections::BTreeSet = cases .iter() .map(|(_, c)| to_step(Actor::System, c).cmd) .collect(); assert_eq!(shapes.len(), 8, "every GroundCommand variant must appear"); } }