clay-borg/games/ground/src/record.rs

365 lines
13 KiB
Rust
Raw Normal View History

//! 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<String, serde_yaml::Value> = 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<String>,
) -> 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(),
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<dyn Policy>)
.collect::<Vec<_>>(),
)
.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::<GroundState>(&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::<GroundState>(&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<String> = cases
.iter()
.map(|(_, c)| to_step(Actor::System, c).cmd)
.collect();
assert_eq!(shapes.len(), 8, "every GroundCommand variant must appear");
}
}