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,