fix(divergence): release the parked ball when the last ball in play leaves

Bug: with crazy-mode balls on the table and a ball parked in the special
hole, the parked ball stayed there after every other ball had drained.
It was reported after a tilt, which drains all balls quickly because the
flippers are dead.

Cause: the special respawn only fired when the drained ball was the very
last ball in Game::balls. In crazy mode a ball is often still waiting in
the launcher (crazy mode pressed before launching, or a ball that rolled
back onto the shooter stop). That waiting ball kept the list non-empty,
so each drain simply removed its ball and the parked ball never came out.
Reproduced deterministically: 30/30 seeds ended with only the launcher
ball left and the special hole still armed.

Fix:
- special_respawn_pending(leaving) now asks whether the leaving ball is
  the last one *in play*: every other ball waits in the launcher, and no
  effect-seven ball is about to be published. It no longer looks at the
  score mode; in the original, normal scoring there meant "no second
  ball", which this expresses directly.
- lose_ball: with other balls still in the list, the drained ball is
  removed and, if it was the last one in play, the parked ball is
  released onto the table (same position, velocity, contact clearing,
  and gate suppression as the original respawn). With no other ball it
  respawns in place as before, keeping the original's continuation of
  the drained ball's response.
- A ball that leaves because it came to rest on the shooter stop while
  the launcher was occupied also releases the parked ball if it was the
  last one in play.

Tilt is untouched: it still ends only with the player's ball, and the
released ball plays under it like the original respawned ball.

Test Plan:
- cargo test parked_ball_is_released_when_only_a_waiting_ball_remains:
  a tilted game with a launcher ball and one ball in play; draining it
  releases the parked ball without costing a ball. A second case with
  another ball still in play keeps the parked ball.
- The temporary reproduction (30 tilted crazy-mode games) now releases
  the parked ball in 30/30; a 40-game crazy-mode stress run still
  finishes every game.
This commit is contained in:
ddidderr committed 2026-10-10 12:01:15 +02:00
1 parent 158936577a
commit 27d40504cc
3 files changed
+101 -23

No files matched your search

