fix(game): restore startup and multiball edge behavior
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s

The interactive build inherited Macroquad's fixed zero random state, making
maximum-power launcher shots repeat across fresh runs. Seed the platform
generator from the startup clock before taking the program-global gameplay
seed. Require record 148's still-live contact before the effect-seven special
respawn and clear active multiball state on an ordinary ball drain, so a
released reserve ball cannot be recreated after both slots are lost.

Type-4 target and effect handlers now honor each record's predicted broadphase
before changing its entry latch. This preserves the latch while another
multiball slot is elsewhere, preventing repeated upper-left corridor scoring.
The regressions and regenerated browser artifact stay with the implementation.

Test Plan:
- `just test` -- passed (137 tests)
- `just clippy` -- passed
- `cargo build --profile production` -- passed
- `just web-build` -- passed
- `cargo +nightly fmt --check` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-29 15:27:28 +02:00
parent a21f01c02d
commit 8fe9431989
4 changed files with 99 additions and 4 deletions
+11
View File
@@ -8,6 +8,17 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Fixed
- Seed the initial gameplay random stream from the startup clock so the first
maximum-power launcher shot is not identical on every run.
- Require an unconsumed record-148 contact before the effect-seven special
respawn, preventing a released reserve ball from being duplicated after
both multiball balls drain.
- Apply type-4 entry latches only inside each target's registered broadphase,
preventing an unrelated multiball slot from clearing an upper-left target's
latch and causing repeated scoring while the ball remains there.
## [1.0.0] - 2026-08-29 ## [1.0.0] - 2026-08-29
### Fixed ### Fixed
+8 -1
View File
@@ -94,6 +94,13 @@ fn add_player_released() -> bool {
ADD_PLAYER_KEYS.into_iter().any(is_key_released) ADD_PLAYER_KEYS.into_iter().any(is_key_released)
} }
fn initial_game_random_seed() -> u32 {
// The original seeds RandSeed from the startup clock. Macroquad's random
// generator starts from a fixed zero state unless the application seeds it.
macroquad::rand::srand(macroquad::miniquad::date::now().to_bits());
macroquad::rand::rand()
}
fn visible_score_digits(mut value: u32) -> Vec<(u8, u8)> { fn visible_score_digits(mut value: u32) -> Vec<(u8, u8)> {
let mut digits = Vec::with_capacity(8); let mut digits = Vec::with_capacity(8);
for position in 0..8 { for position in 0..8 {
@@ -236,7 +243,7 @@ impl App {
screen: Screen::Loading, screen: Screen::Loading,
return_screen: Screen::Attract, return_screen: Screen::Attract,
game: None, game: None,
game_random_seed: macroquad::rand::rand(), game_random_seed: initial_game_random_seed(),
setting_row: 0, setting_row: 0,
loading_started, loading_started,
loading_until: loading_started + 1.1, loading_until: loading_started + 1.1,
+80 -3
View File
@@ -1805,7 +1805,14 @@ impl Game {
return false; return false;
} }
let effect_index = usize::from(EFFECT_SENSOR.id); let effect_index = usize::from(EFFECT_SENSOR.id);
if self.object_active[effect_index] { let predicted_position = old_position.add(movement_velocity);
if self.object_active[effect_index]
&& trigger_broadphase_contains(
predicted_position,
EFFECT_SENSOR.center,
EFFECT_SENSOR.radius,
)
{
let touched = path_intersects_circle( let touched = path_intersects_circle(
old_position, old_position,
movement_velocity, movement_velocity,
@@ -1872,17 +1879,27 @@ impl Game {
return false; return false;
} }
let mut triggered = false; let mut triggered = false;
let predicted_position = old_position.add(movement_velocity);
for sensor in TARGET_SENSORS { for sensor in TARGET_SENSORS {
if !record_ids.contains(&sensor.id) { if !record_ids.contains(&sensor.id) {
continue; continue;
} }
let contact_index = usize::from(sensor.id);
// The original scanner does not call a type-4 handler until the
// predicted point is inside that record's registered bounds. In
// multiball this also prevents an unrelated ball outside the
// corridor from clearing the ball that is currently inside it.
if !self.object_active[contact_index]
|| !trigger_broadphase_contains(predicted_position, sensor.center, sensor.radius)
{
continue;
}
let touched = path_intersects_circle( let touched = path_intersects_circle(
old_position, old_position,
movement_velocity, movement_velocity,
sensor.center, sensor.center,
sensor.radius, sensor.radius,
); );
let contact_index = usize::from(sensor.id);
let entered = touched && !self.trigger_flags[contact_index]; let entered = touched && !self.trigger_flags[contact_index];
self.trigger_flags[contact_index] = touched; self.trigger_flags[contact_index] = touched;
if !entered { if !entered {
@@ -2151,6 +2168,7 @@ impl Game {
return; return;
} }
self.ball_double = BallDoubleState::Inactive; self.ball_double = BallDoubleState::Inactive;
self.multiball_state = MultiballState::Unavailable;
self.special_hole_gate = SpecialHoleGate::Enabled; self.special_hole_gate = SpecialHoleGate::Enabled;
let player = &mut self.players[self.current_player]; let player = &mut self.players[self.current_player];
if player.extra_balls > 0 { if player.extra_balls > 0 {
@@ -2197,6 +2215,7 @@ impl Game {
fn special_respawn_pending(&self) -> bool { fn special_respawn_pending(&self) -> bool {
self.secondary_ball.is_none() self.secondary_ball.is_none()
&& self.score_mode == ScoreMode::Normal && self.score_mode == ScoreMode::Normal
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
&& matches!( && matches!(
self.multiball_state, self.multiball_state,
MultiballState::Ready | MultiballState::Active MultiballState::Ready | MultiballState::Active
@@ -2629,6 +2648,17 @@ mod tests {
assert_eq!(game.launcher_frame(), 10); assert_eq!(game.launcher_frame(), 10);
} }
#[test]
fn maximum_launch_uses_the_initial_random_seed_for_trajectory_variation() {
let mut first = Game::new_with_seed(1, 1);
let mut second = Game::new_with_seed(1, 2);
launch_ball(&mut first, 60);
launch_ball(&mut second, 60);
assert_ne!(first.ball.velocity, second.ball.velocity);
}
#[test] #[test]
fn charged_ball_clears_the_shooter_lane() { fn charged_ball_clears_the_shooter_lane() {
for detail in 1..=5 { for detail in 1..=5 {
@@ -2775,6 +2805,7 @@ mod tests {
let mut special = Game::new(1); let mut special = Game::new(1);
special.ball_double = BallDoubleState::Active; special.ball_double = BallDoubleState::Active;
special.multiball_state = MultiballState::Ready; special.multiball_state = MultiballState::Ready;
special.record_contacts[148] = 2;
special.drain(&mut Vec::new()); special.drain(&mut Vec::new());
assert_eq!(special.ball_double, BallDoubleState::Active); assert_eq!(special.ball_double, BallDoubleState::Active);
assert_eq!(special.ball.position, vec2(17.0, 23.0)); assert_eq!(special.ball.position, vec2(17.0, 23.0));
@@ -3191,6 +3222,10 @@ mod tests {
assert_eq!(game.record_contacts[148], 0); assert_eq!(game.record_contacts[148], 0);
game.drain(&mut events); game.drain(&mut events);
assert!(game.secondary_ball.is_none()); assert!(game.secondary_ball.is_none());
game.drain(&mut events);
assert!(game.ball.in_launcher);
assert_eq!(game.player().balls, 2);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
events.clear(); events.clear();
game.apply_wall_rule(84, &mut events); game.apply_wall_rule(84, &mut events);
@@ -3212,6 +3247,7 @@ mod tests {
fn record_two_response_continues_after_special_respawn_like_live_original() { fn record_two_response_continues_after_special_respawn_like_live_original() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.multiball_state = MultiballState::Active; game.multiball_state = MultiballState::Active;
game.record_contacts[148] = 2;
game.ball.in_launcher = false; game.ball.in_launcher = false;
game.ball.position = vec2(157.0, 453.0); game.ball.position = vec2(157.0, 453.0);
game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second(); game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second();
@@ -4311,7 +4347,7 @@ mod tests {
); );
assert_eq!(game.player().score, 500, "contact must score only once"); assert_eq!(game.player().score, 500, "contact must score only once");
game.ball.position = vec2(205.0, 70.0); game.ball.position = vec2(205.0, 62.0);
game.check_sensor_objects( game.check_sensor_objects(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.ball.position),
MilliVec::default(), MilliVec::default(),
@@ -4326,6 +4362,47 @@ mod tests {
assert_eq!(game.player().score, 1_000); assert_eq!(game.player().score, 1_000);
} }
#[test]
fn type_four_latch_is_not_cleared_by_a_ball_outside_its_broadphase() {
let sensor = TARGET_SENSORS
.into_iter()
.find(|sensor| sensor.id == 140)
.expect("record 140 must be present");
let mut game = Game::new(1);
let mut primary = game.ball;
primary.position = sensor.center;
let mut events = Vec::new();
game.check_target_sensors(
&mut primary,
MilliVec::from_position(sensor.center),
MilliVec::default(),
140..=147,
&mut events,
);
assert_eq!(game.player().score, sensor.score);
let mut unrelated = primary;
unrelated.position = vec2(200.0, 200.0);
let unrelated_position = MilliVec::from_position(unrelated.position);
game.check_target_sensors(
&mut unrelated,
unrelated_position,
MilliVec::default(),
140..=147,
&mut events,
);
game.check_target_sensors(
&mut primary,
MilliVec::from_position(sensor.center),
MilliVec::default(),
140..=147,
&mut events,
);
assert_eq!(game.player().score, sensor.score);
}
#[test] #[test]
fn magnetic_rectangles_use_old_position_for_entry_and_prediction_for_exit() { fn magnetic_rectangles_use_old_position_for_entry_and_prediction_for_exit() {
let mut game = Game::new_with_seed(1, 7); let mut game = Game::new_with_seed(1, 7);
Binary file not shown.