365 lines
13 KiB
Rust
365 lines
13 KiB
Rust
|
|
//! 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");
|
||
|
|
}
|
||
|
|
}
|