test(divergence): cover converging claw approaches

A follow-up review questioned whether both multiball slots could enter the
Greifarm deep zone before either established capture ownership. The zone is
less than 16 pixels wide while ball contact begins at 17 pixels, so two
non-overlapping balls cannot fit there together. Document that invariant and
exercise the real sequential timer from cardinal approaches at two distances
and speeds, asserting that every multiball-latched capture retains its entry
owner.

Test Plan:
- broader diagnostic sweep -- passed (574 captures, no owner mismatch)
- `just --justfile tdkpin-rs/justfile test` -- passed (152 game, 8 service tests)
- `just --justfile tdkpin-rs/justfile clippy` -- passed
- `cargo +nightly fmt --manifest-path tdkpin-rs/Cargo.toml --all -- --check` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-09-01 04:16:05 +02:00
parent 71e5450a84
commit d37d327a02
+79
View File
@@ -153,6 +153,8 @@ pub struct Claw {
pub bank: ClawSpriteBank, pub bank: ClawSpriteBank,
pub ball_suspended: bool, pub ball_suspended: bool,
captured_slot: Option<ClawBallSlot>, captured_slot: Option<ClawBallSlot>,
// The deep zone is less than 16 px wide while ball contact is 17 px, so
// only one physically non-overlapping ball can own a pending entry.
capture_entry: Option<(ClawBallSlot, bool)>, capture_entry: Option<(ClawBallSlot, bool)>,
target_frame: u8, target_frame: u8,
frame_accumulator: f32, frame_accumulator: f32,
@@ -4723,6 +4725,83 @@ mod tests {
); );
} }
#[test]
fn nonoverlapping_two_ball_claw_approaches_keep_capture_ownership() {
// Exercise the sequential solver from non-overlapping cardinal
// approaches around the sub-8 px deep zone. Ball contact begins at
// 17 px, so a second ball must be resolved before both can settle.
let capture_center = CLAW_TRIGGER_CENTER - vec2(0.0, 2.0);
let directions = [
vec2(1.0, 0.0),
vec2(0.0, 1.0),
vec2(-1.0, 0.0),
vec2(0.0, -1.0),
];
let radii = [10.0, 18.0];
let speeds = [100.0, 300.0];
let mut captures = 0;
for first_direction in directions {
for second_direction in directions {
for first_radius in radii {
for second_radius in radii {
let first_position = capture_center + first_direction * first_radius;
let second_position = capture_center + second_direction * second_radius;
if first_position.distance(second_position) < 17.0 {
continue;
}
for first_speed in speeds {
for second_speed in speeds {
let mut game = Game::new_with_seed(1, 7);
game.ball = Ball {
position: first_position,
velocity: (capture_center - first_position).normalize()
* first_speed,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
game.secondary_ball = Some(Ball {
position: second_position,
velocity: (capture_center - second_position).normalize()
* second_speed,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
for _ in 0..100 {
game.timer_tick(0.01, 1, &mut events);
if game.claw.active {
let Some((owner, true)) = game.claw.capture_entry else {
break;
};
captures += 1;
assert_eq!(
Some(owner),
game.claw.captured_slot,
"positions {first_position:?}/{second_position:?}, speeds {first_speed}/{second_speed}"
);
break;
}
if game.secondary_ball.is_none() || game.finished {
break;
}
}
}
}
}
}
}
}
assert!(captures > 0, "the sweep must exercise claw captures");
}
#[test] #[test]
fn secondary_multiball_ball_can_be_held_by_the_claw_without_hiding_primary() { fn secondary_multiball_ball_can_be_held_by_the_claw_without_hiding_primary() {
let mut game = Game::new_with_seed(1, 7); let mut game = Game::new_with_seed(1, 7);