diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index fdb3eba..ade964b 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -8,6 +8,20 @@ and this project adheres to ## [Unreleased] +### Fixed + +- Match capture-driven multiball collapse timing so the surviving slot keeps + double scoring through the current callback, then returns to normal scoring + on the next callback and the returning claw remains paused until that + collapse; completing the fifth wheel lock still ends multiball scoring + immediately. +- Derive wheel-lock awards from the original live contact words so two balls + settling into different slots at once receive the same accumulated award as + the original game. +- Stage an effect-seven reserve-ball request until ball 1's full substep batch + has returned, preventing the new slot from participating in collision and + capture rules before the original creates it. + ## [1.1.0] - 2026-08-29 ### Added diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index f6b9277..3b3c28a 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -592,19 +592,31 @@ 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 { + self.score_mode = ScoreMode::Normal; + } if !self.claw.ball_suspended && !panel_active { let had_secondary_ball = self.secondary_ball.is_some(); + let mut spawned_secondary = None; if had_secondary_ball { self.score_mode = ScoreMode::Multiball; } for _ in 0..substeps { - if self.fixed_update(events) || self.finished || self.claw.ball_suspended { + 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 had_secondary_ball && self.secondary_ball.is_none() { - self.score_mode = ScoreMode::Normal; - } if had_secondary_ball && !self.finished && !self.claw.ball_suspended { if self.secondary_ball.is_some() { for _ in 0..substeps { @@ -626,6 +638,10 @@ impl Game { } } } + if let Some(spawned) = spawned_secondary { + debug_assert!(self.secondary_ball.is_none()); + self.secondary_ball = Some(spawned); + } } self.pending_flipper_edges[0] = @@ -1731,7 +1747,12 @@ impl Game { events: &mut Vec, ) -> BallAction { self.wheel_holes[index] = true; - let filled = self.wheel_holes.iter().filter(|filled| **filled).count(); + // 1000:967a derives the award from all live type-three contact words, + // including another ball that is still settling into a lock hole. + let filled = self.record_contacts[129..=133] + .iter() + .filter(|contact| **contact != 0) + .count(); let shift = u32::try_from(filled.min(5)).unwrap_or(5); let award = 5_000_u32 << shift; let player = &mut self.players[self.current_player]; @@ -1746,6 +1767,7 @@ impl Game { if ball_count == 1 { self.panel_frame = Some(0); } + self.score_mode = ScoreMode::Normal; } events.push(Event::Lock); if ball_count == 1 { @@ -2088,7 +2110,7 @@ impl Game { } fn update_claw(&mut self, dt: f32, events: &mut Vec) { - if !self.claw.active { + if !self.claw.active || self.score_mode == ScoreMode::Multiball { return; } self.claw.frame_accumulator += dt; @@ -3420,15 +3442,71 @@ mod tests { spin: Real48::ZERO, capture_age: 300, }); + game.score_mode = ScoreMode::Multiball; let mut events = Vec::new(); game.advance_secondary_ball(&mut events); assert!(game.secondary_ball.is_none()); + assert_eq!(game.score_mode, ScoreMode::Multiball); assert_eq!(game.ball.capture_age, 17); assert_eq!(game.record_contacts[usize::from(sensor.id)], 2); assert!(game.wheel_holes[0]); assert!(events.contains(&Event::Lock)); + + game.timer_tick(0.0, 0, &mut events); + assert_eq!(game.score_mode, ScoreMode::Normal); + } + + #[test] + fn captured_ball_one_keeps_multiball_scoring_for_ball_two_slot_pass() { + let sensor = LOCK_HOLES[0]; + let mut game = Game::new_with_seed(1, 7); + game.ball.in_launcher = false; + game.ball.position = sensor.center; + game.ball.velocity = Vec2::ZERO; + game.ball.capture_age = 300; + game.record_contacts[usize::from(sensor.id)] = 1; + game.secondary_ball = Some(Ball { + position: EFFECT_SENSOR.center, + velocity: Vec2::ZERO, + in_launcher: false, + spin: Real48::ZERO, + capture_age: 0, + }); + game.target_effect = 1; + game.object_active[usize::from(EFFECT_SENSOR.id)] = true; + game.score_mode = ScoreMode::Multiball; + let mut events = Vec::new(); + + game.timer_tick(0.01, 1, &mut events); + + assert!(game.secondary_ball.is_none()); + assert_eq!(game.player().score, 1_000); + assert_eq!(game.player().secondary_score, 20_000); + assert_eq!(game.score_mode, ScoreMode::Multiball); + + game.timer_tick(0.0, 0, &mut events); + assert_eq!(game.score_mode, ScoreMode::Normal); + } + + #[test] + fn active_multiball_pauses_the_returning_claw_until_slot_collapse() { + 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.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); } #[test] @@ -4529,6 +4607,23 @@ mod tests { ); } + #[test] + fn wheel_award_counts_another_ball_settling_in_a_different_hole() { + let mut game = Game::new(1); + game.record_contacts[129] = 1; + game.record_contacts[130] = 2; + let mut events = Vec::new(); + + assert_eq!( + game.complete_lock_hole(1, 2, &mut events), + BallAction::Remove + ); + + assert_eq!(game.player().secondary_score, 20_000); + assert!(!game.wheel_holes[0]); + assert!(game.wheel_holes[1]); + } + #[test] fn fifth_multiball_lock_defers_panel_and_caps_survivor_reaward() { let mut game = Game::new(1); @@ -4546,6 +4641,7 @@ mod tests { assert_eq!(game.player().secondary_score, 0); assert_eq!(game.player().score_multiplier, 2); assert_eq!(game.panel_frame, None); + assert_eq!(game.score_mode, ScoreMode::Normal); let mut reset_by_special_hole = game.clone(); reset_by_special_hole.reset_ball_to_launcher();