+3
View File
@@ -43,6 +43,9 @@ and this project adheres to
remaining ball returns it to the launcher as in single-ball play.
- A closed or filled lock hole kicks out a ball that came to rest in its pocket,
so it can no longer stay trapped next to the wheel hub.
- Release the ball parked in the special hole when the last ball in play
leaves, even if another ball is waiting in the launcher (for example after
pressing crazy mode before launching).
## [1.2.1] - 2026-09-01
+4
View File
@@ -100,6 +100,10 @@ differences follow from treating every ball alike:
from the hole center, radially away from the hub. A capture that removes
the last ball of a multiball callback resets it to the launcher like a
single-ball capture instead of ending the ball.
- The special-hole respawn triggers when the last ball *in play* drains; balls
waiting in the launcher do not count. The original checked for exactly one
ball and normal scoring, which is the same thing when only one or two balls
exist.
- High-score entries carry a `crazy` flag that both local storage and the
shared service persist; older entries default to normal scores.
+94 -23
View File
@@ -1047,10 +1047,12 @@ impl Game {
match hit {
Some((2, true)) => {
let special_respawn = self.special_respawn_pending();
// The original continues the drained ball's response on the
// ball respawned in its slot.
let respawn_in_place = self.balls.len() == 1 && self.special_respawn_pending(index);
let response_spin = ball.spin;
let step = self.lose_ball(index, events);
if special_respawn {
if respawn_in_place {
let respawned = &mut self.balls[index];
let origin = MilliVec::from_position(respawned.position);
respawned.position = origin.add(velocity).to_position();
@@ -2269,9 +2271,11 @@ impl Game {
/// unless another ball already occupies it.
fn return_ball_to_launcher(&mut self, index: usize) -> BallStep {
if self.launcher_ball().is_some_and(|waiting| waiting != index) {
self.remove_ball(index);
if self.balls.len() == 1 {
self.score_mode = ScoreMode::Normal;
let special_respawn = self.special_respawn_pending(index);
self.take_ball_out_of_play(index);
if special_respawn {
let released = self.release_parked_ball();
self.balls.push(released);
}
return BallStep::Removed;
}
@@ -2427,31 +2431,49 @@ impl Game {
/// leaves the table; the last ball either takes the special respawn or
/// ends the player's ball.
fn lose_ball(&mut self, index: usize, events: &mut Vec<Event>) -> BallStep {
let special_respawn = self.special_respawn_pending(index);
if self.balls.len() > 1 {
self.remove_ball(index);
if self.balls.len() == 1 {
self.score_mode = ScoreMode::Normal;
}
self.take_ball_out_of_play(index);
events.push(Event::Drain);
if special_respawn {
// Only balls waiting in the launcher are left, so the parked
// ball is released just as for a single drained ball.
let released = self.release_parked_ball();
self.balls.push(released);
}
return BallStep::Removed;
}
if self.special_respawn_pending() {
self.balls[index] = Ball {
position: vec2(17.0, 23.0),
velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
self.multiball_state = MultiballState::Unavailable;
self.record_contacts[148] = 0;
self.special_hole_gate = SpecialHoleGate::Suppressed;
if special_respawn {
self.balls[index] = self.release_parked_ball();
return BallStep::Stop;
}
self.end_ball(events);
BallStep::Stop
}
/// Removes a ball that left play while others remain.
fn take_ball_out_of_play(&mut self, index: usize) {
self.remove_ball(index);
if self.balls.len() == 1 {
self.score_mode = ScoreMode::Normal;
}
}
/// Empties the special hole and returns its ball, rolling out of the
/// hole, ready to be put into play.
fn release_parked_ball(&mut self) -> Ball {
self.multiball_state = MultiballState::Unavailable;
self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] = 0;
self.special_hole_gate = SpecialHoleGate::Suppressed;
Ball {
position: vec2(17.0, 23.0),
velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
}
}
/// Test shim: slot one drains.
#[cfg(test)]
fn drain(&mut self, events: &mut Vec<Event>) {
@@ -2507,9 +2529,16 @@ impl Game {
self.finished = true;
}
fn special_respawn_pending(&self) -> bool {
self.balls.len() == 1
&& self.score_mode == ScoreMode::Normal
/// Whether the ball at `leaving` is the last one in play while a ball is
/// parked in the special hole. Balls waiting in the launcher are not in
/// play; a ball effect seven is about to publish is.
fn special_respawn_pending(&self, leaving: usize) -> bool {
self.spawned_balls.is_empty()
&& self
.balls
.iter()
.enumerate()
.all(|(index, ball)| index == leaving || ball.in_launcher)
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
&& matches!(
self.multiball_state,
@@ -3021,6 +3050,48 @@ mod tests {
}
}
#[test]
fn parked_ball_is_released_when_only_a_waiting_ball_remains() {
let mut game = Game::new_with_seed(1, 7);
game.multiball_state = MultiballState::Ready;
game.record_contacts[148] = 2;
game.tilted = true;
// Ball one waits in the launcher; ball two is the last one in play.
game.balls.push(Ball {
position: vec2(160.0, 300.0),
in_launcher: false,
..Ball::default()
});
let mut events = Vec::new();
assert_eq!(game.lose_ball(1, &mut events), BallStep::Removed);
assert_eq!(game.balls.len(), 2);
assert!(game.balls[0].in_launcher);
assert_eq!(game.balls[1].position, vec2(17.0, 23.0));
assert!(!game.balls[1].in_launcher);
assert_eq!(game.record_contacts[148], 0);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
assert_eq!(game.player().balls, 3);
assert!(game.tilted);
// With another ball still in play the parked ball stays put.
let mut busy = Game::new_with_seed(1, 7);
busy.multiball_state = MultiballState::Ready;
busy.record_contacts[148] = 2;
for position in [vec2(160.0, 300.0), vec2(120.0, 160.0)] {
busy.balls.push(Ball {
position,
in_launcher: false,
..Ball::default()
});
}
busy.lose_ball(1, &mut events);
assert_eq!(busy.balls.len(), 2);
assert_eq!(busy.record_contacts[148], 2);
assert!(busy.special_hole_active());
}
#[test]
fn player_count_is_bounded() {
assert_eq!(Game::new(0).players.len(), 1);