diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index ea24b4e..5f04b81 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -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 diff --git a/tdkpin-rs/PARITY_AUDIT.md b/tdkpin-rs/PARITY_AUDIT.md index 2d0d45b..475b0c1 100644 --- a/tdkpin-rs/PARITY_AUDIT.md +++ b/tdkpin-rs/PARITY_AUDIT.md @@ -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. diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index ed92c54..a3972b3 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -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) -> 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) { @@ -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);