fix(divergence): release the parked ball during multiball

Bug: with crazy-mode balls on the table, hitting the effect target while
"release ball" (effect seven) was lit did nothing. The ball parked in
the special hole stayed there.

Cause: the release kept the original two-slot guard "only when no
second ball exists" (balls.len() == 1), plus a check that the triggering
ball's slot number differs from the hole's contact word. Both made sense
when the released ball could only ever become slot two, but they block
the release whenever more balls are in play.

Fix:
- Effect seven now releases whenever the special hole actually holds a
  parked ball (special_hole_active: multiball Ready and contact set),
  at most once per callback. The slot-number check is gone; with one
  ball it never mattered, and with more balls it blocked slot two.
- publish_spawned_balls appends the released ball and makes it the
  owner of the special-hole contact under its real slot number, so it
  clears the contact itself as it rolls out. With one ball in play that
  number is 2, identical to the original's contact word.
- While multiball is Active, remove_ball renumbers (or clears) that
  owner when an earlier ball leaves, like the claw and lock-hole owners.
  In Ready the word is the "ball parked" marker and stays untouched.

The single-ball release path is unchanged; all existing special-hole and
effect-seven tests still pass.

Test Plan:
- cargo test release_ball_frees_the_parked_ball_during_multiball: three
  balls in play, release spawns a fourth at the hole, the owner follows
  a removal, and the released ball leaves the hole and clears it.
- Manual: in crazy mode, park a ball in the special hole (top left),
  light release ball, hit the effect target; a new ball rolls out.
This commit is contained in:
ddidderr committed 2026-10-10 11:45:46 +02:00
1 parent a8cc0fc5fc
commit 0e8121d027
3 files changed
+77 -17

No files matched your search

+3
View File
@@ -31,6 +31,9 @@ and this project adheres to
- Free a ball that was settling into a lock hole when another ball filled it,
and release a lock hole whose settling ball left the table.
- The release-ball effect frees the ball parked in the special hole even when
several balls are already in play; the released ball keeps ownership of the
hole until it rolls out.
## [1.2.1] - 2026-09-01
+3
View File
@@ -85,6 +85,9 @@ differences follow from treating every ball alike:
and a lock hole the leaving ball was settling into is freed.
- The rim of a filled lock hole only repels balls from the outside, so a ball
that was settling into it when another ball filled it rolls back out.
- The release-ball effect (effect seven) frees a parked special-hole ball
whenever one is parked, not only when exactly one ball is in play. The
released ball owns the hole's contact under its own slot number.
- High-score entries carry a `crazy` flag that both local storage and the
shared service persist; older entries default to normal scores.
+71 -17
View File
@@ -779,7 +779,22 @@ impl Game {
}
// Effect seven publishes its spawn request during a slot pass, but
// the new ball only joins once every slot pass has returned.
self.balls.append(&mut self.spawned_balls);
self.publish_spawned_balls();
}
/// Adds balls released from the special hole to the end of the slot
/// order. The released ball owns the special-hole contact until it rolls
/// out; with one ball already in play that is slot two, as in the
/// original.
fn publish_spawned_balls(&mut self) {
for ball in std::mem::take(&mut self.spawned_balls) {
if self.balls.len() >= MAX_BALLS {
break;
}
self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] =
u16::try_from(self.balls.len() + 1).unwrap_or(2);
self.balls.push(ball);
}
}
fn update_record_countdowns(&mut self) {
@@ -1195,7 +1210,6 @@ impl Game {
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
let effect_triggered = self.check_effect_sensor(
ball,
pass.number,
old_position,
movement_from_prediction(old_position, predicted),
events,
@@ -1531,7 +1545,7 @@ impl Game {
}
BallAction::Remove => self.remove_ball(0),
}
self.balls.append(&mut self.spawned_balls);
self.publish_spawned_balls();
scan.action
}
@@ -1608,7 +1622,7 @@ impl Game {
) {
action = completed_action;
}
self.check_effect_sensor(ball, pass.number, old_position, movement_velocity, events);
self.check_effect_sensor(ball, old_position, movement_velocity, events);
self.check_target_sensors(ball, old_position, movement_velocity, 150..=152, events);
SensorScanResult { action }
}
@@ -1880,7 +1894,6 @@ impl Game {
fn check_effect_sensor(
&mut self,
ball: &mut Ball,
ball_number: u16,
old_position: MilliVec,
movement_velocity: MilliVec,
events: &mut Vec<Event>,
@@ -1921,16 +1934,9 @@ impl Game {
self.add_score(transfer, events);
self.players[self.current_player].secondary_score = 0;
}
7 if self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)]
!= ball_number
&& self.balls.len() == 1
&& self.spawned_balls.is_empty()
&& matches!(
self.multiball_state,
MultiballState::Ready | MultiballState::Active
) =>
{
// Release the ball parked in the special hole, however
// many balls are already in play.
7 if self.special_hole_active() && self.spawned_balls.is_empty() => {
self.spawned_balls.push(Ball {
position: vec2(17.0, 23.0),
velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(),
@@ -2162,7 +2168,12 @@ impl Game {
if *claw_contact != 99 && usize::from(*claw_contact) > removed_number {
*claw_contact -= 1;
}
for contact in &mut self.record_contacts[129..=133] {
// While multiball is active the special-hole contact names the
// released ball rather than a parked one.
let special_owner = (self.multiball_state == MultiballState::Active)
.then_some(usize::from(SPECIAL_HOLE_SENSOR.id));
for contact_index in (129..=133).chain(special_owner) {
let contact = &mut self.record_contacts[contact_index];
if *contact == 99 || *contact == 0 {
continue;
}
@@ -2736,6 +2747,50 @@ mod tests {
assert!(!finished.player().crazy_mode_used());
}
#[test]
fn release_ball_frees_the_parked_ball_during_multiball() {
let mut game = Game::new_with_seed(1, 7);
game.multiball_state = MultiballState::Ready;
game.record_contacts[148] = 2;
game.target_effect = 7;
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.balls[0].in_launcher = false;
game.balls[0].position = EFFECT_SENSOR.center;
for position in [vec2(250.0, 215.0), vec2(160.0, 300.0)] {
game.balls.push(Ball {
position,
in_launcher: false,
..Ball::default()
});
}
let mut events = Vec::new();
game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
);
assert_eq!(game.balls.len(), 4);
assert_eq!(game.balls[3].position, vec2(17.0, 23.0));
assert_eq!(game.multiball_state, MultiballState::Active);
assert!(!game.special_hole_active());
// The released ball owns the hole, and keeps owning it when an
// earlier ball leaves the table.
assert_eq!(game.record_contacts[148], 4);
game.remove_ball(1);
assert_eq!(game.record_contacts[148], 3);
for _ in 0..30 {
game.update(0.03, 3, Controls::default());
}
assert_eq!(game.record_contacts[148], 0);
assert!(
game.balls
.iter()
.all(|ball| ball.position.distance(vec2(17.0, 23.0)) > 12.0)
);
}
#[test]
fn player_count_is_bounded() {
assert_eq!(Game::new(0).players.len(), 1);
@@ -3381,7 +3436,6 @@ mod tests {
let mut effect_events = Vec::new();
effect_game.check_effect_sensor(
&mut effect_ball,
1,
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut effect_events,