fix(divergence): latch multiball claw capture state

A multiball Greifarm capture could lose its two-ball restriction while the
ball was still being pulled: capture age remains zero during nonstationary
pulling, so a later callback with one remaining ball rewrote the decision.
Represent the capture decision as an explicit optional latch and clear it only
when that entry exits or the claw state resets. When a held secondary ball is
promoted after the primary drains or is captured, migrate record 89 ownership
to ball one as well, so the released ball can clear its contact normally.

Test Plan:
- `just --justfile tdkpin-rs/justfile test` -- passed (149 game/service tests)
- `just --justfile tdkpin-rs/justfile clippy` -- passed
- `just --justfile tdkpin-rs/justfile build-production` -- passed
- `just --justfile tdkpin-rs/justfile web-build` -- passed
- `cargo +nightly fmt --manifest-path tdkpin-rs/Cargo.toml --all -- --check` -- passed
- `rumdl check --flavor commonmark tdkpin-rs/CHANGELOG.md tdkpin-rs/AGENTS.md` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-31 23:11:00 +02:00
parent 25147ad58e
commit 26a2567c91
3 changed files with 43 additions and 19 deletions
+2
View File
@@ -14,6 +14,8 @@ and this project adheres to
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.
- Preserve that entry decision through the complete pull phase and migrate the
Greifarm contact owner when a held secondary ball becomes the primary ball.
## [1.2.0] - 2026-08-31
+41 -19
View File
@@ -143,7 +143,7 @@ pub struct Claw {
pub bank: ClawSpriteBank,
pub ball_suspended: bool,
captured_slot: Option<ClawBallSlot>,
capture_restricted: bool,
capture_restricted: Option<bool>,
target_frame: u8,
frame_accumulator: f32,
}
@@ -156,7 +156,7 @@ impl Default for Claw {
bank: ClawSpriteBank::Opening,
ball_suspended: false,
captured_slot: None,
capture_restricted: false,
capture_restricted: None,
target_frame: 10,
frame_accumulator: 0.0,
}
@@ -979,10 +979,7 @@ 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;
}
self.promote_claw_secondary_to_primary();
return true;
}
}
@@ -1049,7 +1046,7 @@ impl Game {
) {
CaptureStep::Complete => {
let terminal_frame =
self.next_claw_terminal_frame(self.claw.capture_restricted);
self.next_claw_terminal_frame(self.claw.capture_restricted == Some(true));
self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events);
action = BallAction::Suspend;
}
@@ -1599,7 +1596,7 @@ impl Game {
CaptureStep::Holding | CaptureStep::Outside => {}
CaptureStep::Complete => {
let terminal_frame =
self.next_claw_terminal_frame(self.claw.capture_restricted);
self.next_claw_terminal_frame(self.claw.capture_restricted == Some(true));
self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events);
action = BallAction::Suspend;
}
@@ -1663,6 +1660,12 @@ impl Game {
|| predicted_position.y < center.y.wrapping_sub(broadphase_margin)
|| predicted_position.y > center.y.wrapping_add(broadphase_margin)
{
if record_id == 89
&& ball.capture_age == 0
&& self.record_contacts[usize::from(record_id)] == 0
{
self.claw.capture_restricted = None;
}
return CaptureStep::Outside;
}
let dx = center.x.wrapping_sub(current_position.x);
@@ -1680,11 +1683,11 @@ impl Game {
&& contact_allowed
&& !(record_id == 148 && self.special_hole_gate == SpecialHoleGate::Suppressed)
{
if record_id == 89 && ball.capture_age == 0 {
if record_id == 89 && ball.capture_age == 0 && self.claw.capture_restricted.is_none() {
// 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;
self.claw.capture_restricted = Some(ball_count > 1);
}
if ball.capture_age < 300 {
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
@@ -1744,7 +1747,7 @@ impl Game {
self.record_contacts[contact_index] = 0;
ball.capture_age = 0;
if record_id == 89 {
self.claw.capture_restricted = false;
self.claw.capture_restricted = None;
}
}
let velocity = MilliVec::from_velocity_per_second(ball.velocity);
@@ -2123,6 +2126,16 @@ impl Game {
self.claw.ball_suspended && self.claw.captured_slot == Some(slot)
}
fn promote_claw_secondary_to_primary(&mut self) {
if self.claw.captured_slot == Some(ClawBallSlot::Secondary) {
self.claw.captured_slot = Some(ClawBallSlot::Primary);
if self.record_contacts[89] == 2 {
self.record_contacts[89] = 1;
}
self.score_mode = ScoreMode::Normal;
}
}
pub fn primary_ball_suspended(&self) -> bool {
self.claw_holds_slot(ClawBallSlot::Primary)
}
@@ -2169,7 +2182,7 @@ impl Game {
self.claw.bank = ClawSpriteBank::Closing;
self.claw.ball_suspended = true;
self.claw.captured_slot = Some(ClawBallSlot::Primary);
self.claw.capture_restricted = false;
self.claw.capture_restricted = None;
self.claw.frame_accumulator = 0.0;
self.ball.velocity = Vec2::ZERO;
self.ball.spin = Real48::ZERO;
@@ -2244,7 +2257,7 @@ impl Game {
(ball.position, ball.velocity) = release;
} else {
(self.ball.position, self.ball.velocity) = release;
self.claw.captured_slot = Some(ClawBallSlot::Primary);
self.promote_claw_secondary_to_primary();
}
}
None => {
@@ -2291,8 +2304,7 @@ impl Game {
.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;
self.promote_claw_secondary_to_primary();
events.push(Event::Drain);
return;
}
@@ -4501,7 +4513,7 @@ mod tests {
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert!(game.claw.capture_restricted);
assert_eq!(game.claw.capture_restricted, Some(true));
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
@@ -4565,6 +4577,14 @@ mod tests {
);
game.secondary_ball = None;
game.score_mode = ScoreMode::Normal;
game.ball.velocity = vec2(100.0, 0.0);
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(game.claw.capture_restricted, Some(true));
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
@@ -4572,7 +4592,7 @@ mod tests {
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert!(game.claw.capture_restricted);
assert_eq!(game.claw.capture_restricted, Some(true));
assert!(matches!(game.claw.target_frame, 1 | 6 | 7));
}
@@ -4716,10 +4736,12 @@ mod tests {
BallAction::Suspend
);
game.secondary_ball = Some(secondary);
assert_eq!(game.record_contacts[89], 2);
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert!(game.secondary_ball.is_none());
assert!(game.primary_ball_suspended());
assert_eq!(game.record_contacts[89], 1);
assert_eq!(game.score_mode, ScoreMode::Normal);
assert!(events.contains(&Event::Drain));
}
@@ -4762,7 +4784,7 @@ mod tests {
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert!(game.claw.capture_restricted);
assert_eq!(game.claw.capture_restricted, Some(true));
game.secondary_ball = None;
game.score_mode = ScoreMode::Normal;
@@ -4786,7 +4808,7 @@ mod tests {
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert!(!game.claw.capture_restricted);
assert_eq!(game.claw.capture_restricted, Some(false));
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
Binary file not shown.