fix(divergence): scope claw latch to captured slot

The Greifarm entry restriction was still shared across both multiball slots.
An unrelated upper-playfield ball could clear the entering ball's latch during
its own record-89 scan, while a shallow aborted entry could retain a stale
choice. Store the latch owner with the capture-time restriction, permit only
that slot to clear it when leaving the deep capture zone, and migrate the owner
when a held secondary ball becomes primary.

Test Plan:
- `just --justfile tdkpin-rs/justfile test` -- passed (151 game, 8 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:27:15 +02:00
parent 26a2567c91
commit 71e5450a84
3 changed files with 161 additions and 25 deletions
+3 -2
View File
@@ -14,8 +14,9 @@ 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.
- Preserve that entry decision through the complete pull phase, scope it to the
entering slot until that ball exits the capture zone, and migrate Greifarm
state when a held secondary ball becomes the primary ball.
## [1.2.0] - 2026-08-31
+158 -23
View File
@@ -136,6 +136,16 @@ enum ClawBallSlot {
Secondary,
}
impl ClawBallSlot {
const fn from_ball_number(ball_number: u16) -> Self {
if ball_number == 2 {
Self::Secondary
} else {
Self::Primary
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Claw {
pub active: bool,
@@ -143,7 +153,7 @@ pub struct Claw {
pub bank: ClawSpriteBank,
pub ball_suspended: bool,
captured_slot: Option<ClawBallSlot>,
capture_restricted: Option<bool>,
capture_entry: Option<(ClawBallSlot, bool)>,
target_frame: u8,
frame_accumulator: f32,
}
@@ -156,7 +166,7 @@ impl Default for Claw {
bank: ClawSpriteBank::Opening,
ball_suspended: false,
captured_slot: None,
capture_restricted: None,
capture_entry: None,
target_frame: 10,
frame_accumulator: 0.0,
}
@@ -1045,8 +1055,8 @@ impl Game {
events,
) {
CaptureStep::Complete => {
let terminal_frame =
self.next_claw_terminal_frame(self.claw.capture_restricted == Some(true));
let terminal_frame = self
.next_claw_terminal_frame(self.claw_capture_restricted_for(ball_number));
self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events);
action = BallAction::Suspend;
}
@@ -1595,8 +1605,8 @@ impl Game {
) {
CaptureStep::Holding | CaptureStep::Outside => {}
CaptureStep::Complete => {
let terminal_frame =
self.next_claw_terminal_frame(self.claw.capture_restricted == Some(true));
let terminal_frame = self
.next_claw_terminal_frame(self.claw_capture_restricted_for(ball_number));
self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events);
action = BallAction::Suspend;
}
@@ -1664,7 +1674,7 @@ impl Game {
&& ball.capture_age == 0
&& self.record_contacts[usize::from(record_id)] == 0
{
self.claw.capture_restricted = None;
self.clear_claw_capture_entry_for(ball_number);
}
return CaptureStep::Outside;
}
@@ -1683,11 +1693,12 @@ impl Game {
&& contact_allowed
&& !(record_id == 148 && self.special_hole_gate == SpecialHoleGate::Suppressed)
{
if record_id == 89 && ball.capture_age == 0 && self.claw.capture_restricted.is_none() {
if record_id == 89 && ball.capture_age == 0 && self.claw.capture_entry.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 = Some(ball_count > 1);
self.claw.capture_entry =
Some((ClawBallSlot::from_ball_number(ball_number), ball_count > 1));
}
if ball.capture_age < 300 {
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
@@ -1743,12 +1754,12 @@ impl Game {
return CaptureStep::Complete;
}
if record_id == 89 && surface_distance >= -11_000 {
self.clear_claw_capture_entry_for(ball_number);
}
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 = None;
}
}
let velocity = MilliVec::from_velocity_per_second(ball.velocity);
if surface_distance <= milli_distance(velocity)
@@ -2126,9 +2137,27 @@ impl Game {
self.claw.ball_suspended && self.claw.captured_slot == Some(slot)
}
fn claw_capture_restricted_for(&self, ball_number: u16) -> bool {
self.claw.capture_entry == Some((ClawBallSlot::from_ball_number(ball_number), true))
}
fn clear_claw_capture_entry_for(&mut self, ball_number: u16) {
let slot = ClawBallSlot::from_ball_number(ball_number);
if self
.claw
.capture_entry
.is_some_and(|(owner, _)| owner == slot)
{
self.claw.capture_entry = None;
}
}
fn promote_claw_secondary_to_primary(&mut self) {
if self.claw.captured_slot == Some(ClawBallSlot::Secondary) {
self.claw.captured_slot = Some(ClawBallSlot::Primary);
if let Some((ClawBallSlot::Secondary, restricted)) = self.claw.capture_entry {
self.claw.capture_entry = Some((ClawBallSlot::Primary, restricted));
}
if self.record_contacts[89] == 2 {
self.record_contacts[89] = 1;
}
@@ -2182,7 +2211,7 @@ impl Game {
self.claw.bank = ClawSpriteBank::Closing;
self.claw.ball_suspended = true;
self.claw.captured_slot = Some(ClawBallSlot::Primary);
self.claw.capture_restricted = None;
self.claw.capture_entry = None;
self.claw.frame_accumulator = 0.0;
self.ball.velocity = Vec2::ZERO;
self.ball.spin = Real48::ZERO;
@@ -2205,11 +2234,7 @@ 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.captured_slot = Some(ClawBallSlot::from_ball_number(ball_number));
self.claw.frame_accumulator = 0.0;
ball.velocity = Vec2::ZERO;
ball.spin = Real48::ZERO;
@@ -4513,7 +4538,7 @@ mod tests {
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(game.claw.capture_restricted, Some(true));
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
@@ -4583,7 +4608,7 @@ mod tests {
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(game.claw.capture_restricted, Some(true));
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
@@ -4592,10 +4617,112 @@ mod tests {
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert_eq!(game.claw.capture_restricted, Some(true));
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
assert!(matches!(game.claw.target_frame, 1 | 6 | 7));
}
#[test]
fn other_multiball_slot_cannot_clear_the_claw_entry_latch() {
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, 100.0),
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(CLAW_TRIGGER_CENTER),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
let mut secondary = game.secondary_ball.take().expect("secondary ball");
let secondary_position = MilliVec::from_position(secondary.position);
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
secondary_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Keep
);
game.secondary_ball = Some(secondary);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
}
#[test]
fn shallow_claw_exit_clears_only_the_owning_entry_latch() {
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 mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(CLAW_TRIGGER_CENTER),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
game.ball.position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0);
game.ball.velocity = Vec2::ZERO;
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, None);
game.secondary_ball = None;
game.score_mode = ScoreMode::Normal;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(100.0, 0.0);
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(CLAW_TRIGGER_CENTER),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(
game.claw.capture_entry,
Some((ClawBallSlot::Primary, false))
);
}
#[test]
fn secondary_multiball_ball_can_be_held_by_the_claw_without_hiding_primary() {
let mut game = Game::new_with_seed(1, 7);
@@ -4737,11 +4864,16 @@ mod tests {
);
game.secondary_ball = Some(secondary);
assert_eq!(game.record_contacts[89], 2);
assert_eq!(
game.claw.capture_entry,
Some((ClawBallSlot::Secondary, true))
);
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.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
assert_eq!(game.score_mode, ScoreMode::Normal);
assert!(events.contains(&Event::Drain));
}
@@ -4784,7 +4916,7 @@ mod tests {
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert_eq!(game.claw.capture_restricted, Some(true));
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
game.secondary_ball = None;
game.score_mode = ScoreMode::Normal;
@@ -4808,7 +4940,10 @@ mod tests {
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(game.claw.capture_restricted, Some(false));
assert_eq!(
game.claw.capture_entry,
Some((ClawBallSlot::Primary, false))
);
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
Binary file not shown.