diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index e429948..12b9eea 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -8,6 +8,17 @@ and this project adheres to ## [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 ### Fixed diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index 562d7ed..d787779 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -94,6 +94,13 @@ fn add_player_released() -> bool { 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)> { let mut digits = Vec::with_capacity(8); for position in 0..8 { @@ -236,7 +243,7 @@ impl App { screen: Screen::Loading, return_screen: Screen::Attract, game: None, - game_random_seed: macroquad::rand::rand(), + game_random_seed: initial_game_random_seed(), setting_row: 0, loading_started, loading_until: loading_started + 1.1, diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index cc628c8..edb2730 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -1805,7 +1805,14 @@ impl Game { return false; } 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( old_position, movement_velocity, @@ -1872,17 +1879,27 @@ impl Game { return false; } let mut triggered = false; + let predicted_position = old_position.add(movement_velocity); for sensor in TARGET_SENSORS { if !record_ids.contains(&sensor.id) { 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( old_position, movement_velocity, sensor.center, sensor.radius, ); - let contact_index = usize::from(sensor.id); let entered = touched && !self.trigger_flags[contact_index]; self.trigger_flags[contact_index] = touched; if !entered { @@ -2151,6 +2168,7 @@ impl Game { return; } self.ball_double = BallDoubleState::Inactive; + self.multiball_state = MultiballState::Unavailable; self.special_hole_gate = SpecialHoleGate::Enabled; let player = &mut self.players[self.current_player]; if player.extra_balls > 0 { @@ -2197,6 +2215,7 @@ impl Game { fn special_respawn_pending(&self) -> bool { self.secondary_ball.is_none() && self.score_mode == ScoreMode::Normal + && self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0 && matches!( self.multiball_state, MultiballState::Ready | MultiballState::Active @@ -2629,6 +2648,17 @@ mod tests { 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] fn charged_ball_clears_the_shooter_lane() { for detail in 1..=5 { @@ -2775,6 +2805,7 @@ mod tests { let mut special = Game::new(1); special.ball_double = BallDoubleState::Active; special.multiball_state = MultiballState::Ready; + special.record_contacts[148] = 2; special.drain(&mut Vec::new()); assert_eq!(special.ball_double, BallDoubleState::Active); assert_eq!(special.ball.position, vec2(17.0, 23.0)); @@ -3191,6 +3222,10 @@ mod tests { assert_eq!(game.record_contacts[148], 0); game.drain(&mut events); 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(); game.apply_wall_rule(84, &mut events); @@ -3212,6 +3247,7 @@ mod tests { fn record_two_response_continues_after_special_respawn_like_live_original() { let mut game = Game::new(1); game.multiball_state = MultiballState::Active; + game.record_contacts[148] = 2; game.ball.in_launcher = false; game.ball.position = vec2(157.0, 453.0); 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"); - game.ball.position = vec2(205.0, 70.0); + game.ball.position = vec2(205.0, 62.0); game.check_sensor_objects( MilliVec::from_position(game.ball.position), MilliVec::default(), @@ -4326,6 +4362,47 @@ mod tests { 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] fn magnetic_rectangles_use_old_position_for_entry_and_prediction_for_exit() { let mut game = Game::new_with_seed(1, 7); diff --git a/tdkpin-rs/web/tdkpin-rs.wasm b/tdkpin-rs/web/tdkpin-rs.wasm index ec9d459..b3fec19 100755 Binary files a/tdkpin-rs/web/tdkpin-rs.wasm and b/tdkpin-rs/web/tdkpin-rs.wasm differ