diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index 80780cc..e5363b2 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to ## [Unreleased] +### Fixed + +- Keep a ball captured by the Greifarm during multiball instead of consuming it; + latch the two-ball capture rule at each entry, exclude the launcher release + while that capture is active, and let later single-ball re-entries use all + four original release choices. + ## [1.2.0] - 2026-08-31 ### Added diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index 58341a1..4f9d320 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -901,7 +901,7 @@ impl App { ); } } - if !game.claw.ball_suspended { + if !game.primary_ball_suspended() { draw_texture( &self.assets.ball, game.ball.position.x - 8.0, @@ -909,7 +909,9 @@ impl App { WHITE, ); } - if let Some(ball) = game.secondary_ball { + if let Some(ball) = game.secondary_ball + && !game.secondary_ball_suspended() + { draw_texture( &self.assets.ball, ball.position.x - 8.0, diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index ef80267..8988eae 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -46,6 +46,7 @@ const CLAW_FRAME_SECONDS: f32 = 0.030; 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]; const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 380.0; #[allow(clippy::cast_possible_wrap)] @@ -129,12 +130,20 @@ pub enum ClawSpriteBank { Opening, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ClawBallSlot { + Primary, + Secondary, +} + #[derive(Clone, Copy, Debug)] pub struct Claw { pub active: bool, pub frame: u8, pub bank: ClawSpriteBank, pub ball_suspended: bool, + captured_slot: Option, + capture_restricted: bool, target_frame: u8, frame_accumulator: f32, } @@ -146,6 +155,8 @@ impl Default for Claw { frame: 10, bank: ClawSpriteBank::Opening, ball_suspended: false, + captured_slot: None, + capture_restricted: false, target_frame: 10, frame_accumulator: 0.0, } @@ -592,39 +603,46 @@ impl Game { self.update_target_rotation(events); } let panel_active = self.update_panel_completion(events); - // The original collapses an inactive two-ball slot immediately before - // the next physics pass. Capture removal does not clear multiball - // scoring until that collapse, so the surviving slot retains double - // scoring for the remainder of the callback in which it was captured. - if self.secondary_ball.is_none() && self.score_mode == ScoreMode::Multiball { + // A held claw ball still counts as the second live ball until the + // other slot disappears or the claw releases it. + if self.secondary_ball.is_none() + && self.claw.captured_slot != Some(ClawBallSlot::Secondary) + && self.score_mode == ScoreMode::Multiball + { self.score_mode = ScoreMode::Normal; } - if !self.claw.ball_suspended && !panel_active { + if !panel_active { let had_secondary_ball = self.secondary_ball.is_some(); let mut spawned_secondary = None; + let primary_held = self.claw_holds_slot(ClawBallSlot::Primary); + let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary); if had_secondary_ball { self.score_mode = ScoreMode::Multiball; } - for _ in 0..substeps { - let stop = self.fixed_update(events); - // Effect seven publishes its spawn request during record 149, - // but the original timer does not create slot two until the - // complete slot-one simulation pass has returned. - if !had_secondary_ball && spawned_secondary.is_none() { - spawned_secondary = self.secondary_ball.take(); - } - if stop || self.finished || self.claw.ball_suspended { - break; + if !primary_held { + for _ in 0..substeps { + let stop = self.fixed_update(events); + // Effect seven publishes its spawn request during record 149, + // but the original timer does not create slot two until the + // complete slot-one simulation pass has returned. + if !had_secondary_ball && spawned_secondary.is_none() { + spawned_secondary = self.secondary_ball.take(); + } + if stop || self.finished || self.claw_holds_slot(ClawBallSlot::Primary) { + break; + } } } - if had_secondary_ball && !self.finished && !self.claw.ball_suspended { - if self.secondary_ball.is_some() { + if had_secondary_ball && !self.finished { + if self.secondary_ball.is_some() && !self.claw_holds_slot(ClawBallSlot::Secondary) { for _ in 0..substeps { - if self.advance_secondary_ball(events) { + if self.advance_secondary_ball_slot(events, !primary_held) { break; } } - } else { + } else if !self.claw_holds_slot(ClawBallSlot::Primary) + && !self.claw_holds_slot(ClawBallSlot::Secondary) + { self.secondary_ball = Some(self.ball); for _ in 0..substeps { if self.advance_secondary_ball_slot(events, false) { @@ -642,6 +660,10 @@ impl Game { debug_assert!(self.secondary_ball.is_none()); self.secondary_ball = Some(spawned); } + + // The two slots are mutually exclusive while the claw is holding + // a ball. + debug_assert!(!(primary_held && secondary_held)); } self.pending_flipper_edges[0] = @@ -759,6 +781,8 @@ impl Game { } Nudge::None => return, } + let primary_held = self.claw_holds_slot(ClawBallSlot::Primary); + let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary); if let Some(secondary) = &mut self.secondary_ball { let slot_impulse = match nudge { Nudge::Left | Nudge::Right => MilliVec { @@ -778,12 +802,16 @@ impl Game { let mut primary_slot = MilliVec::from_velocity_per_second(self.ball.velocity); primary_slot.x = primary_slot.x.wrapping_add(slot_impulse.x); primary_slot.y = primary_slot.y.wrapping_add(slot_impulse.y); - self.ball.velocity = primary_slot.to_velocity_per_second(); + if !primary_held { + self.ball.velocity = primary_slot.to_velocity_per_second(); + } let mut secondary_slot = MilliVec::from_velocity_per_second(secondary.velocity); secondary_slot.x = secondary_slot.x.wrapping_add(slot_impulse.x); secondary_slot.y = secondary_slot.y.wrapping_add(slot_impulse.y); - secondary.velocity = secondary_slot.to_velocity_per_second(); - } else { + if !secondary_held { + secondary.velocity = secondary_slot.to_velocity_per_second(); + } + } else if !primary_held { self.ball.velocity = velocity.to_velocity_per_second(); } @@ -832,8 +860,13 @@ impl Game { ); let mut best_collision = best_static .map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin))); + let primary_held = self.claw_holds_slot(ClawBallSlot::Primary); + let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary); let mut transferred_secondary_velocity = None; - if let Some(secondary) = initial_secondary { + if let Some(secondary) = initial_secondary + && !primary_held + && !secondary_held + { let other_position = MilliVec::from_position(secondary.position); let response = ball_collision_response( old_position, @@ -876,6 +909,8 @@ impl Game { if object_id == 175 && let (Some(secondary), Some(transferred)) = (&mut self.secondary_ball, transferred_secondary_velocity) + && !secondary_held + && !primary_held { secondary.velocity = transferred.to_velocity_per_second(); } @@ -944,6 +979,10 @@ impl Game { .secondary_ball .take() .expect("a multiball capture must leave the other slot active"); + if self.claw.captured_slot == Some(ClawBallSlot::Secondary) { + self.claw.captured_slot = Some(ClawBallSlot::Primary); + self.score_mode = ScoreMode::Normal; + } return true; } } @@ -1008,12 +1047,12 @@ impl Game { &mut capture_candidate, events, ) { - CaptureStep::Complete if ball_count == 1 => { - let terminal_frame = self.next_claw_terminal_frame(); - self.begin_claw_capture_after_hold(ball, terminal_frame, events); + CaptureStep::Complete => { + let terminal_frame = + self.next_claw_terminal_frame(self.claw.capture_restricted); + self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events); action = BallAction::Suspend; } - CaptureStep::Complete => action = BallAction::Remove, CaptureStep::Outside | CaptureStep::Holding => {} } *velocity = MilliVec::from_velocity_per_second(ball.velocity); @@ -1227,8 +1266,9 @@ impl Game { ) } + #[cfg(test)] fn advance_secondary_ball(&mut self, events: &mut Vec) -> bool { - self.advance_secondary_ball_slot(events, true) + self.advance_secondary_ball_slot(events, !self.claw_holds_slot(ClawBallSlot::Primary)) } #[allow(clippy::too_many_lines)] @@ -1267,6 +1307,8 @@ impl Game { let mut transferred_primary_velocity = None; let other_position = MilliVec::from_position(primary_position); if primary_slot_active + && !self.claw_holds_slot(ClawBallSlot::Primary) + && !self.claw_holds_slot(ClawBallSlot::Secondary) && predicted.x >= other_position.x.wrapping_sub(21_000) && predicted.x <= other_position.x.wrapping_add(21_000) && predicted.y >= other_position.y.wrapping_sub(21_000) @@ -1556,13 +1598,10 @@ impl Game { ) { CaptureStep::Holding | CaptureStep::Outside => {} CaptureStep::Complete => { - if ball_count == 1 { - let terminal_frame = self.next_claw_terminal_frame(); - self.begin_claw_capture_after_hold(ball, terminal_frame, events); - action = BallAction::Suspend; - } else { - action = BallAction::Remove; - } + let terminal_frame = + self.next_claw_terminal_frame(self.claw.capture_restricted); + self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events); + action = BallAction::Suspend; } } } @@ -1595,7 +1634,11 @@ impl Game { SensorScanResult { action } } - #[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)] + #[allow( + clippy::cast_possible_truncation, + clippy::too_many_arguments, + clippy::too_many_lines + )] fn capture_record_step( &mut self, ball: &mut Ball, @@ -1637,6 +1680,12 @@ impl Game { && contact_allowed && !(record_id == 148 && self.special_hole_gate == SpecialHoleGate::Suppressed) { + if record_id == 89 && ball.capture_age == 0 { + // Latch the restriction when the ball first enters the deep + // capture zone, before the pull/hold phase can outlive the + // other active ball. + self.claw.capture_restricted = ball_count > 1; + } if ball.capture_age < 300 { let mut velocity = MilliVec::from_velocity_per_second(ball.velocity); let stationary = center.x.wrapping_sub(previous_position.x).wrapping_abs() < 500 @@ -1679,16 +1728,24 @@ impl Game { return CaptureStep::Complete; } ball.capture_age = 0; - self.record_contacts[contact_index] = if record_id == 148 { 2 } else { 99 }; - if ball_count > 1 { - self.record_contacts[contact_index] = 2; - } + self.record_contacts[contact_index] = if record_id == 89 && ball_count > 1 { + // Keep ownership with the held slot so its later release can + // clear the claw contact before a fresh entry. + ball_number + } else if record_id == 148 || ball_count > 1 { + 2 + } else { + 99 + }; return CaptureStep::Complete; } if surface_distance > -11_000 && contact == ball_number { self.record_contacts[contact_index] = 0; ball.capture_age = 0; + if record_id == 89 { + self.claw.capture_restricted = false; + } } let velocity = MilliVec::from_velocity_per_second(ball.velocity); if surface_distance <= milli_distance(velocity) @@ -2052,8 +2109,26 @@ impl Game { self.target_effect.min(7) } - fn next_claw_terminal_frame(&mut self) -> u8 { - CLAW_TERMINAL_FRAMES[usize::from(self.random.below(4))] + fn next_claw_terminal_frame(&mut self, restricted: bool) -> u8 { + if restricted { + // Frame 18 sends the released ball down the shooter lane. During + // a capture that began with two live balls, keep it on the table. + CLAW_TABLE_TERMINAL_FRAMES[usize::from(self.random.below(3))] + } else { + CLAW_TERMINAL_FRAMES[usize::from(self.random.below(4))] + } + } + + fn claw_holds_slot(&self, slot: ClawBallSlot) -> bool { + self.claw.ball_suspended && self.claw.captured_slot == Some(slot) + } + + pub fn primary_ball_suspended(&self) -> bool { + self.claw_holds_slot(ClawBallSlot::Primary) + } + + pub fn secondary_ball_suspended(&self) -> bool { + self.claw_holds_slot(ClawBallSlot::Secondary) } fn apply_flipper_kicks(&mut self) { @@ -2062,6 +2137,8 @@ impl Game { if self.ball.in_launcher || self.tilted { return; } + let primary_held = self.claw_holds_slot(ClawBallSlot::Primary); + let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary); for (delta, side) in [ (i32::from(pending[0]), FlipperSide::Left), @@ -2070,8 +2147,12 @@ impl Game { if delta == 0 { continue; } - apply_flipper_response_to_ball(&mut self.ball, delta, side); - if let Some(secondary) = &mut self.secondary_ball { + if !primary_held { + apply_flipper_response_to_ball(&mut self.ball, delta, side); + } + if let Some(secondary) = &mut self.secondary_ball + && !secondary_held + { apply_flipper_response_to_ball(secondary, delta, side); } } @@ -2087,6 +2168,8 @@ impl Game { self.claw.target_frame = terminal_frame; self.claw.bank = ClawSpriteBank::Closing; self.claw.ball_suspended = true; + self.claw.captured_slot = Some(ClawBallSlot::Primary); + self.claw.capture_restricted = false; self.claw.frame_accumulator = 0.0; self.ball.velocity = Vec2::ZERO; self.ball.spin = Real48::ZERO; @@ -2097,6 +2180,7 @@ impl Game { fn begin_claw_capture_after_hold( &mut self, ball: &mut Ball, + ball_number: u16, terminal_frame: u8, events: &mut Vec, ) { @@ -2108,6 +2192,11 @@ impl Game { self.claw.target_frame = terminal_frame; self.claw.bank = ClawSpriteBank::Closing; self.claw.ball_suspended = true; + self.claw.captured_slot = Some(if ball_number == 2 { + ClawBallSlot::Secondary + } else { + ClawBallSlot::Primary + }); self.claw.frame_accumulator = 0.0; ball.velocity = Vec2::ZERO; ball.spin = Real48::ZERO; @@ -2115,7 +2204,7 @@ impl Game { } fn update_claw(&mut self, dt: f32, events: &mut Vec) { - if !self.claw.active || self.score_mode == ScoreMode::Multiball { + if !self.claw.active { return; } self.claw.frame_accumulator += dt; @@ -2145,7 +2234,24 @@ impl Game { let release_frame = self.claw.frame; self.claw.target_frame = 10; self.claw.bank = ClawSpriteBank::Opening; - (self.ball.position, self.ball.velocity) = claw_release(release_frame); + let release = claw_release(release_frame); + match self.claw.captured_slot { + Some(ClawBallSlot::Primary) => { + (self.ball.position, self.ball.velocity) = release; + } + Some(ClawBallSlot::Secondary) => { + if let Some(ball) = &mut self.secondary_ball { + (ball.position, ball.velocity) = release; + } else { + (self.ball.position, self.ball.velocity) = release; + self.claw.captured_slot = Some(ClawBallSlot::Primary); + } + } + None => { + (self.ball.position, self.ball.velocity) = release; + self.claw.captured_slot = Some(ClawBallSlot::Primary); + } + } self.claw.ball_suspended = false; events.push(Event::ClawRelease); events.push(Event::Sound(2016)); @@ -2180,6 +2286,16 @@ impl Game { } fn drain(&mut self, events: &mut Vec) { + if self.claw_holds_slot(ClawBallSlot::Secondary) { + self.ball = self + .secondary_ball + .take() + .expect("the claw-held secondary ball must remain in slot two"); + self.claw.captured_slot = Some(ClawBallSlot::Primary); + self.score_mode = ScoreMode::Normal; + events.push(Event::Drain); + return; + } if let Some(remaining_ball) = self.secondary_ball.take() { self.ball = remaining_ball; self.score_mode = ScoreMode::Normal; @@ -3496,20 +3612,18 @@ mod tests { } #[test] - fn active_multiball_pauses_the_returning_claw_until_slot_collapse() { + fn active_multiball_advances_the_returning_claw_while_both_balls_live() { let mut game = Game::new(1); game.claw.active = true; game.claw.frame = 1; game.claw.target_frame = 10; game.claw.bank = ClawSpriteBank::Opening; game.claw.frame_accumulator = 0.0; + game.claw.ball_suspended = true; + game.claw.captured_slot = Some(ClawBallSlot::Primary); game.score_mode = ScoreMode::Multiball; let mut events = Vec::new(); - game.update_claw(CLAW_FRAME_SECONDS, &mut events); - assert_eq!(game.claw.frame, 1); - - game.score_mode = ScoreMode::Normal; game.update_claw(CLAW_FRAME_SECONDS, &mut events); assert_eq!(game.claw.frame, 2); } @@ -4365,6 +4479,321 @@ mod tests { assert!(!game.claw.active); } + #[test] + fn multiball_claw_capture_keeps_the_ball_and_uses_only_table_releases() { + let mut game = Game::new_with_seed(1, 7); + game.ball.in_launcher = false; + game.ball.position = CLAW_TRIGGER_CENTER; + game.ball.velocity = Vec2::ZERO; + game.secondary_ball = Some(Ball { + position: vec2(210.0, 240.0), + velocity: vec2(12.0, -34.0), + in_launcher: false, + spin: Real48::ZERO, + capture_age: 0, + }); + game.object_active.fill(false); + game.score_mode = ScoreMode::Multiball; + let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER); + let mut events = Vec::new(); + + assert_eq!( + game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), + BallAction::Keep + ); + assert!(game.claw.capture_restricted); + + assert_eq!( + game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), + BallAction::Suspend + ); + assert!(game.claw.active); + assert!(game.primary_ball_suspended()); + assert!(!game.secondary_ball_suspended()); + assert!(matches!(game.claw.target_frame, 1 | 6 | 7)); + assert_eq!(game.record_contacts[89], 1); + + let survivor_before = game.secondary_ball.expect("survivor must remain").position; + game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events); + assert_ne!( + game.secondary_ball.expect("survivor must remain").position, + survivor_before + ); + + while game.claw.active { + game.update_claw(CLAW_FRAME_SECONDS, &mut events); + } + assert!(events.contains(&Event::ClawRelease)); + assert!(!game.primary_ball_suspended()); + assert!(game.secondary_ball.is_some()); + + game.ball.position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0); + game.ball.velocity = Vec2::ZERO; + game.ball.capture_age = 0; + assert_eq!( + game.check_sensor_objects( + MilliVec::from_position(game.ball.position), + MilliVec::default(), + &mut events, + ), + BallAction::Keep + ); + assert_eq!(game.record_contacts[89], 0); + } + + #[test] + fn multiball_claw_restriction_is_latched_before_the_other_ball_drains() { + let mut game = Game::new_with_seed(1, 7); + game.ball.in_launcher = false; + game.ball.position = CLAW_TRIGGER_CENTER; + game.ball.velocity = vec2(100.0, 0.0); + game.secondary_ball = Some(Ball { + position: vec2(210.0, 240.0), + velocity: Vec2::ZERO, + in_launcher: false, + spin: Real48::ZERO, + capture_age: 0, + }); + game.object_active.fill(false); + game.score_mode = ScoreMode::Multiball; + let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER); + let mut events = Vec::new(); + + assert_eq!( + game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), + BallAction::Keep + ); + game.secondary_ball = None; + game.score_mode = ScoreMode::Normal; + game.ball.velocity = Vec2::ZERO; + game.ball.capture_age = 300; + + assert_eq!( + game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), + BallAction::Suspend + ); + assert!(game.claw.capture_restricted); + assert!(matches!(game.claw.target_frame, 1 | 6 | 7)); + } + + #[test] + fn secondary_multiball_ball_can_be_held_by_the_claw_without_hiding_primary() { + let mut game = Game::new_with_seed(1, 7); + game.ball.in_launcher = false; + game.ball.position = vec2(210.0, 240.0); + game.ball.velocity = Vec2::ZERO; + game.secondary_ball = Some(Ball { + position: CLAW_TRIGGER_CENTER, + velocity: Vec2::ZERO, + in_launcher: false, + spin: Real48::ZERO, + capture_age: 0, + }); + game.object_active.fill(false); + game.score_mode = ScoreMode::Multiball; + let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER); + let mut events = Vec::new(); + let mut secondary = game.secondary_ball.take().expect("secondary ball"); + + assert_eq!( + game.check_sensor_objects_for_ball( + &mut secondary, + 2, + 2, + claw_position, + MilliVec::default(), + &mut events, + ) + .action, + BallAction::Keep + ); + game.secondary_ball = Some(secondary); + let mut secondary = game.secondary_ball.take().expect("secondary ball"); + assert_eq!( + game.check_sensor_objects_for_ball( + &mut secondary, + 2, + 2, + claw_position, + MilliVec::default(), + &mut events, + ) + .action, + BallAction::Suspend + ); + game.secondary_ball = Some(secondary); + + assert!(!game.primary_ball_suspended()); + assert!(game.secondary_ball_suspended()); + assert!(matches!(game.claw.target_frame, 1 | 6 | 7)); + let primary_before = game.ball.position; + game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events); + assert_ne!(game.ball.position, primary_before); + } + + #[test] + fn held_claw_ball_survives_the_other_ball_draining() { + let mut game = Game::new_with_seed(1, 7); + game.ball.in_launcher = false; + game.ball.position = CLAW_TRIGGER_CENTER; + game.ball.velocity = Vec2::ZERO; + game.secondary_ball = Some(Ball { + position: vec2(340.0, 100.0), + velocity: vec2(1.0, 0.0), + in_launcher: false, + spin: Real48::ZERO, + capture_age: 0, + }); + game.object_active.fill(false); + game.score_mode = ScoreMode::Multiball; + let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER); + let mut events = Vec::new(); + assert_eq!( + game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), + BallAction::Keep + ); + assert_eq!( + game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), + BallAction::Suspend + ); + let balls_before = game.player().balls; + + game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events); + assert!(game.secondary_ball.is_none()); + assert_eq!(game.player().balls, balls_before); + assert_eq!(game.score_mode, ScoreMode::Normal); + assert!(game.claw.ball_suspended); + + while game.claw.ball_suspended { + game.update_claw(CLAW_FRAME_SECONDS, &mut events); + } + assert!(events.contains(&Event::ClawRelease)); + assert!(!game.ball.in_launcher); + } + + #[test] + fn held_secondary_claw_ball_is_promoted_if_primary_drains() { + let mut game = Game::new_with_seed(1, 7); + game.ball.in_launcher = false; + game.ball.position = vec2(340.0, 100.0); + game.ball.velocity = vec2(1.0, 0.0); + game.secondary_ball = Some(Ball { + position: CLAW_TRIGGER_CENTER, + velocity: Vec2::ZERO, + in_launcher: false, + spin: Real48::ZERO, + capture_age: 0, + }); + game.object_active.fill(false); + game.score_mode = ScoreMode::Multiball; + let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER); + let mut events = Vec::new(); + let mut secondary = game.secondary_ball.take().expect("secondary ball"); + assert_eq!( + game.check_sensor_objects_for_ball( + &mut secondary, + 2, + 2, + claw_position, + MilliVec::default(), + &mut events, + ) + .action, + BallAction::Keep + ); + game.secondary_ball = Some(secondary); + let mut secondary = game.secondary_ball.take().expect("secondary ball"); + assert_eq!( + game.check_sensor_objects_for_ball( + &mut secondary, + 2, + 2, + claw_position, + MilliVec::default(), + &mut events, + ) + .action, + BallAction::Suspend + ); + game.secondary_ball = Some(secondary); + + game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events); + assert!(game.secondary_ball.is_none()); + assert!(game.primary_ball_suspended()); + assert_eq!(game.score_mode, ScoreMode::Normal); + assert!(events.contains(&Event::Drain)); + } + + #[test] + fn unrestricted_claw_capture_retains_the_launcher_release_choice() { + let mut saw_launcher_release = false; + for seed in 0..64 { + let mut game = Game::new_with_seed(1, seed); + if game.next_claw_terminal_frame(false) == 18 { + saw_launcher_release = true; + break; + } + } + assert!(saw_launcher_release); + } + + #[test] + fn claw_reentry_after_multiball_uses_the_current_ball_count() { + let mut game = Game::new_with_seed(1, 7); + game.ball.in_launcher = false; + game.ball.position = CLAW_TRIGGER_CENTER; + game.ball.velocity = Vec2::ZERO; + game.secondary_ball = Some(Ball { + position: vec2(210.0, 240.0), + velocity: Vec2::ZERO, + in_launcher: false, + spin: Real48::ZERO, + capture_age: 0, + }); + game.object_active.fill(false); + game.score_mode = ScoreMode::Multiball; + let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER); + let mut events = Vec::new(); + assert_eq!( + game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), + BallAction::Keep + ); + assert_eq!( + game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), + BallAction::Suspend + ); + assert!(game.claw.capture_restricted); + + game.secondary_ball = None; + game.score_mode = ScoreMode::Normal; + while game.claw.active { + game.update_claw(CLAW_FRAME_SECONDS, &mut events); + } + + let unrestricted_seed = (0..64) + .find(|seed| { + let mut candidate = Game::new_with_seed(1, *seed); + candidate.next_claw_terminal_frame(false) == 18 + }) + .expect("a seed must select the launcher release"); + game.random = BorlandRandom::new(unrestricted_seed); + game.ball.position = CLAW_TRIGGER_CENTER; + game.ball.velocity = Vec2::ZERO; + game.ball.capture_age = 0; + game.record_contacts[89] = 0; + + assert_eq!( + game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), + BallAction::Keep + ); + assert!(!game.claw.capture_restricted); + assert_eq!( + game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), + BallAction::Suspend + ); + assert_eq!(game.claw.target_frame, 18); + } + #[test] fn claw_frame_cadence_follows_the_five_original_timer_choices() { for (detail, frame_seconds) in DETAIL_TIMER_SECONDS.into_iter().enumerate() { diff --git a/tdkpin-rs/web/tdkpin-rs.wasm b/tdkpin-rs/web/tdkpin-rs.wasm index 8d1d9c6..bad9928 100755 Binary files a/tdkpin-rs/web/tdkpin-rs.wasm and b/tdkpin-rs/web/tdkpin-rs.wasm differ