fix(divergence): preserve multiball Greifarm captures
The original port consumed a ball as soon as record 89 completed during multiball, which skipped the Greifarm animation and lost the ball permanently. Keep the captured slot through the full animation instead, latch whether the capture began with two live balls, and exclude the launcher release (frame 18) for that capture. The surviving slot continues physics, scoring, nudging, and flipper responses while the held slot is suspended; drain and promotion paths also preserve the held ball. A later Greifarm entry makes a fresh ball-count decision. Update slot-aware rendering and refresh the browser WASM artifact. 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:
+481
-52
@@ -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<ClawBallSlot>,
|
||||
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<Event>) -> 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<Event>,
|
||||
) {
|
||||
@@ -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<Event>) {
|
||||
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<Event>) {
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user