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:
@@ -31,6 +31,22 @@ development packages are also required (X11, OpenGL, and ALSA). Windows needs
|
|||||||
no extra runtime installation; macOS builds with the normal Apple developer
|
no extra runtime installation; macOS builds with the normal Apple developer
|
||||||
command-line tools.
|
command-line tools.
|
||||||
|
|
||||||
|
## Deterministic mechanics validation
|
||||||
|
|
||||||
|
Named scenarios can be advanced without waiting in real time. The simulator
|
||||||
|
uses exact 120 Hz steps, prints its final state as JSON, and can write both the
|
||||||
|
complete step trace and the original-size 640x460 framebuffer:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -- --simulate claw-6 --at 0.15 \
|
||||||
|
--trace /tmp/claw-6.json \
|
||||||
|
--screenshot /tmp/claw-6.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--step N` instead of `--at SECONDS` to reproduce one exact update. The
|
||||||
|
available scenarios are `launcher`, `flippers`, `claw-1`, `claw-6`, `claw-7`,
|
||||||
|
and `claw-18`; `--seed N` fixes random choices for later seeded scenarios.
|
||||||
|
|
||||||
## Original and modern controls
|
## Original and modern controls
|
||||||
|
|
||||||
| Action | Original key | Additional modern key |
|
| Action | Original key | Additional modern key |
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ decoded, build-ready subset; it does not replace that evidence archive.
|
|||||||
- Runtime coverage: the Linux executable was launched through the real window
|
- Runtime coverage: the Linux executable was launched through the real window
|
||||||
backend, the attract screen was inspected, a game was started, a ball was
|
backend, the attract screen was inspected, a game was started, a ball was
|
||||||
launched, collision scoring was observed, and a rendered frame was captured.
|
launched, collision scoring was observed, and a rendered frame was captured.
|
||||||
|
- Deterministic mechanics coverage: named launcher, flipper, and all four claw
|
||||||
|
terminal scenarios advance at exact 120 Hz steps, record JSON state/event
|
||||||
|
traces, and can export the logical 640x460 render target at any requested
|
||||||
|
step for visual inspection.
|
||||||
- Semantic boundary: no claim is made that every trajectory or score tick is
|
- Semantic boundary: no claim is made that every trajectory or score tick is
|
||||||
bit-identical to the 16-bit executable. Remaining numeric inference is listed
|
bit-identical to the 16-bit executable. Remaining numeric inference is listed
|
||||||
above instead of being presented as proven parity.
|
above instead of being presented as proven parity.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crate::{
|
|||||||
table::BUMPERS,
|
table::BUMPERS,
|
||||||
};
|
};
|
||||||
use macroquad::prelude::*;
|
use macroquad::prelude::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
const WIDTH: f32 = 640.0;
|
const WIDTH: f32 = 640.0;
|
||||||
const HEIGHT: f32 = 460.0;
|
const HEIGHT: f32 = 460.0;
|
||||||
@@ -82,6 +83,26 @@ impl App {
|
|||||||
self.present();
|
self.present();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_simulation_game(&mut self, game: Game) {
|
||||||
|
self.game = Some(game);
|
||||||
|
self.screen = Screen::Playing;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_simulation(&self) {
|
||||||
|
self.draw_logical();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> {
|
||||||
|
let path = path
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| format!("screenshot path is not valid UTF-8: {}", path.display()))?;
|
||||||
|
self.render_target
|
||||||
|
.texture
|
||||||
|
.get_texture_data()
|
||||||
|
.export_png(path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_global_input(&mut self) {
|
fn handle_global_input(&mut self) {
|
||||||
if is_key_pressed(KeyCode::F12) {
|
if is_key_pressed(KeyCode::F12) {
|
||||||
self.saved.settings.sounds = !self.saved.settings.sounds;
|
self.saved.settings.sounds = !self.saved.settings.sounds;
|
||||||
|
|||||||
+10
-1
@@ -193,7 +193,7 @@ impl Default for Ball {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Game {
|
pub struct Game {
|
||||||
pub players: Vec<Player>,
|
pub players: Vec<Player>,
|
||||||
pub current_player: usize,
|
pub current_player: usize,
|
||||||
@@ -275,6 +275,15 @@ impl Game {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec<Event> {
|
||||||
|
self.ball.in_launcher = false;
|
||||||
|
self.ball.position = CLAW_TRIGGER_CENTER;
|
||||||
|
self.ball.velocity = Vec2::ZERO;
|
||||||
|
let mut events = Vec::new();
|
||||||
|
self.begin_claw_capture(terminal_frame, &mut events);
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
fn fire_launcher(&mut self) {
|
fn fire_launcher(&mut self) {
|
||||||
let launch_speed = LAUNCH_SPEED_MIN + self.launcher_charge * LAUNCH_SPEED_RANGE;
|
let launch_speed = LAUNCH_SPEED_MIN + self.launcher_charge * LAUNCH_SPEED_RANGE;
|
||||||
self.ball.in_launcher = false;
|
self.ball.in_launcher = false;
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ mod assets;
|
|||||||
mod game;
|
mod game;
|
||||||
mod geometry;
|
mod geometry;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
|
mod simulation;
|
||||||
mod table;
|
mod table;
|
||||||
|
|
||||||
use app::App;
|
use app::App;
|
||||||
use macroquad::prelude::*;
|
use macroquad::prelude::*;
|
||||||
|
use simulation::{Request, Simulation, usage};
|
||||||
|
|
||||||
const WINDOW_WIDTH: i32 = 960;
|
const WINDOW_WIDTH: i32 = 960;
|
||||||
const WINDOW_HEIGHT: i32 = 690;
|
const WINDOW_HEIGHT: i32 = 690;
|
||||||
@@ -24,9 +26,44 @@ fn window_conf() -> Conf {
|
|||||||
|
|
||||||
#[macroquad::main(window_conf)]
|
#[macroquad::main(window_conf)]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
let request = match Request::parse(std::env::args()) {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("{error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(request) = request {
|
||||||
|
if let Err(error) = run_simulation(request).await {
|
||||||
|
eprintln!("{error}\n\n{}", usage());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let mut app = App::load().await;
|
let mut app = App::load().await;
|
||||||
loop {
|
loop {
|
||||||
app.frame();
|
app.frame();
|
||||||
next_frame().await;
|
next_frame().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_simulation(request: Request) -> Result<(), String> {
|
||||||
|
let mut simulation = Simulation::new(request.scenario, request.seed);
|
||||||
|
simulation.advance_to(request.target_step);
|
||||||
|
|
||||||
|
if let Some(path) = &request.trace {
|
||||||
|
simulation.write_trace(path)?;
|
||||||
|
}
|
||||||
|
if let Some(path) = &request.screenshot {
|
||||||
|
let mut app = App::load().await;
|
||||||
|
app.set_simulation_game(simulation.game().clone());
|
||||||
|
app.render_simulation();
|
||||||
|
next_frame().await;
|
||||||
|
app.export_simulation_png(path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_state = serde_json::to_string_pretty(simulation.final_snapshot())
|
||||||
|
.map_err(|error| format!("could not encode final simulation state: {error}"))?;
|
||||||
|
println!("{final_state}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user