feat(testing): add deterministic mechanics capture

Add named launcher, flipper, and claw scenarios that advance at exact 120 Hz steps with stable seeds. Emit per-step JSON state/event traces and optionally capture the logical 640x460 framebuffer at any requested time or step.

Cover parser behavior, deterministic replay, launcher timing, flipper edges, and complete capture/release/idle cycles for all claw terminals.

Test Plan:
- cargo fmt --check
- cargo test
- cargo clippy --all-targets --all-features -- -D warnings
- cargo run -- --simulate claw-6 --at 0.15 --screenshot /tmp/tdkpin-sim-validation/claw-6-after-flush.png --trace /tmp/tdkpin-sim-validation/claw-6.json
- Inspect both generated 640x460 PNGs and JSON traces
- git diff --cached --check
This commit is contained in:
2026-08-22 18:50:25 +02:00
parent 75c2464e72
commit 16c055eec6
6 changed files with 481 additions and 1 deletions
+393
View File
@@ -0,0 +1,393 @@
use crate::game::{ClawSpriteBank, Controls, Event, Game};
use serde::Serialize;
use std::{
fs,
path::{Path, PathBuf},
str::FromStr,
};
pub const SIMULATION_HZ: u32 = 120;
const SIMULATION_HZ_F64: f64 = 120.0;
const SIMULATION_DT: f32 = 1.0 / 120.0;
const MAX_SIMULATION_STEPS: u64 = 72_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scenario {
Launcher,
Flippers,
Claw1,
Claw6,
Claw7,
Claw18,
}
impl Scenario {
pub const fn name(self) -> &'static str {
match self {
Self::Launcher => "launcher",
Self::Flippers => "flippers",
Self::Claw1 => "claw-1",
Self::Claw6 => "claw-6",
Self::Claw7 => "claw-7",
Self::Claw18 => "claw-18",
}
}
const fn claw_terminal(self) -> Option<u8> {
match self {
Self::Claw1 => Some(1),
Self::Claw6 => Some(6),
Self::Claw7 => Some(7),
Self::Claw18 => Some(18),
Self::Launcher | Self::Flippers => None,
}
}
}
impl FromStr for Scenario {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"launcher" => Ok(Self::Launcher),
"flippers" => Ok(Self::Flippers),
"claw-1" => Ok(Self::Claw1),
"claw-6" => Ok(Self::Claw6),
"claw-7" => Ok(Self::Claw7),
"claw-18" => Ok(Self::Claw18),
_ => Err(format!(
"unknown scenario {value:?}; expected launcher, flippers, claw-1, claw-6, claw-7, or claw-18"
)),
}
}
}
#[derive(Debug, PartialEq)]
pub struct Request {
pub scenario: Scenario,
pub target_step: u64,
pub screenshot: Option<PathBuf>,
pub trace: Option<PathBuf>,
pub seed: u32,
}
impl Request {
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Option<Self>, String> {
let mut arguments = args.into_iter();
let _program = arguments.next();
let Some(first) = arguments.next() else {
return Ok(None);
};
if first != "--simulate" {
return Err(format!("unknown argument {first:?}\n\n{}", usage()));
}
let scenario = arguments
.next()
.ok_or_else(|| format!("--simulate requires a scenario\n\n{}", usage()))?
.parse()?;
let mut request = Self {
scenario,
target_step: u64::from(SIMULATION_HZ),
screenshot: None,
trace: None,
seed: 0x5444_4b50,
};
while let Some(option) = arguments.next() {
let value = arguments
.next()
.ok_or_else(|| format!("{option} requires a value"))?;
match option.as_str() {
"--at" => request.target_step = seconds_to_step(&value)?,
"--step" => {
request.target_step = value
.parse()
.map_err(|_| format!("invalid step {value:?}"))?;
}
"--screenshot" => request.screenshot = Some(PathBuf::from(value)),
"--trace" => request.trace = Some(PathBuf::from(value)),
"--seed" => {
request.seed = value
.parse()
.map_err(|_| format!("invalid seed {value:?}"))?;
}
_ => return Err(format!("unknown simulation option {option:?}")),
}
}
if request.target_step > MAX_SIMULATION_STEPS {
return Err(format!(
"requested step {} exceeds the ten-minute validation limit {MAX_SIMULATION_STEPS}",
request.target_step
));
}
Ok(Some(request))
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn seconds_to_step(value: &str) -> Result<u64, String> {
let seconds: f64 = value
.parse()
.map_err(|_| format!("invalid simulation time {value:?}"))?;
if !seconds.is_finite() || seconds < 0.0 {
return Err("simulation time must be a finite non-negative number".to_owned());
}
if seconds > 600.0 {
return Err("simulation time exceeds the ten-minute validation limit".to_owned());
}
let step = (seconds * SIMULATION_HZ_F64).round();
Ok(step as u64)
}
pub const fn usage() -> &'static str {
"Usage:\n tdkpin-rs\n tdkpin-rs --simulate SCENARIO [--at SECONDS | --step N] [--screenshot FILE.png] [--trace FILE.json] [--seed N]\n\nScenarios: launcher, flippers, claw-1, claw-6, claw-7, claw-18"
}
#[derive(Debug, Serialize, PartialEq)]
pub struct Snapshot {
pub scenario: &'static str,
pub step: u64,
pub seconds: f64,
pub ball: BallSnapshot,
pub claw: ClawSnapshot,
pub flippers: FlipperSnapshot,
pub launcher_charge: f32,
pub events: Vec<String>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct BallSnapshot {
pub x: f32,
pub y: f32,
pub velocity_x: f32,
pub velocity_y: f32,
pub in_launcher: bool,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct ClawSnapshot {
pub active: bool,
pub frame: u8,
pub bank: &'static str,
pub ball_suspended: bool,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct FlipperSnapshot {
pub left_raised: bool,
pub right_raised: bool,
}
pub struct Simulation {
scenario: Scenario,
step: u64,
game: Game,
trace: Vec<Snapshot>,
}
impl Simulation {
pub fn new(scenario: Scenario, seed: u32) -> Self {
let mut game = Game::new_with_seed(1, seed);
let initial_events = scenario
.claw_terminal()
.map_or_else(Vec::new, |terminal| game.begin_claw_scenario(terminal));
let mut simulation = Self {
scenario,
step: 0,
game,
trace: Vec::new(),
};
simulation.record(initial_events);
simulation
}
pub fn advance_to(&mut self, target_step: u64) {
while self.step < target_step {
let controls = controls_for(self.scenario, self.step);
let events = self.game.update(SIMULATION_DT, 3, controls);
self.step += 1;
self.record(events);
}
}
pub fn game(&self) -> &Game {
&self.game
}
pub fn final_snapshot(&self) -> &Snapshot {
self.trace
.last()
.expect("a simulation always records step zero")
}
pub fn write_trace(&self, path: &Path) -> Result<(), String> {
let json = serde_json::to_vec_pretty(&self.trace)
.map_err(|error| format!("could not encode simulation trace: {error}"))?;
fs::write(path, json)
.map_err(|error| format!("could not write trace {}: {error}", path.display()))
}
fn record(&mut self, events: Vec<Event>) {
let step = u32::try_from(self.step).expect("simulation step is limited to 72000");
let claw_bank = match self.game.claw.bank {
ClawSpriteBank::Closing => "closing",
ClawSpriteBank::Opening => "opening",
};
self.trace.push(Snapshot {
scenario: self.scenario.name(),
step: self.step,
seconds: f64::from(step) / SIMULATION_HZ_F64,
ball: BallSnapshot {
x: self.game.ball.position.x,
y: self.game.ball.position.y,
velocity_x: self.game.ball.velocity.x,
velocity_y: self.game.ball.velocity.y,
in_launcher: self.game.ball.in_launcher,
},
claw: ClawSnapshot {
active: self.game.claw.active,
frame: self.game.claw.frame,
bank: claw_bank,
ball_suspended: self.game.claw.ball_suspended,
},
flippers: FlipperSnapshot {
left_raised: self.game.flippers.left_raised,
right_raised: self.game.flippers.right_raised,
},
launcher_charge: self.game.launcher_charge,
events: events
.into_iter()
.map(|event| format!("{event:?}"))
.collect(),
});
}
}
fn controls_for(scenario: Scenario, step: u64) -> Controls {
match scenario {
Scenario::Launcher => Controls {
launch_down: step < u64::from(SIMULATION_HZ),
..Controls::default()
},
Scenario::Flippers => {
let phase = step % u64::from(SIMULATION_HZ);
Controls {
left_flipper: (15..45).contains(&phase),
right_flipper: (60..90).contains(&phase),
..Controls::default()
}
}
Scenario::Claw1 | Scenario::Claw6 | Scenario::Claw7 | Scenario::Claw18 => {
Controls::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
#[test]
fn interactive_run_has_no_simulation_request() {
assert_eq!(Request::parse(args(&["tdkpin-rs"])), Ok(None));
}
#[test]
fn simulation_request_accepts_time_outputs_and_seed() {
let request = Request::parse(args(&[
"tdkpin-rs",
"--simulate",
"claw-7",
"--at",
"0.15",
"--screenshot",
"frame.png",
"--trace",
"trace.json",
"--seed",
"42",
]))
.expect("request should parse")
.expect("simulation should be selected");
assert_eq!(request.scenario, Scenario::Claw7);
assert_eq!(request.target_step, 18);
assert_eq!(request.screenshot, Some(PathBuf::from("frame.png")));
assert_eq!(request.trace, Some(PathBuf::from("trace.json")));
assert_eq!(request.seed, 42);
}
#[test]
fn seeded_simulations_have_identical_step_traces() {
let mut first = Simulation::new(Scenario::Claw6, 7);
let mut second = Simulation::new(Scenario::Claw6, 7);
first.advance_to(90);
second.advance_to(90);
assert_eq!(first.trace, second.trace);
}
#[test]
fn launcher_scenario_holds_for_one_second_then_releases() {
let mut simulation = Simulation::new(Scenario::Launcher, 1);
simulation.advance_to(60);
assert!(simulation.game.ball.in_launcher);
assert!((0.49..=0.51).contains(&simulation.game.launcher_charge));
simulation.advance_to(121);
assert!(!simulation.game.ball.in_launcher);
assert!(simulation.trace[121].events.contains(&"Launch".to_owned()));
}
#[test]
fn flipper_scenario_records_both_movement_edges() {
let mut simulation = Simulation::new(Scenario::Flippers, 1);
simulation.advance_to(46);
assert_eq!(simulation.trace[16].events, ["FlipperMove"]);
assert_eq!(simulation.trace[46].events, ["FlipperMove"]);
assert!(!simulation.game.flippers.left_raised);
}
#[test]
fn every_claw_scenario_completes_all_captures_and_returns_idle() {
for scenario in [
Scenario::Claw1,
Scenario::Claw6,
Scenario::Claw7,
Scenario::Claw18,
] {
let mut simulation = Simulation::new(scenario, 1);
simulation.advance_to(120);
let events: Vec<&str> = simulation
.trace
.iter()
.flat_map(|snapshot| snapshot.events.iter().map(String::as_str))
.collect();
let captures = events
.iter()
.filter(|event| **event == "ClawCapture")
.count();
let releases = events
.iter()
.filter(|event| **event == "ClawRelease")
.count();
assert!(captures >= 1, "{} must enter the claw", scenario.name());
assert_eq!(
captures,
releases,
"{} must release every captured ball",
scenario.name()
);
assert!(!simulation.game.claw.active);
assert!(!simulation.game.claw.ball_suspended);
assert!(simulation.game.ball.position.is_finite());
assert!(simulation.game.ball.velocity.is_finite());
}
}
}