fix(multiball): clear stale release-ball state

The release-ball effect was inferred from the persistent multiball state,
which remains active while two balls are in play. After the first release this
left the top-left special-hole indicator lit and caused later sprite hits to
arm another release. Use record 148's contact state as the release prerequisite
and show the special-hole indicator only while that pre-release state is ready.

Test Plan:
- `cargo test --workspace --all-targets --all-features` -- passed (135 tests)
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `cargo build --profile production` -- passed
- `git diff --check` -- passed
This commit is contained in:
2026-08-29 09:36:33 +02:00
parent fa3f168467
commit 36f8c38d62
+51 -10
View File
@@ -1421,10 +1421,7 @@ impl Game {
} }
if wall.flags & 0x0004 != 0 { if wall.flags & 0x0004 != 0 {
events.push(Event::Sound(2012)); events.push(Event::Sound(2012));
if matches!( if self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0 {
self.multiball_state,
MultiballState::Ready | MultiballState::Active
) {
self.target_effect = 7; self.target_effect = 7;
} else { } else {
let previous = self.target_effect; let previous = self.target_effect;
@@ -1860,7 +1857,8 @@ impl Game {
self.add_score(transfer, events); self.add_score(transfer, events);
self.players[self.current_player].secondary_score = 0; self.players[self.current_player].secondary_score = 0;
} }
7 if ball_number != 2 7 if self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != ball_number
&& self.secondary_ball.is_none() && self.secondary_ball.is_none()
&& matches!( && matches!(
self.multiball_state, self.multiball_state,
@@ -2026,10 +2024,8 @@ impl Game {
} }
pub fn special_hole_active(&self) -> bool { pub fn special_hole_active(&self) -> bool {
matches!( self.multiball_state == MultiballState::Ready
self.multiball_state, && self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
MultiballState::Ready | MultiballState::Active
)
} }
pub fn player_effect(&self) -> u8 { pub fn player_effect(&self) -> u8 {
@@ -2550,6 +2546,7 @@ mod tests {
game.object_active.fill(false); game.object_active.fill(false);
game.object_active[usize::from(EFFECT_SENSOR.id)] = true; game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.multiball_state = MultiballState::Ready; game.multiball_state = MultiballState::Ready;
game.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] = 2;
game.target_effect = 7; game.target_effect = 7;
game.timer_tick(0.050, 5, &mut Vec::new()); game.timer_tick(0.050, 5, &mut Vec::new());
@@ -3138,7 +3135,7 @@ mod tests {
.secondary_ball .secondary_ball
.expect("effect seven should spawn a second ball"); .expect("effect seven should spawn a second ball");
assert_eq!(game.multiball_state, MultiballState::Active); assert_eq!(game.multiball_state, MultiballState::Active);
assert!(game.special_hole_active()); assert!(!game.special_hole_active());
assert_eq!(spawned.position, vec2(17.0, 23.0)); assert_eq!(spawned.position, vec2(17.0, 23.0));
assert_eq!( assert_eq!(
MilliVec::from_velocity_per_second(spawned.velocity), MilliVec::from_velocity_per_second(spawned.velocity),
@@ -3195,6 +3192,50 @@ mod tests {
assert_eq!(game.special_hole_gate, SpecialHoleGate::Enabled); assert_eq!(game.special_hole_gate, SpecialHoleGate::Enabled);
} }
#[test]
fn release_ball_consumes_the_special_hole_and_does_not_rearm_it() {
let mut game = Game::new_with_seed(1, 7);
let mut events = Vec::new();
game.multiball_state = MultiballState::Ready;
game.record_contacts[148] = 2;
game.apply_wall_rule(84, &mut events);
assert_eq!(game.target_effect, 7);
assert!(game.special_hole_active());
game.ball.in_launcher = false;
game.ball.position = EFFECT_SENSOR.center;
game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
);
assert!(game.secondary_ball.is_some());
assert_eq!(game.target_effect, 0);
assert!(!game.special_hole_active());
for _ in 0..4 {
game.advance_secondary_ball(&mut events);
}
assert_eq!(game.record_contacts[148], 0);
game.drain(&mut events);
assert!(game.secondary_ball.is_none());
events.clear();
game.apply_wall_rule(84, &mut events);
assert!((1..=6).contains(&game.target_effect));
assert!(game.effect_target_active());
game.target_effect = 7;
game.trigger_flags[usize::from(EFFECT_SENSOR.id)] = false;
game.ball.position = EFFECT_SENSOR.center;
game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
);
assert!(game.secondary_ball.is_none());
}
#[test] #[test]
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);