diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index e4176bb..a37c2f6 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -34,6 +34,8 @@ and this project adheres to - 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. +- The claw never drops a ball into the shooter lane while more than one ball is + in play, including balls that arrive after the capture began. ## [1.2.1] - 2026-09-01 diff --git a/tdkpin-rs/PARITY_AUDIT.md b/tdkpin-rs/PARITY_AUDIT.md index 40eef93..441350f 100644 --- a/tdkpin-rs/PARITY_AUDIT.md +++ b/tdkpin-rs/PARITY_AUDIT.md @@ -88,6 +88,9 @@ differences follow from treating every ball alike: - 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. +- The claw re-targets a shooter-lane release (terminal frame 18) to a table + release whenever more than one ball is in play during its ride, not only + when the capture began in multiball. - 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 5c76211..261e37e 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -47,6 +47,8 @@ const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010]; const DETAIL_SUBSTEPS: [u8; 5] = [5, 4, 3, 2, 1]; const CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18]; const CLAW_TABLE_TERMINAL_FRAMES: [u8; 3] = [1, 6, 7]; +/// Claw terminal frame that drops the released ball into the shooter lane. +const CLAW_SHOOTER_LANE_FRAME: u8 = 18; const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 380.0; #[allow(clippy::cast_possible_wrap)] @@ -2269,6 +2271,14 @@ impl Game { return; } self.claw.frame_accumulator += dt; + // Never send a ball down the shooter lane while other balls are in + // play, even when more balls arrived after the capture began. + if self.claw.ball_suspended + && self.claw.target_frame == CLAW_SHOOTER_LANE_FRAME + && self.balls.len() > 1 + { + self.claw.target_frame = self.next_claw_terminal_frame(true); + } while self.claw.active && self.claw.frame_accumulator >= self.claw_frame_seconds { self.claw.frame_accumulator -= self.claw_frame_seconds; self.advance_claw(events); @@ -2791,6 +2801,30 @@ mod tests { ); } + #[test] + fn claw_never_drops_into_the_shooter_lane_with_several_balls() { + let mut game = Game::new_with_seed(1, 7); + game.begin_claw_scenario(CLAW_SHOOTER_LANE_FRAME); + assert_eq!(game.claw.target_frame, CLAW_SHOOTER_LANE_FRAME); + // A second ball arrives while the claw is already on its way. + game.balls.push(Ball { + position: vec2(160.0, 300.0), + in_launcher: false, + ..Ball::default() + }); + let mut events = Vec::new(); + for _ in 0..80 { + game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events); + if events.contains(&Event::ClawRelease) { + break; + } + } + assert!(events.contains(&Event::ClawRelease)); + assert!(CLAW_TABLE_TERMINAL_FRAMES.contains(&game.claw.frame)); + assert!(!game.balls[0].in_launcher); + assert!(game.balls[0].position.x < 300.0); + } + #[test] fn player_count_is_bounded() { assert_eq!(Game::new(0).players.len(), 1);