test(game): exercise two minutes of autoplay

Add a seeded autoplay scenario that performs a deliberate full-strength charge
for every launcher ball and operates both flippers from live ball position.
Turn the two-minute run into an end-to-end test covering repeated launches,
flipper edges, bumpers, targets, lock holes, claw capture/release, and drains
while rejecting non-finite state.

Document autoplay as the long-run validation entry point and record the expanded
runtime coverage boundary.

Test Plan:
- `cargo test --all-targets` -- 48 passed
- `cargo clippy --all-targets -- -D warnings` -- passed
- `cargo build --profile production` -- passed
- Windows GNU and macOS x86_64 `cargo check` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-22 21:33:29 +02:00
parent cd27fc8404
commit 7d28818e23
3 changed files with 71 additions and 6 deletions
+4 -2
View File
@@ -44,8 +44,10 @@ cargo run -- --simulate claw-6 --at 0.15 \
```
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.
available scenarios are `autoplay`, `launcher`, `flippers`, `claw-1`, `claw-6`,
`claw-7`, and `claw-18`; `--seed N` fixes random choices. `autoplay` charges
each ball and operates the flippers from live ball position for long end-to-end
validation runs.
## Original and modern controls
+4
View File
@@ -66,6 +66,10 @@ decoded, build-ready subset; it does not replace that evidence archive.
terminal scenarios are driven by a 120 Hz validation clock while production
physics accumulates the recovered 100 Hz substep. Traces include the last
collision-object id and can export the logical 640x460 render target.
- End-to-end gameplay coverage: seeded autoplay charges each launcher ball and
operates both flippers from ball position. The two-minute acceptance run
covers repeated launches, both flippers, targets, bumpers, lock holes, a claw
capture/release pair, and drains while checking every state for finite values.
- 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
above instead of being presented as proven parity.
+63 -4
View File
@@ -13,6 +13,7 @@ const MAX_SIMULATION_STEPS: u64 = 72_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scenario {
Autoplay,
Launcher,
Flippers,
Claw1,
@@ -24,6 +25,7 @@ pub enum Scenario {
impl Scenario {
pub const fn name(self) -> &'static str {
match self {
Self::Autoplay => "autoplay",
Self::Launcher => "launcher",
Self::Flippers => "flippers",
Self::Claw1 => "claw-1",
@@ -39,7 +41,7 @@ impl Scenario {
Self::Claw6 => Some(6),
Self::Claw7 => Some(7),
Self::Claw18 => Some(18),
Self::Launcher | Self::Flippers => None,
Self::Autoplay | Self::Launcher | Self::Flippers => None,
}
}
}
@@ -49,6 +51,7 @@ impl FromStr for Scenario {
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"autoplay" => Ok(Self::Autoplay),
"launcher" => Ok(Self::Launcher),
"flippers" => Ok(Self::Flippers),
"claw-1" => Ok(Self::Claw1),
@@ -56,7 +59,7 @@ impl FromStr for Scenario {
"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"
"unknown scenario {value:?}; expected autoplay, launcher, flippers, claw-1, claw-6, claw-7, or claw-18"
)),
}
}
@@ -140,7 +143,7 @@ fn seconds_to_step(value: &str) -> Result<u64, String> {
}
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"
"Usage:\n tdkpin-rs\n tdkpin-rs --simulate SCENARIO [--at SECONDS | --step N] [--screenshot FILE.png] [--trace FILE.json] [--seed N]\n\nScenarios: autoplay, launcher, flippers, claw-1, claw-6, claw-7, claw-18"
}
#[derive(Debug, Serialize, PartialEq)]
@@ -184,6 +187,7 @@ pub struct Simulation {
step: u64,
game: Game,
trace: Vec<Snapshot>,
autoplay_launcher_frames: u32,
}
impl Simulation {
@@ -197,6 +201,7 @@ impl Simulation {
step: 0,
game,
trace: Vec::new(),
autoplay_launcher_frames: 0,
};
simulation.record(initial_events);
simulation
@@ -204,7 +209,11 @@ impl Simulation {
pub fn advance_to(&mut self, target_step: u64) {
while self.step < target_step {
let controls = controls_for(self.scenario, self.step);
let controls = if self.scenario == Scenario::Autoplay {
self.autoplay_controls()
} else {
controls_for(self.scenario, self.step)
};
let events = self.game.update(SIMULATION_DT, 3, controls);
self.step += 1;
self.record(events);
@@ -263,10 +272,31 @@ impl Simulation {
.collect(),
});
}
fn autoplay_controls(&mut self) -> Controls {
if self.game.ball.in_launcher {
let launch_down = self.autoplay_launcher_frames < 132;
self.autoplay_launcher_frames += 1;
Controls {
launch_down,
..Controls::default()
}
} else {
self.autoplay_launcher_frames = 0;
Controls {
left_flipper: self.game.ball.position.y > 340.0
&& self.game.ball.position.x < 175.0,
right_flipper: self.game.ball.position.y > 340.0
&& self.game.ball.position.x > 140.0,
..Controls::default()
}
}
}
}
fn controls_for(scenario: Scenario, step: u64) -> Controls {
match scenario {
Scenario::Autoplay => unreachable!("autoplay controls need mutable simulation state"),
Scenario::Launcher => Controls {
launch_down: step < u64::from(SIMULATION_HZ),
..Controls::default()
@@ -333,6 +363,35 @@ mod tests {
assert_eq!(first.trace, second.trace);
}
#[test]
fn autoplay_exercises_two_minutes_of_complete_gameplay() {
let mut simulation = Simulation::new(Scenario::Autoplay, 7);
simulation.advance_to(u64::from(SIMULATION_HZ) * 120);
let event_count = |name: &str| {
simulation
.trace
.iter()
.flat_map(|snapshot| &snapshot.events)
.filter(|event| event.as_str() == name)
.count()
};
assert!(event_count("Launch") >= 3);
assert!(event_count("FlipperMove") >= 10);
assert!(event_count("Bumper") >= 1);
assert!(event_count("Target") >= 1);
assert!(event_count("Lock") >= 1);
assert!(event_count("ClawCapture") >= 1);
assert_eq!(event_count("ClawCapture"), event_count("ClawRelease"));
assert!(event_count("Drain") >= 1);
assert!(simulation.trace.iter().all(|snapshot| {
snapshot.ball.x.is_finite()
&& snapshot.ball.y.is_finite()
&& snapshot.ball.velocity_x.is_finite()
&& snapshot.ball.velocity_y.is_finite()
}));
}
#[test]
fn launcher_scenario_holds_for_one_second_then_releases() {
let mut simulation = Simulation::new(Scenario::Launcher, 1);