From 0e8121d0271b1623224af8befeb69a4bd7e42ce8 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sat, 10 Oct 2026 11:45:46 +0200 Subject: [PATCH] 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. --- tdkpin-rs/CHANGELOG.md | 3 ++ tdkpin-rs/PARITY_AUDIT.md | 3 ++ tdkpin-rs/src/game.rs | 88 +++++++++++++++++++++++++++++++-------- 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index 12d0cdf..e4176bb 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -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 diff --git a/tdkpin-rs/PARITY_AUDIT.md b/tdkpin-rs/PARITY_AUDIT.md index b4b6d8a..40eef93 100644 --- a/tdkpin-rs/PARITY_AUDIT.md +++ b/tdkpin-rs/PARITY_AUDIT.md @@ -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. diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index 16708ae..5c76211 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -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, @@ -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,