use macroquad::prelude::{Rect, Vec2, vec2}; use crate::{ borland_random::BorlandRandom, flipper_physics::{FlipperSide, moving_flipper_response}, geometry::Segment, original_physics::{ CollisionMaterial, CollisionResponse, GRAVITY_MILLI_PER_STEP, MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec, StaticCollisionCandidate, ball_collision_response, capture_collision_candidate, circle_collision_candidate_at, line_collision_candidate_at, milli_distance, path_intersects_circle, }, real48::Real48, table::{ BUMPERS, EFFECT_SENSOR, LOCK_HOLES, PASSIVE_CIRCLES, SPECIAL_HOLE_SENSOR, TARGET_SENSORS, WALLS, }, }; const LEFT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(133.0, 377.0); const RIGHT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(181.0, 376.0); const LAUNCHER_POSITION: Vec2 = Vec2::new(325.0, 413.0); const LAUNCHER_PRESS_IMPULSE_MILLI: i32 = 750; const LAUNCHER_RELEASE_IMPULSE_MILLI: i32 = 1_500; const LAUNCHER_REPEAT_DELAY_SECONDS: f32 = 0.650; const LAUNCHER_REPEAT_SECONDS: f32 = 0.040; const CLAW_TRIGGER_CENTER: Vec2 = Vec2::new(289.0, 94.0); const CLAW_TRIGGER_RADIUS: f32 = 19.0; // The default original timer fires every 30 ms and advances the claw by one // frame. Keeping that cadence independent of render rate makes captures // deterministic on modern machines. 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)] const fn score_reaches_marker(score: u32, threshold: u32) -> bool { // 1000:bc36 compares signed high words and, when equal, unsigned low // words. That is exactly a signed 32-bit comparison of the bit patterns. (score as i32) >= (threshold as i32) } #[derive(Clone, Copy, Debug, Default)] pub struct Controls { pub left_flipper: bool, pub right_flipper: bool, pub launch_down: bool, pub nudge: Nudge, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Nudge { #[default] None, Left, Right, Center, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Event { FlipperMove, Launch, Bumper, Target, Wheel, ClawCapture, ClawRelease, Lock, Media, ExtraBall, Nudge, Tilt, Drain, HighScoreCandidate(u32), StopSound, Sound(u16), } impl Event { /// Explicit original Win16 WAVE resource selected at this control-flow site. pub const fn sound_resource(self) -> Option { match self { Self::Sound(resource) => Some(resource), Self::FlipperMove | Self::Launch | Self::Bumper | Self::Target | Self::Wheel | Self::ClawCapture | Self::ClawRelease | Self::Lock | Self::Media | Self::ExtraBall | Self::Nudge | Self::Tilt | Self::Drain | Self::HighScoreCandidate(_) | Self::StopSound => None, } } } #[derive(Clone, Copy, Debug, Default)] pub struct Flippers { pub left_raised: bool, pub right_raised: bool, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ClawSpriteBank { Closing, #[default] Opening, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ClawBallSlot { Primary, 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, pub frame: u8, pub bank: ClawSpriteBank, pub ball_suspended: bool, captured_slot: Option, capture_entry: Option<(ClawBallSlot, bool)>, target_frame: u8, frame_accumulator: f32, } impl Default for Claw { fn default() -> Self { Self { active: false, frame: 10, bank: ClawSpriteBank::Opening, ball_suspended: false, captured_slot: None, capture_entry: None, target_frame: 10, frame_accumulator: 0.0, } } } impl Claw { pub fn sprite_source(self) -> Option { if !self.active { return None; } let mut column_frame = self.frame; let mut source_y = match self.bank { ClawSpriteBank::Closing => 0.0, ClawSpriteBank::Opening => 148.0, }; if column_frame > 9 { column_frame -= 9; source_y += 74.0; } Some(Rect::new( f32::from(column_frame - 1) * 100.0, source_y, 100.0, 74.0, )) } } #[derive(Clone, Debug)] pub struct Player { pub score: u32, pub secondary_score: u32, pub score_multiplier: u8, pub balls: u8, pub extra_balls: u8, pub bumper_value: u32, pub diamond_segments: u8, pub media_level: u8, rules: RuleState, } impl Default for Player { fn default() -> Self { Self { score: 0, secondary_score: 0, score_multiplier: 1, balls: 3, extra_balls: 0, bumper_value: 1_000, diamond_segments: 0, media_level: 0, rules: RuleState::default(), } } } #[derive(Clone, Copy, Debug)] pub struct Ball { pub position: Vec2, pub velocity: Vec2, pub in_launcher: bool, spin: Real48, capture_age: i32, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PlayerEntry { Open, Closed, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum MultiballState { #[default] Unavailable, Ready, Active, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum ScoreMode { #[default] Normal, Multiball, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum BallDoubleState { #[default] Inactive, Active, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum SpecialHoleGate { #[default] Enabled, Suppressed, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum CaptureStep { Outside, Holding, Complete, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum BallAction { Keep, Suspend, Reset, Remove, Finish, } #[derive(Clone, Copy, Debug)] struct SensorScanResult { action: BallAction, } fn retain_first_collision( best: &mut Option<(u8, bool, StaticCollisionCandidate)>, candidate: (u8, bool, StaticCollisionCandidate), ) { if best.is_none() { *best = Some(candidate); } } const fn predicted_outside_board(position: MilliVec) -> bool { position.x <= 0 || position.x > 340_000 || position.y <= 0 || position.y > 460_000 } fn trigger_broadphase_contains(predicted: MilliVec, center: Vec2, radius: f32) -> bool { let center = MilliVec::from_position(center); let margin = MilliVec::from_position(vec2(radius + 5.0, 0.0)).x; predicted.x >= center.x.wrapping_sub(margin) && predicted.x <= center.x.wrapping_add(margin) && predicted.y >= center.y.wrapping_sub(margin) && predicted.y <= center.y.wrapping_add(margin) } const fn movement_from_prediction(old_position: MilliVec, predicted: MilliVec) -> MilliVec { MilliVec { x: predicted.x.wrapping_sub(old_position.x), y: predicted.y.wrapping_sub(old_position.y), } } #[derive(Clone, Copy, Debug)] struct RuleState { wheel_holes: [bool; 5], top_targets: [bool; 3], record_contacts: [u16; 176], object_active: [bool; 176], target_effect: u8, multiball_state: MultiballState, } impl Default for RuleState { fn default() -> Self { Self { wheel_holes: [false; 5], top_targets: [false; 3], record_contacts: [0; 176], object_active: initial_object_activity(), target_effect: 0, multiball_state: MultiballState::Unavailable, } } } fn initial_object_activity() -> [bool; 176] { let mut active = [true; 176]; active[0] = false; for object_id in [6, 87, 88, 149, 153, 154, 175] { active[object_id] = false; } active } impl Default for Ball { fn default() -> Self { Self { position: LAUNCHER_POSITION, velocity: Vec2::ZERO, in_launcher: true, spin: Real48::ZERO, capture_age: 0, } } } #[derive(Clone, Debug)] pub struct Game { pub players: Vec, pub current_player: usize, pub ball: Ball, pub secondary_ball: Option, pub wheel_holes: [bool; 5], pub top_targets: [bool; 3], pub tilted: bool, pub flippers: Flippers, flipper_inputs: Flippers, pub claw: Claw, pub target_rotation_state: Option, pub panel_frame: Option, pub launcher_charge: f32, pub finished: bool, pub last_collision_id: Option, accumulator: f32, record_countdowns: [u8; 176], tilt_counter: u16, launcher_was_down: bool, launcher_hold_seconds: f32, launcher_next_repeat: f32, launcher_velocity_milli: i32, player_entry: PlayerEntry, random: BorlandRandom, pending_flipper_edges: [i8; 2], flipper_release_latch: [bool; 2], record_contacts: [u16; 176], trigger_flags: [bool; 176], object_active: [bool; 176], target_effect: u8, claw_frame_seconds: f32, multiball_state: MultiballState, special_hole_gate: SpecialHoleGate, score_mode: ScoreMode, ball_double: BallDoubleState, } impl Game { #[cfg(not(target_arch = "wasm32"))] pub fn new(player_count: usize) -> Self { Self::new_with_seed(player_count, macroquad::rand::rand()) } pub fn new_with_seed(player_count: usize, seed: u32) -> Self { Self { players: vec![Player::default(); player_count.clamp(1, 4)], current_player: 0, ball: Ball::default(), secondary_ball: None, wheel_holes: [false; 5], top_targets: [false; 3], tilted: false, flippers: Flippers::default(), flipper_inputs: Flippers::default(), claw: Claw::default(), target_rotation_state: None, panel_frame: None, launcher_charge: 0.0, finished: false, last_collision_id: None, accumulator: 0.0, record_countdowns: [0; 176], tilt_counter: 0, launcher_was_down: false, launcher_hold_seconds: 0.0, launcher_next_repeat: LAUNCHER_REPEAT_DELAY_SECONDS, launcher_velocity_milli: 0, player_entry: PlayerEntry::Open, random: BorlandRandom::new(seed), pending_flipper_edges: [0; 2], flipper_release_latch: [false; 2], record_contacts: [0; 176], trigger_flags: [false; 176], object_active: initial_object_activity(), target_effect: 0, claw_frame_seconds: CLAW_FRAME_SECONDS, multiball_state: MultiballState::Unavailable, special_hole_gate: SpecialHoleGate::Enabled, score_mode: ScoreMode::Normal, ball_double: BallDoubleState::Inactive, } } pub fn player(&self) -> &Player { &self.players[self.current_player] } pub fn random_seed(&self) -> u32 { self.random.seed() } pub fn secondary_score_display(&self) -> u32 { self.player() .secondary_score .wrapping_mul(u32::from(self.player().score_multiplier)) } pub fn launcher_frame(&self) -> usize { if !self.launcher_was_down { return 0; } (1..=10) .find(|multiple| { -LAUNCHER_PRESS_IMPULSE_MILLI * i32::try_from(*multiple).unwrap_or(10) < self.launcher_velocity_milli }) .unwrap_or(10) } pub fn add_player_before_launch(&mut self) -> bool { if self.player_entry == PlayerEntry::Closed || self.players.len() >= 4 { return false; } self.players.push(Player::default()); true } #[cfg(not(target_arch = "wasm32"))] pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec { self.ball.in_launcher = false; self.ball.position = CLAW_TRIGGER_CENTER; self.ball.velocity = Vec2::ZERO; let mut events = Vec::new(); self.begin_claw_capture(terminal_frame, &mut events); events } #[cfg(not(target_arch = "wasm32"))] pub(crate) fn begin_effect_scenario(&mut self, effect: u8) { self.target_effect = effect.min(7); self.object_active[usize::from(EFFECT_SENSOR.id)] = self.target_effect != 0; if self.target_effect == 7 { self.multiball_state = MultiballState::Ready; self.record_contacts[148] = 2; } } #[cfg(not(target_arch = "wasm32"))] pub(crate) fn begin_target_scenario(&mut self) { self.ball.in_launcher = false; self.ball.position = vec2(170.0, 230.0); self.players[0].bumper_value = 6_000; self.wheel_holes = [true, false, true, false, false]; self.target_rotation_state = Some(0); self.object_active[90] = false; self.object_active[109] = false; } fn fire_launcher(&mut self) { self.launcher_velocity_milli = self .launcher_velocity_milli .wrapping_sub(LAUNCHER_RELEASE_IMPULSE_MILLI); let mut launch_velocity = self.launcher_velocity_milli; if launch_velocity < -MAXIMUM_SPEED_MILLI_PER_STEP { let variation = self.random.below(3_800) / 40; launch_velocity = -MAXIMUM_SPEED_MILLI_PER_STEP + i32::from(variation); } else if launch_velocity > -2_280 { launch_velocity = launch_velocity.wrapping_sub(i32::from(self.random.below(3_800) / 40)); } self.ball.in_launcher = false; self.ball.velocity = MilliVec { x: 0, y: launch_velocity, } .to_velocity_per_second(); self.launcher_charge = 0.0; self.launcher_was_down = false; self.launcher_hold_seconds = 0.0; self.launcher_next_repeat = LAUNCHER_REPEAT_DELAY_SECONDS; self.launcher_velocity_milli = 0; self.player_entry = PlayerEntry::Closed; } pub fn update(&mut self, frame_time: f32, detail: u8, controls: Controls) -> Vec { if self.finished { return Vec::new(); } self.last_collision_id = None; self.claw_frame_seconds = DETAIL_TIMER_SECONDS[usize::from(detail.clamp(1, 5) - 1)]; let mut events = Vec::new(); if !controls.left_flipper { self.flipper_release_latch[0] = false; } else if self.tilted { self.flipper_release_latch[0] = true; } if !controls.right_flipper { self.flipper_release_latch[1] = false; } else if self.tilted { self.flipper_release_latch[1] = true; } self.flipper_inputs.left_raised = controls.left_flipper && !self.tilted && !self.flipper_release_latch[0]; self.flipper_inputs.right_raised = controls.right_flipper && !self.tilted && !self.flipper_release_latch[1]; // The original key handlers keep accepting the launcher key while // tilted; Tilt disables flippers and scoring, but must not strand a // ball that is still waiting in the shooter lane. if self.ball.in_launcher { if controls.launch_down { if self.launcher_was_down { self.launcher_hold_seconds += frame_time.min(0.05); while self.launcher_hold_seconds >= self.launcher_next_repeat { self.launcher_velocity_milli = self .launcher_velocity_milli .wrapping_sub(LAUNCHER_PRESS_IMPULSE_MILLI); self.launcher_next_repeat += LAUNCHER_REPEAT_SECONDS; } } else { self.launcher_velocity_milli = self .launcher_velocity_milli .wrapping_sub(LAUNCHER_PRESS_IMPULSE_MILLI); self.launcher_was_down = true; } let charge_milli = (-self.launcher_velocity_milli).clamp(0, MAXIMUM_SPEED_MILLI_PER_STEP); self.launcher_charge = f32::from(i16::try_from(charge_milli).unwrap_or(3_800)) / f32::from(3_800_i16); } else if self.launcher_was_down { self.fire_launcher(); events.push(Event::Launch); if !self.tilted { events.push(Event::Sound(2002)); } } } if !self.tilted && controls.nudge != Nudge::None { self.apply_nudge(controls.nudge, &mut events); } self.accumulator = (self.accumulator + frame_time.min(0.05)).min(0.1); let detail_index = usize::from(detail.clamp(1, 5) - 1); let timer_interval = DETAIL_TIMER_SECONDS[detail_index]; while self.accumulator >= timer_interval { self.timer_tick(timer_interval, DETAIL_SUBSTEPS[detail_index], &mut events); self.accumulator -= timer_interval; } events } fn timer_tick(&mut self, elapsed: f32, substeps: u8, events: &mut Vec) { self.update_claw(elapsed, events); if !self.ball.in_launcher { self.update_record_countdowns(); self.update_target_rotation(events); } let panel_active = self.update_panel_completion(events); // 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 !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; } 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 { if self.secondary_ball.is_some() && !self.claw_holds_slot(ClawBallSlot::Secondary) { for _ in 0..substeps { if self.advance_secondary_ball_slot(events, !primary_held) { break; } } } 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) { break; } } if let Some(survivor) = self.secondary_ball.take() { self.ball = survivor; } else { self.drain(events); } } } if let Some(spawned) = spawned_secondary { 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] = match (self.flipper_inputs.left_raised, self.flippers.left_raised) { (true, false) => -1, (false, true) => 1, _ => 0, }; self.pending_flipper_edges[1] = match (self.flipper_inputs.right_raised, self.flippers.right_raised) { (true, false) => -1, (false, true) => 1, _ => 0, }; self.flippers = self.flipper_inputs; for edge in self.pending_flipper_edges { if edge != 0 { events.push(Event::FlipperMove); events.push(Event::Sound(2021)); } } self.apply_flipper_kicks(); if self.tilt_counter != 0 { self.tilt_counter -= 1; } } fn update_record_countdowns(&mut self) { for countdown in &mut self.record_countdowns[1..] { if *countdown != 0 { *countdown -= 1; } } } pub fn record_countdown(&self, record_id: usize) -> u8 { self.record_countdowns[record_id] } fn update_target_rotation(&mut self, events: &mut Vec) { let Some(state) = self.target_rotation_state else { return; }; if state >= 6 { self.target_rotation_state = None; return; } let next = state + 1; self.target_rotation_state = Some(next); if next == 1 { events.push(Event::Sound(2011)); } if next == 6 { self.wheel_holes.rotate_left(1); let first = self.record_contacts[129]; for record_id in 129..133 { self.record_contacts[record_id] = self.record_contacts[record_id + 1]; } self.record_contacts[133] = first; } } fn update_panel_completion(&mut self, events: &mut Vec) -> bool { let Some(frame) = self.panel_frame else { return false; }; if frame >= 281 { self.panel_frame = None; self.wheel_holes.fill(false); return true; } if frame == 1 { for record_id in 129..=133 { self.record_contacts[record_id] = 0; } } let next = frame + 1; self.panel_frame = Some(next); match next { 1 | 66 | 159 | 238 => events.push(Event::Sound(2013)), 45 | 129 | 222 => events.push(Event::StopSound), 58 | 230 => events.push(Event::Sound(2012)), _ => {} } true } #[allow(clippy::cast_possible_truncation)] fn apply_nudge(&mut self, nudge: Nudge, events: &mut Vec) { const SCALAR: i32 = 15; events.push(Event::Sound(2019)); let mut velocity = MilliVec::from_velocity_per_second(self.ball.velocity); match nudge { Nudge::Left | Nudge::Right => { let amount = (50 - i32::from(self.random.below(20))) * SCALAR; let signed = if nudge == Nudge::Left { -amount } else { amount }; velocity.x = velocity.x.wrapping_add(signed); } Nudge::Center => { let horizontal = Real48::from_i32(SCALAR) .multiply( self.random .real48() .multiply(Real48::from_i32(20)) .subtract(Real48::from_i32(10)), ) .round_i32(); let vertical = -(60 - i32::from(self.random.below(20))) * SCALAR; velocity.x = velocity.x.wrapping_add(horizontal); velocity.y = velocity.y.wrapping_add(vertical); } 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 { x: if nudge == Nudge::Left { -600 } else { 600 }, y: 0, }, Nudge::Center => { let horizontal = (i32::from(self.random.below(21)) - 10) * SCALAR; let vertical = -(i32::from(self.random.below(100)) + 50) * SCALAR; MilliVec { x: horizontal, y: vertical, } } Nudge::None => MilliVec::default(), }; 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); 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); if !secondary_held { secondary.velocity = secondary_slot.to_velocity_per_second(); } } else if !primary_held { self.ball.velocity = velocity.to_velocity_per_second(); } self.tilt_counter = self.tilt_counter.wrapping_add(25); let threshold = self.random.below(10) + 30; if threshold < self.tilt_counter { self.tilted = true; self.flipper_release_latch[0] |= self.flipper_inputs.left_raised; self.flipper_release_latch[1] |= self.flipper_inputs.right_raised; self.flipper_inputs = Flippers::default(); self.pending_flipper_edges.fill(0); events.push(Event::Tilt); events.push(Event::Sound(2020)); } else { events.push(Event::Nudge); } } #[allow(clippy::too_many_lines)] fn fixed_update(&mut self, events: &mut Vec) -> bool { if self.ball.in_launcher { self.ball.position = LAUNCHER_POSITION; self.ball.velocity = Vec2::ZERO; return true; } let old_position = MilliVec::from_position(self.ball.position); let initial_secondary = self.secondary_ball; let ball_count = if initial_secondary.is_some() { 2 } else { 1 }; let mut ball = self.ball; let mut velocity = MilliVec::from_velocity_per_second(ball.velocity); velocity.y += GRAVITY_MILLI_PER_STEP; velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); let speed_before_collision = milli_distance(velocity); if predicted_outside_board(old_position.add(velocity)) { self.drain(events); return true; } let (best_static, scan, predicted) = self.scan_ordered_records_for_ball( &mut ball, 1, ball_count, old_position, &mut velocity, events, ); 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 && !primary_held && !secondary_held { let other_position = MilliVec::from_position(secondary.position); let response = ball_collision_response( old_position, velocity, ball.spin, other_position, MilliVec::from_velocity_per_second(secondary.velocity), secondary.capture_age <= 0, ); if 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) && predicted.y <= other_position.y.wrapping_add(21_000) && let Some(response) = response && best_collision.is_none() { transferred_secondary_velocity = Some(response.other_velocity); best_collision = Some(( 175, false, CollisionResponse { surface_distance: response.surface_distance, velocity: response.moving_velocity, spin: response.moving_spin, auxiliary_fired: false, }, )); } } let collided = best_collision.is_some(); if collided { ball.capture_age = 0; } let mut auxiliary_fired = false; let (hit_wall, hit_circle) = if let Some((object_id, is_wall, response)) = best_collision { velocity = response.velocity; ball.spin = response.spin; auxiliary_fired = response.auxiliary_fired; self.last_collision_id = Some(object_id); 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(); } if is_wall { (Some(object_id), None) } else { (None, Some(object_id)) } } else { (None, None) }; if hit_wall == Some(25) && speed_before_collision >= 1_000 { velocity.x = 0; } if hit_wall == Some(25) && speed_before_collision < 1_000 { velocity.x = 0; velocity.y = 0; } velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); ball.position = old_position.add(velocity).to_position(); ball.velocity = velocity.to_velocity_per_second(); self.ball = ball; if let Some(wall_id) = hit_wall { if wall_id == 2 { let special_respawn = self.special_respawn_pending(); let response_spin = self.ball.spin; self.drain(events); if special_respawn { let origin = MilliVec::from_position(self.ball.position); self.ball.position = origin.add(velocity).to_position(); self.ball.velocity = velocity.to_velocity_per_second(); self.ball.spin = response_spin; } return true; } if wall_id == 25 && speed_before_collision < 1_000 { self.ball = Ball::default(); self.launcher_charge = 0.0; self.launcher_was_down = false; return true; } self.apply_wall_rule(wall_id, events); if auxiliary_fired && matches!(wall_id, 55 | 74 | 107) { events.push(Event::Sound(2019)); } } if let Some(circle_id) = hit_circle { self.apply_bumper_rule(circle_id, auxiliary_fired, events); } match scan.action { BallAction::Keep => {} BallAction::Suspend => return true, BallAction::Reset => { self.reset_ball_to_launcher(); return true; } BallAction::Finish => { self.drain(events); return true; } BallAction::Remove => { self.ball = self .secondary_ball .take() .expect("a multiball capture must leave the other slot active"); self.promote_claw_secondary_to_primary(); return true; } } collided } fn retain_static_range( &self, old_position: MilliVec, predicted: MilliVec, velocity: MilliVec, record_ids: std::ops::RangeInclusive, best: &mut Option<(u8, bool, StaticCollisionCandidate)>, ) { if let Some(candidate) = self.find_static_collision_candidate_in_range( old_position, predicted, velocity, record_ids, ) { retain_first_collision(best, candidate); } } #[allow(clippy::too_many_arguments, clippy::too_many_lines)] fn scan_ordered_records_for_ball( &mut self, ball: &mut Ball, ball_number: u16, ball_count: u16, old_position: MilliVec, velocity: &mut MilliVec, events: &mut Vec, ) -> ( Option<(u8, bool, StaticCollisionCandidate)>, SensorScanResult, MilliVec, ) { let mut predicted = old_position.add(*velocity); let mut best = None; let mut capture_candidate = None; let mut action = BallAction::Keep; self.retain_static_range(old_position, predicted, *velocity, 1..=5, &mut best); if self.apply_magnetic_record(6, old_position, velocity) { predicted = old_position.add(*velocity); } self.retain_static_range(old_position, predicted, *velocity, 7..=88, &mut best); if old_position.y < 250_000 && !self.claw.active { ball.velocity = velocity.to_velocity_per_second(); match self.capture_record_step( ball, ball_number, ball_count, 89, CLAW_TRIGGER_CENTER, CLAW_TRIGGER_RADIUS, old_position, predicted, &mut capture_candidate, events, ) { CaptureStep::Complete => { 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; } CaptureStep::Outside | CaptureStep::Holding => {} } *velocity = MilliVec::from_velocity_per_second(ball.velocity); } self.retain_static_range(old_position, predicted, *velocity, 90..=128, &mut best); if old_position.y < 250_000 { ball.velocity = velocity.to_velocity_per_second(); if let Some(completed_action) = self.check_lock_holes( ball, ball_number, ball_count, old_position, predicted, &mut capture_candidate, events, ) { action = completed_action; } *velocity = MilliVec::from_velocity_per_second(ball.velocity); } self.retain_static_range(old_position, predicted, *velocity, 134..=139, &mut best); if old_position.y < 250_000 { ball.velocity = velocity.to_velocity_per_second(); if self.type4_broadphase_contains(predicted, 140..=147) { self.special_hole_gate = SpecialHoleGate::Enabled; } let target_triggered = self.check_target_sensors( ball, old_position, movement_from_prediction(old_position, predicted), 140..=147, events, ); *velocity = MilliVec::from_velocity_per_second(ball.velocity); if target_triggered { predicted = old_position.add(*velocity); } if let Some(completed_action) = self.check_special_hole( ball, ball_number, ball_count, old_position, predicted, &mut capture_candidate, events, ) { action = completed_action; } *velocity = MilliVec::from_velocity_per_second(ball.velocity); let effect_triggered = self.check_effect_sensor( ball, ball_number, old_position, movement_from_prediction(old_position, predicted), events, ); *velocity = MilliVec::from_velocity_per_second(ball.velocity); if effect_triggered { predicted = old_position.add(*velocity); } let target_triggered = self.check_target_sensors( ball, old_position, movement_from_prediction(old_position, predicted), 150..=152, events, ); *velocity = MilliVec::from_velocity_per_second(ball.velocity); if target_triggered { predicted = old_position.add(*velocity); } } if self.apply_magnetic_record(153, old_position, velocity) { predicted = old_position.add(*velocity); } if self.apply_magnetic_record(154, old_position, velocity) { predicted = old_position.add(*velocity); } self.retain_static_range(old_position, predicted, *velocity, 155..=173, &mut best); if let Some((id, candidate)) = capture_candidate && best.is_none_or(|(static_id, _, _)| id < static_id) { best = Some((id, false, candidate)); } ball.velocity = velocity.to_velocity_per_second(); (best, SensorScanResult { action }, predicted) } fn find_static_collision_candidate_in_range( &self, old_position: MilliVec, predicted: MilliVec, velocity: MilliVec, record_ids: std::ops::RangeInclusive, ) -> Option<(u8, bool, StaticCollisionCandidate)> { let mut best = None; let layer = if old_position.y < 250_000 { 0x01 } else { 0x02 }; for object_id in record_ids { if !self.object_active[usize::from(object_id)] || self.claw.active && (12..=20).contains(&object_id) { continue; } if let Some(wall) = WALLS.iter().find(|wall| wall.id == object_id) { if wall.layer_mask & layer == 0 { continue; } let segment = self.live_wall_segment(wall.id, wall.segment); let start = MilliVec::from_position(segment.start); let end = MilliVec::from_position(segment.end); if predicted.x < start.x.min(end.x).wrapping_sub(5_000) || predicted.x > start.x.max(end.x).wrapping_add(5_000) || predicted.y < start.y.min(end.y).wrapping_sub(5_000) || predicted.y > start.y.max(end.y).wrapping_add(5_000) { continue; } let material = if self.tilted { CollisionMaterial::line( f64::from(wall.normal_rebound), f64::from(wall.tangent_coupling), ) } else { CollisionMaterial::line_with_kick( f64::from(wall.normal_rebound), f64::from(wall.tangent_coupling), f64::from(wall.response_auxiliary), f64::from(wall.response_kick), ) }; if let Some(candidate) = line_collision_candidate_at( old_position, predicted, velocity, segment.start, segment.end, material, ) { retain_first_collision(&mut best, (wall.id, true, candidate)); } } let circle = PASSIVE_CIRCLES .iter() .chain(BUMPERS.iter()) .find(|circle| circle.id == object_id); if let Some(circle) = circle && circle.layer_mask & layer != 0 && { let (minimum, maximum) = self.live_circle_bounds( circle.id, self.live_circle_center(circle.id, circle.center), circle.contact_radius, ); predicted.x >= minimum.x && predicted.x <= maximum.x && predicted.y >= minimum.y && predicted.y <= maximum.y } && let Some(candidate) = circle_collision_candidate_at( old_position, predicted, velocity, self.live_circle_center(circle.id, circle.center), circle.contact_radius, CollisionMaterial::circle( f64::from(circle.normal_rebound), f64::from(circle.tangent_coupling), if self.tilted { 0.0 } else { f64::from(circle.normal_kick) }, ), ) && best.is_none() { retain_first_collision(&mut best, (circle.id, false, candidate)); } } best } fn type4_broadphase_contains( &self, predicted: MilliVec, record_ids: std::ops::RangeInclusive, ) -> bool { !self.tilted && TARGET_SENSORS.into_iter().any(|sensor| { record_ids.contains(&sensor.id) && self.object_active[usize::from(sensor.id)] && trigger_broadphase_contains(predicted, sensor.center, sensor.radius) }) } #[cfg(test)] fn find_static_collision_candidate( &self, old_position: MilliVec, velocity: MilliVec, ) -> Option<(u8, bool, StaticCollisionCandidate)> { self.find_static_collision_candidate_in_range( old_position, old_position.add(velocity), velocity, 1..=175, ) } #[cfg(test)] fn advance_secondary_ball(&mut self, events: &mut Vec) -> bool { self.advance_secondary_ball_slot(events, !self.claw_holds_slot(ClawBallSlot::Primary)) } #[allow(clippy::too_many_lines)] fn advance_secondary_ball_slot( &mut self, events: &mut Vec, primary_slot_active: bool, ) -> bool { let Some(mut ball) = self.secondary_ball.take() else { return true; }; let primary_position = self.ball.position; let primary_velocity = self.ball.velocity; let primary_capture_age = self.ball.capture_age; let old_position = MilliVec::from_position(ball.position); let mut velocity = MilliVec::from_velocity_per_second(ball.velocity); velocity.y += GRAVITY_MILLI_PER_STEP; velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); let speed_before_collision = milli_distance(velocity); if predicted_outside_board(old_position.add(velocity)) { self.score_mode = ScoreMode::Normal; events.push(Event::Drain); return true; } let (best_static, scan, predicted) = self.scan_ordered_records_for_ball( &mut ball, 2, 2, old_position, &mut velocity, events, ); let mut best_collision = best_static .map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin))); 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) && predicted.y <= other_position.y.wrapping_add(21_000) && let Some(response) = ball_collision_response( old_position, velocity, ball.spin, other_position, MilliVec::from_velocity_per_second(primary_velocity), primary_capture_age <= 0, ) && best_collision.is_none() { transferred_primary_velocity = Some(response.other_velocity); best_collision = Some(( 174, false, CollisionResponse { surface_distance: response.surface_distance, velocity: response.moving_velocity, spin: response.moving_spin, auxiliary_fired: false, }, )); } let mut hit = None; let collided = best_collision.is_some(); if collided { ball.capture_age = 0; } let mut auxiliary_fired = false; if let Some((object_id, is_wall, response)) = best_collision { velocity = response.velocity; ball.spin = response.spin; auxiliary_fired = response.auxiliary_fired; hit = Some((object_id, is_wall)); if object_id == 174 && let Some(transferred) = transferred_primary_velocity { self.ball.velocity = transferred.to_velocity_per_second(); } } if hit == Some((25, true)) && speed_before_collision >= 1_000 { velocity.x = 0; } if hit == Some((25, true)) && speed_before_collision < 1_000 { velocity.x = 0; velocity.y = 0; } velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); ball.position = old_position.add(velocity).to_position(); ball.velocity = velocity.to_velocity_per_second(); if hit == Some((2, true)) { self.score_mode = ScoreMode::Normal; events.push(Event::Drain); return true; } if let Some((object_id, true)) = hit { if object_id == 25 && speed_before_collision < 1_000 { self.score_mode = ScoreMode::Normal; return true; } self.apply_wall_rule(object_id, events); if auxiliary_fired && matches!(object_id, 55 | 74 | 107) { events.push(Event::Sound(2019)); } } else if let Some((object_id, false)) = hit { self.apply_bumper_rule(object_id, auxiliary_fired, events); } match scan.action { BallAction::Keep | BallAction::Suspend => self.secondary_ball = Some(ball), BallAction::Reset | BallAction::Remove | BallAction::Finish => return true, } collided || scan.action == BallAction::Suspend } fn apply_bumper_rule(&mut self, object_id: u8, auxiliary_fired: bool, events: &mut Vec) { if self.tilted || !BUMPERS.iter().any(|bumper| bumper.id == object_id) { return; } self.record_countdowns[usize::from(object_id)] = 5; events.push(Event::Bumper); events.push(Event::Sound(2006)); let points = self .player() .bumper_value .saturating_sub(if auxiliary_fired { 0 } else { 1_000 }); if points != 0 { self.add_score(points, events); } } fn apply_wall_rule(&mut self, object_id: u8, events: &mut Vec) { if self.tilted { return; } let wall = WALLS .iter() .find(|wall| wall.id == object_id) .expect("a wall collision id must reference a recovered wall"); if wall.flags & 0x0002 != 0 { let group = usize::from(wall.contact_group); self.object_active[group] = false; if wall.flags & 0x2000 != 0 { self.object_active[group + 1] = false; self.object_active[group + 2] = false; } } if wall.flags & 0x0008 != 0 { events.push(Event::Sound(2012)); if [90, 93, 96, 99, 102] .into_iter() .all(|id| !self.object_active[id]) { let player = &mut self.players[self.current_player]; let completion_bonus = if player.bumper_value >= 6_000 { 50_000 } else { player.bumper_value += 1_000; 0 }; events.push(Event::Sound(2017)); if completion_bonus != 0 { self.add_score(completion_bonus, events); } for active in &mut self.object_active[90..=104] { *active = true; } } } if wall.flags & 0x0010 != 0 { events.push(Event::Sound(2012)); if [109, 112, 115, 118] .into_iter() .all(|id| !self.object_active[id]) { let segments = self.player().diamond_segments; if segments < 9 { self.players[self.current_player].diamond_segments += 1; let award = if segments == 8 { 24_464 } else { u32::from(segments + 1) * 10_000 }; events.push(Event::Sound(2017)); self.add_score(award, events); if segments == 8 { self.ball_double = BallDoubleState::Active; for object_id in [153, 6, 154] { self.object_active[object_id] = true; } } } else { if self.ball_double == BallDoubleState::Active { let player = &mut self.players[self.current_player]; player.secondary_score = player.secondary_score.wrapping_add(100_000); } else { self.ball_double = BallDoubleState::Active; } events.push(Event::Sound(2007)); } for active in &mut self.object_active[109..=120] { *active = true; } } } if wall.flags & 0x0400 != 0 && self.target_rotation_state.is_none() && self.panel_frame.is_none() { self.target_rotation_state = Some(0); } if wall.flags & 0x0004 != 0 { events.push(Event::Sound(2012)); if self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0 { self.target_effect = 7; } else { let previous = self.target_effect; loop { self.target_effect = u8::try_from(self.random.below(6) + 1).unwrap_or(1); if self.target_effect != previous { break; } } } self.object_active[usize::from(EFFECT_SENSOR.id)] = true; } // 1000:91eb dispatches b476's flag-driven rules before it adds the // selected record's static score. This is observable when the ninth // diamond enables ball-scoped double scoring: that collision's 1,500 // points are already doubled, while the preceding 24,464 award is not. self.apply_static_wall_score(object_id, wall.score, events); } fn apply_static_wall_score(&mut self, object_id: u8, score: u32, events: &mut Vec) { if score == 0 { return; } self.add_score(score, events); events.push(if object_id == 121 { Event::Wheel } else { Event::Target }); } #[cfg(test)] fn check_sensor_objects( &mut self, old_position: MilliVec, movement_velocity: MilliVec, events: &mut Vec, ) -> BallAction { let ball_count = if self.secondary_ball.is_some() { 2 } else { 1 }; let mut ball = self.ball; let scan = self.check_sensor_objects_for_ball( &mut ball, 1, ball_count, old_position, movement_velocity, events, ); match scan.action { BallAction::Keep | BallAction::Suspend => self.ball = ball, BallAction::Reset => self.reset_ball_to_launcher(), BallAction::Finish => { self.ball = ball; self.drain(events); } BallAction::Remove => { self.ball = self .secondary_ball .take() .expect("a multiball capture must leave the other slot active"); } } scan.action } #[cfg(test)] fn check_sensor_objects_for_ball( &mut self, ball: &mut Ball, ball_number: u16, ball_count: u16, old_position: MilliVec, movement_velocity: MilliVec, events: &mut Vec, ) -> SensorScanResult { let mut capture_candidate = None; let mut action = BallAction::Keep; let predicted_position = old_position.add(movement_velocity); if !self.tilted && (TARGET_SENSORS.into_iter().any(|sensor| { self.object_active[usize::from(sensor.id)] && trigger_broadphase_contains(predicted_position, sensor.center, sensor.radius) }) || self.object_active[usize::from(EFFECT_SENSOR.id)] && trigger_broadphase_contains( predicted_position, EFFECT_SENSOR.center, EFFECT_SENSOR.radius, )) { self.special_hole_gate = SpecialHoleGate::Enabled; } if old_position.y >= 250_000 { return SensorScanResult { action: BallAction::Keep, }; } if !self.claw.active { match self.capture_record_step( ball, ball_number, ball_count, 89, CLAW_TRIGGER_CENTER, CLAW_TRIGGER_RADIUS, old_position, predicted_position, &mut capture_candidate, events, ) { CaptureStep::Holding | CaptureStep::Outside => {} CaptureStep::Complete => { 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; } } } if let Some(completed_action) = self.check_lock_holes( ball, ball_number, ball_count, old_position, predicted_position, &mut capture_candidate, events, ) { action = completed_action; } self.check_target_sensors(ball, old_position, movement_velocity, 140..=147, events); if let Some(completed_action) = self.check_special_hole( ball, ball_number, ball_count, old_position, predicted_position, &mut capture_candidate, events, ) { action = completed_action; } self.check_effect_sensor(ball, ball_number, old_position, movement_velocity, events); self.check_target_sensors(ball, old_position, movement_velocity, 150..=152, events); SensorScanResult { action } } #[allow( clippy::cast_possible_truncation, clippy::too_many_arguments, clippy::too_many_lines )] fn capture_record_step( &mut self, ball: &mut Ball, ball_number: u16, ball_count: u16, record_id: u8, center: Vec2, radius: f32, previous_position: MilliVec, predicted_position: MilliVec, best_capture: &mut Option<(u8, StaticCollisionCandidate)>, events: &mut Vec, ) -> CaptureStep { let current_position = MilliVec::from_position(ball.position); let center_view = center; let center = MilliVec::from_position(center_view); let radius_view = radius; let radius = (radius_view * 1_000.0).round() as i32; let broadphase_margin = MilliVec::from_position(vec2(radius_view + 5.0, 0.0)).x; if predicted_position.x < center.x.wrapping_sub(broadphase_margin) || predicted_position.x > center.x.wrapping_add(broadphase_margin) || 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.clear_claw_capture_entry_for(ball_number); } return CaptureStep::Outside; } let dx = center.x.wrapping_sub(current_position.x); let dy = center .y .wrapping_sub(current_position.y) .wrapping_sub(2_000); let surface_distance = milli_distance(MilliVec { x: dx, y: dy }) - radius; let contact_index = usize::from(record_id); let contact = self.record_contacts[contact_index]; let contact_allowed = contact == 0 || (contact != 99 && ball_count == 1) || contact == ball_number; if surface_distance < -11_000 && contact_allowed && !(record_id == 148 && self.special_hole_gate == SpecialHoleGate::Suppressed) { 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_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); let stationary = center.x.wrapping_sub(previous_position.x).wrapping_abs() < 500 && center.y.wrapping_sub(previous_position.y).wrapping_abs() < 500 && velocity.x.wrapping_abs() < 500 && velocity.y.wrapping_abs() < 500; if stationary { velocity = MilliVec::default(); if ball.capture_age == 0 { self.record_contacts[contact_index] = ball_number; if !self.tilted { events.push(Event::Sound(2015)); } } ball.capture_age = if record_id == 89 { 300 } else { ball.capture_age.wrapping_add(5) }; } else { let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]); velocity.x = Real48::from_i32(velocity.x).multiply(damping).round_i32(); velocity.y = Real48::from_i32(velocity.y).multiply(damping).round_i32(); velocity.x = if center.x > current_position.x { velocity.x.wrapping_add(150) } else { velocity.x.wrapping_sub(150) }; velocity.y = if center.y > current_position.y { velocity.y.wrapping_add(150) } else { velocity.y.wrapping_sub(150) }; } ball.velocity = velocity.to_velocity_per_second(); return CaptureStep::Holding; } if record_id == 89 && ball_count == 1 { return CaptureStep::Complete; } ball.capture_age = 0; 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 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; } let velocity = MilliVec::from_velocity_per_second(ball.velocity); if surface_distance <= milli_distance(velocity) && surface_distance >= -11_000 && contact != 0 && let Some(candidate) = capture_collision_candidate( current_position, velocity, center_view, radius_view, CollisionMaterial::line(0.6, 0.0), ) && best_capture.is_none() { *best_capture = Some((record_id, candidate)); } CaptureStep::Outside } #[allow(clippy::too_many_arguments)] fn check_lock_holes( &mut self, ball: &mut Ball, ball_number: u16, ball_count: u16, old_position: MilliVec, predicted_position: MilliVec, best_capture: &mut Option<(u8, StaticCollisionCandidate)>, events: &mut Vec, ) -> Option { for (index, sensor) in LOCK_HOLES.into_iter().enumerate() { match self.capture_record_step( ball, ball_number, ball_count, sensor.id, sensor.center, sensor.radius, old_position, predicted_position, best_capture, events, ) { CaptureStep::Outside | CaptureStep::Holding => continue, CaptureStep::Complete => {} } return Some(self.complete_lock_hole(index, ball_count, events)); } None } fn complete_lock_hole( &mut self, index: usize, ball_count: u16, events: &mut Vec, ) -> BallAction { self.wheel_holes[index] = true; // Deliberate original-game divergence: a multiball capture normally // leaves owner 2 here, which lets the surviving single ball capture // the visibly occupied hole once more. Occupied wheel holes remain // permanent in the clone regardless of which ball completed them. self.record_contacts[129 + index] = 99; // 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]; player.secondary_score = player.secondary_score.wrapping_add(award); if filled == 5 { let transfer = self.secondary_score_display(); self.add_score(transfer, events); let player = &mut self.players[self.current_player]; player.secondary_score = 0; player.score_multiplier = player.score_multiplier.wrapping_add(1); player.extra_balls = player.extra_balls.wrapping_add(1); if ball_count == 1 { self.panel_frame = Some(0); } self.score_mode = ScoreMode::Normal; } events.push(Event::Lock); if ball_count == 1 { if self.tilted { BallAction::Finish } else { BallAction::Reset } } else { BallAction::Remove } } #[allow(clippy::too_many_arguments)] fn check_special_hole( &mut self, ball: &mut Ball, ball_number: u16, ball_count: u16, old_position: MilliVec, predicted_position: MilliVec, best_capture: &mut Option<(u8, StaticCollisionCandidate)>, events: &mut Vec, ) -> Option { match self.capture_record_step( ball, ball_number, ball_count, SPECIAL_HOLE_SENSOR.id, SPECIAL_HOLE_SENSOR.center, SPECIAL_HOLE_SENSOR.radius, old_position, predicted_position, best_capture, events, ) { CaptureStep::Outside | CaptureStep::Holding => return None, CaptureStep::Complete => {} } self.multiball_state = MultiballState::Ready; events.push(Event::Wheel); Some(if ball_count == 1 { if self.tilted { BallAction::Finish } else { BallAction::Reset } } else { BallAction::Remove }) } fn check_effect_sensor( &mut self, ball: &mut Ball, ball_number: u16, old_position: MilliVec, movement_velocity: MilliVec, events: &mut Vec, ) -> bool { if self.tilted { return false; } let effect_index = usize::from(EFFECT_SENSOR.id); let predicted_position = old_position.add(movement_velocity); if self.object_active[effect_index] && trigger_broadphase_contains( predicted_position, EFFECT_SENSOR.center, EFFECT_SENSOR.radius, ) { let touched = path_intersects_circle( old_position, movement_velocity, EFFECT_SENSOR.center, EFFECT_SENSOR.radius, ); let entered = touched && !self.trigger_flags[effect_index]; self.trigger_flags[effect_index] = touched; if entered { events.push(Event::Target); events.push(Event::Sound(2004)); self.add_score(EFFECT_SENSOR.score, events); match self.target_effect { 1..=5 => { const ADDITIONS: [u32; 5] = [10_000, 20_000, 50_000, 100_000, 200_000]; let addition = ADDITIONS[usize::from(self.target_effect - 1)]; let player = &mut self.players[self.current_player]; player.secondary_score = player.secondary_score.wrapping_add(addition); } 6 => { let transfer = self.secondary_score_display(); self.add_score(transfer, events); self.players[self.current_player].secondary_score = 0; } 7 if self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0 && self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != ball_number && self.secondary_ball.is_none() && matches!( self.multiball_state, MultiballState::Ready | MultiballState::Active ) => { self.secondary_ball = Some(Ball { position: vec2(17.0, 23.0), velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(), in_launcher: false, spin: Real48::ZERO, capture_age: 0, }); self.multiball_state = MultiballState::Active; self.score_mode = ScoreMode::Multiball; } _ => {} } self.target_effect = 0; self.object_active[effect_index] = false; self.randomize_trigger_velocity(ball); return true; } } false } fn check_target_sensors( &mut self, ball: &mut Ball, old_position: MilliVec, movement_velocity: MilliVec, record_ids: std::ops::RangeInclusive, events: &mut Vec, ) -> bool { if self.tilted { return false; } let mut triggered = false; let predicted_position = old_position.add(movement_velocity); for sensor in TARGET_SENSORS { if !record_ids.contains(&sensor.id) { continue; } let contact_index = usize::from(sensor.id); // The original scanner does not call a type-4 handler until the // predicted point is inside that record's registered bounds. In // multiball this also prevents an unrelated ball outside the // corridor from clearing the ball that is currently inside it. if !self.object_active[contact_index] || !trigger_broadphase_contains(predicted_position, sensor.center, sensor.radius) { continue; } let touched = path_intersects_circle( old_position, movement_velocity, sensor.center, sensor.radius, ); let entered = touched && !self.trigger_flags[contact_index]; self.trigger_flags[contact_index] = touched; if !entered { continue; } events.push(Event::Target); events.push(Event::Sound(2004)); self.add_score(sensor.score, events); self.record_countdowns[usize::from(sensor.id)] = if sensor.id <= 147 { 20 } else { 10 }; if (150..=152).contains(&sensor.id) { self.top_targets[usize::from(sensor.id - 150)] = true; let field_id = [153, 6, 154][usize::from(sensor.id - 150)]; self.object_active[field_id] = true; } self.randomize_trigger_velocity(ball); triggered = true; } triggered } fn randomize_trigger_velocity(&mut self, ball: &mut Ball) { let mut velocity = MilliVec::from_velocity_per_second(ball.velocity); let offset = Real48::from_bytes([0x81, 0x71, 0x3d, 0x0a, 0xd7, 0x03]); let span = Real48::from_bytes([0x7d, 0x71, 0x3d, 0x0a, 0xd7, 0x23]); let x_factor = offset.subtract(self.random.real48().multiply(span)); let y_factor = offset.subtract(self.random.real48().multiply(span)); velocity.x = Real48::from_i32(velocity.x).multiply(x_factor).round_i32(); velocity.y = Real48::from_i32(velocity.y).multiply(y_factor).round_i32(); ball.velocity = velocity.to_velocity_per_second(); } fn reset_ball_to_launcher(&mut self) { if self.record_contacts[129..=133] .iter() .all(|contact| *contact != 0) { self.panel_frame = Some(0); } self.tilted = false; self.tilt_counter = 0; self.ball = Ball::default(); self.launcher_charge = 0.0; self.launcher_was_down = false; self.launcher_hold_seconds = 0.0; self.launcher_next_repeat = LAUNCHER_REPEAT_DELAY_SECONDS; self.launcher_velocity_milli = 0; } #[cfg(test)] fn apply_magnetic_fields(&mut self, old_position: MilliVec, velocity: &mut MilliVec) { for object_id in [6, 153, 154] { self.apply_magnetic_record(object_id, old_position, velocity); } } fn apply_magnetic_record( &mut self, object_id: usize, old_position: MilliVec, velocity: &mut MilliVec, ) -> bool { if self.tilted || !self.object_active[object_id] { return false; } let (min_x, min_y, max_x, max_y) = match object_id { 6 => (143_000, 421_000, 169_000, 452_000), 153 => (1_000, 315_000, 32_000, 389_000), 154 => (278_000, 315_000, 312_000, 387_000), _ => unreachable!("only the three rectangular type-four records are magnetic"), }; if old_position.x < min_x || old_position.x > max_x || old_position.y < min_y || old_position.y > max_y { return false; } self.special_hole_gate = SpecialHoleGate::Enabled; let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]); velocity.x = Real48::from_i32(velocity.x).multiply(damping).round_i32(); let vertical_factor = Real48::from_i32(1).subtract( self.random .real48() .multiply(Real48::from_bytes([0x7f, 0x9a, 0x99, 0x99, 0x99, 0x19])), ); velocity.y = Real48::from_i32(MAXIMUM_SPEED_MILLI_PER_STEP) .multiply(vertical_factor) .round_i32() .wrapping_neg(); let predicted = old_position.add(*velocity); if predicted.x < min_x || predicted.x > max_x || predicted.y < min_y || predicted.y > max_y { self.object_active[object_id] = false; } true } pub fn magnetic_field_active(&self, object_id: usize) -> bool { debug_assert!([6, 153, 154].contains(&object_id)); self.object_active[object_id] } pub fn bank_target_lit(&self, object_id: u8) -> bool { matches!(object_id, 90 | 93 | 96 | 99 | 102 | 109 | 112 | 115 | 118) && !self.object_active[usize::from(object_id)] } pub fn effect_target_active(&self) -> bool { self.object_active[usize::from(EFFECT_SENSOR.id)] } pub fn special_hole_active(&self) -> bool { self.multiball_state == MultiballState::Ready && self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0 } pub fn player_effect(&self) -> u8 { self.target_effect.min(7) } 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) } 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; } self.score_mode = ScoreMode::Normal; } } 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) { let pending = self.pending_flipper_edges; self.pending_flipper_edges.fill(0); 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), (i32::from(pending[1]), FlipperSide::Right), ] { if delta == 0 { continue; } 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); } } } #[cfg(not(target_arch = "wasm32"))] fn begin_claw_capture(&mut self, terminal_frame: u8, events: &mut Vec) { debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame)); if self.claw.active { return; } self.claw.active = true; 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_entry = None; self.claw.frame_accumulator = 0.0; self.ball.velocity = Vec2::ZERO; self.ball.spin = Real48::ZERO; events.push(Event::ClawCapture); events.push(Event::Sound(2015)); } fn begin_claw_capture_after_hold( &mut self, ball: &mut Ball, ball_number: u16, terminal_frame: u8, events: &mut Vec, ) { debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame)); if self.claw.active { return; } self.claw.active = true; self.claw.target_frame = terminal_frame; self.claw.bank = ClawSpriteBank::Closing; self.claw.ball_suspended = true; 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; events.push(Event::ClawCapture); } fn update_claw(&mut self, dt: f32, events: &mut Vec) { if !self.claw.active { return; } self.claw.frame_accumulator += dt; while self.claw.active && self.claw.frame_accumulator >= self.claw_frame_seconds { self.claw.frame_accumulator -= self.claw_frame_seconds; self.advance_claw(events); } } fn advance_claw(&mut self, events: &mut Vec) { if self.claw.frame != self.claw.target_frame { if self.claw.frame < self.claw.target_frame { self.claw.frame += 1; } else { self.claw.frame -= 1; } return; } if self.claw.frame == 10 { if self.claw.bank == ClawSpriteBank::Opening { self.claw = Claw::default(); } return; } let release_frame = self.claw.frame; self.claw.target_frame = 10; self.claw.bank = ClawSpriteBank::Opening; 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.promote_claw_secondary_to_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)); } fn add_score(&mut self, points: u32, events: &mut Vec) { const THRESHOLDS: [u32; 4] = [140_000, 650_000, 1_300_000, 4_000_000]; if self.tilted { return; } let multiball_multiplier = if self.score_mode == ScoreMode::Multiball { 2 } else { 1 }; let ball_multiplier = if self.ball_double == BallDoubleState::Active { 2 } else { 1 }; let multiplier = multiball_multiplier * ball_multiplier; let player = &mut self.players[self.current_player]; player.score = player.score.wrapping_add(points.wrapping_mul(multiplier)); let level = usize::from(player.media_level); if level < THRESHOLDS.len() && score_reaches_marker(player.score, THRESHOLDS[level]) { player.media_level += 1; player.extra_balls = player.extra_balls.wrapping_add(1); events.push(Event::Media); events.push(Event::ExtraBall); events.push(Event::Sound(2007)); } } fn drain(&mut self, events: &mut Vec) { 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.promote_claw_secondary_to_primary(); events.push(Event::Drain); return; } if let Some(remaining_ball) = self.secondary_ball.take() { self.ball = remaining_ball; self.score_mode = ScoreMode::Normal; events.push(Event::Drain); return; } if self.special_respawn_pending() { self.ball = Ball { position: vec2(17.0, 23.0), velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(), in_launcher: false, spin: Real48::ZERO, capture_age: 0, }; self.multiball_state = MultiballState::Unavailable; self.record_contacts[148] = 0; self.special_hole_gate = SpecialHoleGate::Suppressed; return; } self.ball_double = BallDoubleState::Inactive; self.multiball_state = MultiballState::Unavailable; self.special_hole_gate = SpecialHoleGate::Enabled; let player = &mut self.players[self.current_player]; if player.extra_balls > 0 { player.extra_balls -= 1; } else { player.balls = player.balls.saturating_sub(1); } let high_score_candidate = (player.balls == 0 && player.extra_balls == 0).then_some(player.score); self.save_current_rule_state(); events.push(Event::Drain); if !self.tilted { events.push(Event::Sound(2008)); } if let Some(score) = high_score_candidate { events.push(Event::HighScoreCandidate(score)); self.flipper_release_latch[0] |= self.flipper_inputs.left_raised; self.flipper_release_latch[1] |= self.flipper_inputs.right_raised; self.flipper_inputs = Flippers::default(); } self.tilted = false; self.tilt_counter = 0; self.secondary_ball = None; self.score_mode = ScoreMode::Normal; self.launcher_charge = 0.0; self.launcher_was_down = false; self.launcher_hold_seconds = 0.0; self.launcher_next_repeat = LAUNCHER_REPEAT_DELAY_SECONDS; self.launcher_velocity_milli = 0; let mut next = (self.current_player + 1) % self.players.len(); for _ in 0..self.players.len() { if self.players[next].balls > 0 || self.players[next].extra_balls > 0 { self.current_player = next; self.load_current_rule_state(); self.ball = Ball::default(); return; } next = (next + 1) % self.players.len(); } self.finished = true; } fn special_respawn_pending(&self) -> bool { self.secondary_ball.is_none() && self.score_mode == ScoreMode::Normal && self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0 && matches!( self.multiball_state, MultiballState::Ready | MultiballState::Active ) } fn save_current_rule_state(&mut self) { self.players[self.current_player].rules = RuleState { wheel_holes: self.wheel_holes, top_targets: self.top_targets, record_contacts: self.record_contacts, object_active: self.object_active, target_effect: self.target_effect, multiball_state: self.multiball_state, }; } fn load_current_rule_state(&mut self) { let state = self.players[self.current_player].rules; self.wheel_holes = state.wheel_holes; self.top_targets = state.top_targets; self.record_contacts = state.record_contacts; self.trigger_flags.fill(false); self.object_active = state.object_active; self.target_effect = state.target_effect; self.multiball_state = state.multiball_state; } fn live_wall_segment(&self, object_id: u8, resting: Segment) -> Segment { let (start, end) = match object_id { 66 if self.flippers.left_raised => (vec2(98.0, 382.0), vec2(131.0, 369.0)), 68 if self.flippers.left_raised => (vec2(137.0, 384.0), vec2(116.0, 405.0)), 81 if self.flippers.right_raised => (vec2(197.0, 405.0), vec2(171.0, 378.0)), 83 if self.flippers.right_raised => (vec2(183.0, 368.0), vec2(217.0, 383.0)), _ => return resting, }; Segment::new(start, end, resting.bounce) } fn live_circle_center(&self, object_id: u8, resting: Vec2) -> Vec2 { match object_id { 67 if self.flippers.left_raised => LEFT_FLIPPER_RAISED_TIP, 82 if self.flippers.right_raised => RIGHT_FLIPPER_RAISED_TIP, _ => resting, } } fn live_circle_bounds( &self, object_id: u8, center: Vec2, contact_radius: f32, ) -> (MilliVec, MilliVec) { // 1000:8b0d moves the tip records through an asymmetric swept box; // their collision broadphase is not center +/- (radius + 5). if object_id == 67 && self.flippers.left_raised { return ( MilliVec { x: 118_000, y: 362_000, }, MilliVec { x: 150_000, y: 476_000, }, ); } if object_id == 82 && self.flippers.right_raised { return ( MilliVec { x: 167_000, y: 362_000, }, MilliVec { x: 191_000, y: 476_000, }, ); } let center = MilliVec::from_position(center); let margin = MilliVec::from_position(vec2(contact_radius + 5.0, 0.0)).x; ( MilliVec { x: center.x.wrapping_sub(margin), y: center.y.wrapping_sub(margin), }, MilliVec { x: center.x.wrapping_add(margin), y: center.y.wrapping_add(margin), }, ) } } fn claw_release(frame: u8) -> (Vec2, Vec2) { let (position, direction) = match frame { 1 => (vec2(258.0, 78.0), vec2(-0.60, 0.00)), 2 => (vec2(259.0, 81.0), vec2(-0.45, 0.05)), 3 => (vec2(258.0, 78.0), vec2(-0.70, 0.30)), 4 => (vec2(261.0, 86.0), vec2(-0.30, 0.20)), 5 => (vec2(266.0, 90.0), vec2(-0.32, 0.22)), 6 => (vec2(270.0, 94.0), vec2(-0.40, 0.60)), 7 => (vec2(275.0, 97.0), vec2(-0.31, 0.70)), 8 => (vec2(278.0, 98.0), vec2(-0.20, 0.80)), 9 => (vec2(282.0, 101.0), vec2(-0.10, 0.90)), 18 => return (vec2(325.0, 92.0), vec2(0.0, 100.0)), _ => unreachable!("the original claw only releases from a terminal frame"), }; (position, direction * ORIGINAL_BALL_SPEED_PER_SECOND) } fn apply_flipper_response_to_ball(ball: &mut Ball, delta: i32, side: FlipperSide) { let position = MilliVec::from_position(ball.position); let velocity = MilliVec::from_velocity_per_second(ball.velocity); if let Some(response) = moving_flipper_response( position, velocity, delta, side, Real48::from_bytes([0x80, 0, 0, 0, 0, 0]), MAXIMUM_SPEED_MILLI_PER_STEP, ) { ball.position = position.add(response.movement).to_position(); ball.velocity = response.velocity.to_velocity_per_second(); } } #[allow(clippy::too_many_lines)] #[cfg(test)] mod tests { use super::*; fn launch_ball(game: &mut Game, held_frames: usize) -> Vec { for _ in 0..held_frames { game.update( 1.0 / 60.0, 3, Controls { launch_down: true, ..Controls::default() }, ); } game.update(1.0 / 60.0, 3, Controls::default()) } fn complete_stationary_type_three_capture( game: &mut Game, center: Vec2, events: &mut Vec, ) { for _ in 0..=60 { game.ball.in_launcher = false; game.ball.position = center; game.ball.velocity = Vec2::ZERO; game.check_sensor_objects(MilliVec::from_position(center), MilliVec::default(), events); } } fn capture_primary_record_step( game: &mut Game, record_id: u8, center: Vec2, radius: f32, previous_position: MilliVec, events: &mut Vec, ) -> CaptureStep { let mut ball = game.ball; let mut capture_candidate = None; let predicted_position = previous_position.add(MilliVec::from_velocity_per_second(ball.velocity)); let result = game.capture_record_step( &mut ball, 1, if game.secondary_ball.is_some() { 2 } else { 1 }, record_id, center, radius, previous_position, predicted_position, &mut capture_candidate, events, ); game.ball = ball; result } #[test] fn player_count_is_bounded() { assert_eq!(Game::new(0).players.len(), 1); assert_eq!(Game::new(99).players.len(), 4); } #[test] fn plus_adds_players_only_before_the_first_launch() { let mut game = Game::new(1); assert!(game.add_player_before_launch()); assert!(game.add_player_before_launch()); assert!(game.add_player_before_launch()); assert!(!game.add_player_before_launch()); assert_eq!(game.players.len(), 4); let mut game = Game::new(1); launch_ball(&mut game, 1); assert!(!game.add_player_before_launch()); assert_eq!(game.players.len(), 1); } #[test] fn ball_starts_in_the_recovered_shooter_lane() { let game = Game::new(1); assert_eq!(game.ball.position, Vec2::new(325.0, 413.0)); assert!(game.ball.in_launcher); } #[test] fn each_static_record_reaches_the_production_scanner() { let mut missed_walls = Vec::new(); for wall in WALLS { let direction = wall.segment.end - wall.segment.start; let normal = vec2(-direction.y, direction.x).normalize(); let midpoint = (wall.segment.start + wall.segment.end) * 0.5; let mut game = Game::new(1); game.object_active.fill(false); game.object_active[usize::from(wall.id)] = true; game.ball.in_launcher = false; game.ball.position = midpoint - normal; game.ball.velocity = MilliVec::from_velocity_per_second(normal * 200.0).to_velocity_per_second(); game.fixed_update(&mut Vec::new()); if game.last_collision_id != Some(wall.id) { missed_walls.push(wall.id); } } let mut missed_circles = Vec::new(); for circle in PASSIVE_CIRCLES.iter().chain(BUMPERS.iter()) { let mut game = Game::new(1); game.object_active.fill(false); game.object_active[usize::from(circle.id)] = true; game.ball.in_launcher = false; game.ball.position = circle.center + vec2(circle.contact_radius + 1.0, 0.0); game.ball.velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second(); game.fixed_update(&mut Vec::new()); if game.last_collision_id != Some(circle.id) { missed_circles.push(circle.id); } } assert!( missed_walls.is_empty(), "missed production wall records: {missed_walls:?}" ); assert!( missed_circles.is_empty(), "missed production circle records: {missed_circles:?}" ); } #[test] fn detail_timer_batches_the_original_number_of_substeps() { for (detail, interval, substeps) in [ (1, 0.050, 5_i32), (2, 0.040, 4), (3, 0.030, 3), (4, 0.020, 2), (5, 0.010, 1), ] { let mut game = Game::new(1); game.ball.in_launcher = false; game.ball.position = vec2(200.0, 250.0); game.ball.velocity = Vec2::ZERO; game.object_active.fill(false); game.update(interval - 0.001, detail, Controls::default()); assert_eq!(game.ball.position, vec2(200.0, 250.0)); assert_eq!(game.ball.velocity, Vec2::ZERO); game.update(0.001_1, detail, Controls::default()); let velocity = MilliVec::from_velocity_per_second(game.ball.velocity); let position = MilliVec::from_position(game.ball.position); assert_eq!(velocity.y, 15 * substeps); assert_eq!(position.y, 250_000 + 15 * substeps * (substeps + 1) / 2); } } #[test] fn detail_batch_stops_after_the_first_collision_like_integrate_one_ball() { let mut game = Game::new(1); game.ball.in_launcher = false; game.ball.position = vec2(270.0, 30.0); game.ball.velocity = vec2(0.0, -380.0); let mut expected = game.clone(); let mut expected_events = Vec::new(); let mut stopped = false; for _ in 0..5 { if expected.fixed_update(&mut expected_events) { stopped = true; break; } } assert!(stopped, "the recovered top rail must end this detail batch"); let mut events = Vec::new(); game.timer_tick(0.050, 5, &mut events); assert_eq!(game.ball.position, expected.ball.position); assert_eq!(game.ball.velocity, expected.ball.velocity); assert_eq!(events, expected_events); } #[test] fn effect_seven_spawn_waits_until_the_next_timer_callback() { let mut game = Game::new_with_seed(1, 7); game.ball.in_launcher = false; game.ball.position = EFFECT_SENSOR.center; game.ball.velocity = Vec2::ZERO; game.object_active.fill(false); game.object_active[usize::from(EFFECT_SENSOR.id)] = true; game.multiball_state = MultiballState::Ready; game.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] = 2; game.target_effect = 7; game.timer_tick(0.050, 5, &mut Vec::new()); let spawned = game .secondary_ball .expect("the effect-seven request must create slot two after simulation"); assert_eq!(spawned.position, vec2(17.0, 23.0)); assert_eq!( MilliVec::from_velocity_per_second(spawned.velocity), MilliVec { x: 0, y: 3_040 } ); } #[test] fn survivor_keeps_ball_two_identity_for_the_rest_of_the_callback() { let lock = LOCK_HOLES[0]; let mut game = Game::new_with_seed(1, 7); game.ball.in_launcher = false; game.ball.position = lock.center; game.ball.velocity = Vec2::ZERO; game.ball.capture_age = 300; game.record_contacts[usize::from(lock.id)] = 1; game.secondary_ball = Some(Ball { position: EFFECT_SENSOR.center, velocity: Vec2::ZERO, in_launcher: false, spin: Real48::ZERO, capture_age: 0, }); game.object_active[usize::from(EFFECT_SENSOR.id)] = true; game.multiball_state = MultiballState::Ready; game.target_effect = 7; game.timer_tick(0.050, 5, &mut Vec::new()); assert!(game.secondary_ball.is_none()); assert_eq!(game.target_effect, 0); assert!(!game.effect_target_active()); assert_eq!(game.multiball_state, MultiballState::Ready); assert!(game.wheel_holes[0]); assert!(game.ball.position.y > EFFECT_SENSOR.center.y); } #[test] fn launcher_charges_while_held_and_fires_on_release() { let mut game = Game::new(1); game.update( 1.0 / 60.0, 3, Controls { launch_down: true, ..Controls::default() }, ); assert!(game.ball.in_launcher); assert!(game.launcher_charge > 0.0); let events = game.update(1.0 / 60.0, 3, Controls::default()); assert!(events.contains(&Event::Launch)); assert!(!game.ball.in_launcher); assert!(game.ball.velocity.y < 0.0); } #[test] fn holding_the_launcher_produces_a_faster_shot() { let mut quick = Game::new_with_seed(1, 7); let held = Controls { launch_down: true, ..Controls::default() }; quick.update(0.0, 3, held); quick.update(0.0, 3, Controls::default()); let quick_speed = -quick.ball.velocity.y; assert_eq!( MilliVec::from_velocity_per_second(quick.ball.velocity), MilliVec { x: 0, y: -2_270 } ); let mut charged = Game::new_with_seed(1, 7); launch_ball(&mut charged, 60); let charged_speed = -charged.ball.velocity.y; assert!(charged_speed > quick_speed + 80.0); } #[test] fn maximum_launch_requires_a_deliberate_hold() { let mut game = Game::new(1); let held = Controls { launch_down: true, ..Controls::default() }; for _ in 0..30 { game.update(1.0 / 60.0, 3, held); } assert!((0.19..0.21).contains(&game.launcher_charge)); assert_eq!(game.launcher_frame(), 2); for _ in 0..30 { game.update(1.0 / 60.0, 3, held); } assert!(game.launcher_charge >= 0.95); assert_eq!(game.launcher_frame(), 10); } #[test] fn maximum_launch_uses_the_initial_random_seed_for_trajectory_variation() { let mut first = Game::new_with_seed(1, 1); let mut second = Game::new_with_seed(1, 2); launch_ball(&mut first, 60); launch_ball(&mut second, 60); assert_ne!(first.ball.velocity, second.ball.velocity); } #[test] fn charged_ball_clears_the_shooter_lane() { for detail in 1..=5 { let mut game = Game::new_with_seed(1, u32::from(detail)); launch_ball(&mut game, 60); let mut entered_table = false; let mut returned_to_launcher = false; let mut minimum_y = game.ball.position.y; for _ in 0..480 { game.update(1.0 / 120.0, detail, Controls::default()); minimum_y = minimum_y.min(game.ball.position.y); if game.ball.position.x < 280.0 && game.ball.position.y > 30.0 { entered_table = true; } returned_to_launcher |= game.ball.in_launcher && !entered_table; } assert!( entered_table, "detail {detail}: the shooter curve should guide the ball onto the table; position={:?}, velocity={:?}, minimum_y={minimum_y}", game.ball.position, game.ball.velocity ); assert!( !returned_to_launcher, "detail {detail}: a launched ball must not rebound into the launcher" ); } } #[test] fn shooter_stop_rearms_a_returning_ball() { let mut game = Game::new(1); game.ball.in_launcher = false; game.ball.position = Vec2::new(325.0, 410.0); game.ball.velocity = Vec2::new(0.0, 80.0); for _ in 0..8 { game.update(1.0 / 120.0, 5, Controls::default()); if game.ball.in_launcher { break; } } assert!(game.ball.in_launcher); assert_eq!(game.ball.position, LAUNCHER_POSITION); assert_eq!(game.ball.velocity, Vec2::ZERO); } #[test] fn fast_shooter_stop_contact_bounces_without_rearming() { let mut game = Game::new(1); game.object_active.fill(false); game.object_active[25] = true; game.ball.in_launcher = false; game.ball.position = Vec2::new(320.0, 412.0); game.ball.velocity = MilliVec { x: 500, y: 1_000 }.to_velocity_per_second(); assert!(game.fixed_update(&mut Vec::new())); assert!(!game.ball.in_launcher); assert!(game.ball.velocity.y < 0.0); assert_eq!(MilliVec::from_velocity_per_second(game.ball.velocity).x, 0); assert_eq!(MilliVec::from_position(game.ball.position).x, 320_000); } #[test] fn ball_reset_clears_tilt_state_before_the_next_launch() { let mut game = Game::new(1); game.tilted = true; game.tilt_counter = 39; game.reset_ball_to_launcher(); assert!(!game.tilted); assert_eq!(game.tilt_counter, 0); assert!(game.ball.in_launcher); } #[test] fn ninth_diamond_and_followup_banks_match_original_awards() { let mut game = Game::new(1); game.players[0].diamond_segments = 8; let mut events = Vec::new(); assert_eq!(game.ball_double, BallDoubleState::Inactive); assert!(!game.magnetic_field_active(153)); assert!(!game.magnetic_field_active(6)); assert!(!game.magnetic_field_active(154)); for object_id in [109, 112, 115, 118] { game.apply_wall_rule(object_id, &mut events); } assert_eq!(game.player().diamond_segments, 9); assert_eq!(game.player().score, 3 * 1_500 + 24_464 + 2 * 1_500); assert_eq!(game.ball_double, BallDoubleState::Active); assert!(game.magnetic_field_active(153)); assert!(game.magnetic_field_active(6)); assert!(game.magnetic_field_active(154)); for object_id in [109, 112, 115, 118] { game.apply_wall_rule(object_id, &mut events); } assert_eq!(game.player().score, 3 * 1_500 + 24_464 + 10 * 1_500); assert_eq!(game.player().secondary_score, 100_000); for object_id in [109, 112, 115, 118] { game.apply_wall_rule(object_id, &mut events); } assert_eq!(game.player().secondary_score, 200_000); } #[test] fn score_helper_stacks_multiball_and_ball_double_and_honors_tilt() { let mut game = Game::new(1); let mut events = Vec::new(); game.score_mode = ScoreMode::Multiball; game.add_score(100, &mut events); assert_eq!(game.player().score, 200); game.ball_double = BallDoubleState::Active; game.add_score(100, &mut events); assert_eq!(game.player().score, 600); game.tilted = true; game.add_score(100, &mut events); assert_eq!(game.player().score, 600); } #[test] fn double_score_resets_on_normal_ball_end_but_survives_special_respawn() { let mut normal = Game::new(1); normal.players[0].diamond_segments = 9; normal.players[0].secondary_score = 200_000; normal.ball_double = BallDoubleState::Active; normal.drain(&mut Vec::new()); assert_eq!(normal.ball_double, BallDoubleState::Inactive); for object_id in [109, 112, 115, 118] { normal.apply_wall_rule(object_id, &mut Vec::new()); } assert_eq!(normal.ball_double, BallDoubleState::Active); assert_eq!(normal.player().secondary_score, 200_000); let mut special = Game::new(1); special.ball_double = BallDoubleState::Active; special.multiball_state = MultiballState::Ready; special.record_contacts[148] = 2; special.drain(&mut Vec::new()); assert_eq!(special.ball_double, BallDoubleState::Active); assert_eq!(special.ball.position, vec2(17.0, 23.0)); } #[test] fn tilted_collision_dispatch_leaves_rule_records_unchanged() { let mut game = Game::new(1); game.tilted = true; let mut events = Vec::new(); game.apply_wall_rule(90, &mut events); game.apply_wall_rule(84, &mut events); assert!(game.object_active[90]); assert_eq!(game.target_effect, 0); assert!(!game.effect_target_active()); assert_eq!(game.player().score, 0); assert!(events.is_empty()); } #[test] fn tilted_type_three_completion_finishes_or_special_respawns() { let mut lock = Game::new(1); lock.tilted = true; lock.ball.in_launcher = false; lock.ball.position = LOCK_HOLES[0].center; lock.ball.velocity = Vec2::ZERO; lock.ball.capture_age = 300; let mut lock_events = Vec::new(); assert_eq!( lock.check_sensor_objects( MilliVec::from_position(LOCK_HOLES[0].center), MilliVec::default(), &mut lock_events, ), BallAction::Finish ); assert_eq!(lock.player().balls, 2); assert_eq!(lock.player().secondary_score, 10_000); assert!(lock.ball.in_launcher); assert!(!lock.tilted); assert!(lock_events.contains(&Event::Drain)); assert!(!lock_events.contains(&Event::Sound(2008))); let mut special = Game::new(1); special.tilted = true; special.ball.in_launcher = false; special.ball.position = SPECIAL_HOLE_SENSOR.center; special.ball.velocity = Vec2::ZERO; special.ball.capture_age = 300; let balls = special.player().balls; let mut special_events = Vec::new(); assert_eq!( special.check_sensor_objects( MilliVec::from_position(SPECIAL_HOLE_SENSOR.center), MilliVec::default(), &mut special_events, ), BallAction::Finish ); assert_eq!(special.player().balls, balls); assert_eq!(special.ball.position, vec2(17.0, 23.0)); assert!(special.tilted); assert_eq!(special.multiball_state, MultiballState::Unavailable); assert_eq!(special.record_contacts[148], 0); assert!(!special_events.contains(&Event::Drain)); assert!( !special_events .iter() .any(|event| event.sound_resource().is_some()) ); } #[test] fn recovered_target_banks_deactivate_and_rearm_their_line_groups() { let mut game = Game::new(1); let mut events = Vec::new(); for object_id in [90, 93, 96, 99] { game.apply_wall_rule(object_id, &mut events); } assert!(game.bank_target_lit(90)); assert!(game.bank_target_lit(99)); assert!(!game.bank_target_lit(102)); game.apply_wall_rule(102, &mut events); assert_eq!(game.player().bumper_value, 2_000); assert!(game.object_active[90..=104].iter().all(|active| *active)); assert!(!game.bank_target_lit(90)); for object_id in [109, 112, 115] { game.apply_wall_rule(object_id, &mut events); } assert!(game.bank_target_lit(109)); assert!(game.bank_target_lit(115)); assert!(!game.bank_target_lit(118)); game.apply_wall_rule(118, &mut events); assert_eq!(game.player().diamond_segments, 1); assert!(game.object_active[109..=120].iter().all(|active| *active)); assert!(!game.bank_target_lit(109)); } #[test] fn effect_selector_activates_and_consumes_record_149() { let mut game = Game::new_with_seed(1, 7); let mut events = Vec::new(); game.apply_wall_rule(84, &mut events); assert!((1..=6).contains(&game.target_effect)); assert!(game.effect_target_active()); game.target_effect = 3; assert_eq!(game.player_effect(), 3); game.ball.in_launcher = false; game.ball.position = EFFECT_SENSOR.center; game.check_sensor_objects( MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::default(), &mut events, ); assert_eq!(game.player().secondary_score, 50_000); assert_eq!(game.secondary_score_display(), 50_000); assert_eq!(game.player().score, 2_000); assert_eq!(game.target_effect, 0); assert_eq!(game.player_effect(), 0); assert!(!game.effect_target_active()); } #[test] fn media_extra_balls_use_the_recovered_threshold_table() { let mut game = Game::new(1); let mut events = Vec::new(); for (expected_level, threshold) in [140_000, 650_000, 1_300_000, 4_000_000] .into_iter() .enumerate() { game.players[0].score = threshold - 1; assert_eq!(usize::from(game.player().media_level), expected_level); game.add_score(1, &mut events); assert_eq!(usize::from(game.player().media_level), expected_level + 1); } assert_eq!(game.player().extra_balls, 4); assert_eq!( events .iter() .filter(|event| **event == Event::ExtraBall) .count(), 4 ); assert_eq!( events .iter() .filter_map(|event| event.sound_resource()) .collect::>(), [2007; 4] ); let mut wrapped = Game::new(1); wrapped.players[0].score = 0x8000_0000; let mut wrapped_events = Vec::new(); wrapped.add_score(0, &mut wrapped_events); assert_eq!(wrapped.player().media_level, 0); assert_eq!(wrapped.player().extra_balls, 0); assert!(wrapped_events.is_empty()); } #[test] fn type_four_hit_sound_precedes_a_crossed_media_marker() { let mut target_game = Game::new_with_seed(1, 7); target_game.players[0].score = 139_500; let sensor = TARGET_SENSORS .into_iter() .find(|sensor| sensor.id == 150) .expect("record 150 must be present"); let mut target_ball = target_game.ball; target_ball.position = sensor.center; let mut target_events = Vec::new(); target_game.check_target_sensors( &mut target_ball, MilliVec::from_position(sensor.center), MilliVec::default(), 150..=152, &mut target_events, ); assert_eq!( target_events, [ Event::Target, Event::Sound(2004), Event::Media, Event::ExtraBall, Event::Sound(2007), ] ); let mut effect_game = Game::new_with_seed(1, 7); effect_game.players[0].score = 139_500; effect_game.begin_effect_scenario(1); let mut effect_ball = effect_game.ball; effect_ball.position = EFFECT_SENSOR.center; let mut effect_events = Vec::new(); effect_game.check_effect_sensor( &mut effect_ball, 1, MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::default(), &mut effect_events, ); assert_eq!( effect_events, [ Event::Target, Event::Sound(2004), Event::Media, Event::ExtraBall, Event::Sound(2007), ] ); } #[test] fn type_four_motion_response_refreshes_the_remaining_prediction() { let sensor = TARGET_SENSORS .into_iter() .find(|sensor| sensor.id == 150) .expect("record 150 must be present"); let mut game = Game::new_with_seed(1, 7); game.object_active.fill(false); game.object_active[usize::from(sensor.id)] = true; let old_position = MilliVec::from_position(sensor.center - vec2(1.0, 0.0)); let mut ball = Ball { position: old_position.to_position(), velocity: MilliVec { x: 100, y: 0 }.to_velocity_per_second(), in_launcher: false, spin: Real48::ZERO, capture_age: 0, }; let initial_velocity = MilliVec::from_velocity_per_second(ball.velocity); let mut velocity = initial_velocity; let mut events = Vec::new(); let (_, _, predicted) = game.scan_ordered_records_for_ball( &mut ball, 1, 1, old_position, &mut velocity, &mut events, ); let updated_velocity = MilliVec::from_velocity_per_second(ball.velocity); assert_ne!(updated_velocity, initial_velocity); assert_eq!(predicted, old_position.add(updated_velocity)); } #[test] fn bumper_sound_precedes_a_crossed_media_marker() { let mut game = Game::new(1); game.players[0].score = 139_000; let mut events = Vec::new(); game.apply_bumper_rule(51, true, &mut events); assert_eq!(game.record_countdown(51), 5); assert_eq!(game.player().score, 140_000); assert_eq!( events, [ Event::Bumper, Event::Sound(2006), Event::Media, Event::ExtraBall, Event::Sound(2007), ] ); } #[test] fn bank_completion_sounds_precede_crossed_media_markers() { let mut bumper_bank = Game::new(1); bumper_bank.players[0].score = 90_000; bumper_bank.players[0].bumper_value = 6_000; for object_id in [90, 93, 96, 99] { bumper_bank.object_active[object_id] = false; } let mut bumper_events = Vec::new(); bumper_bank.apply_wall_rule(102, &mut bumper_events); assert_eq!(bumper_bank.player().score, 141_500); assert_eq!( bumper_events .iter() .filter_map(|event| event.sound_resource()) .collect::>(), [2012, 2017, 2007] ); let mut diamond_bank = Game::new(1); diamond_bank.players[0].score = 130_000; for object_id in [109, 112, 115] { diamond_bank.object_active[object_id] = false; } let mut diamond_events = Vec::new(); diamond_bank.apply_wall_rule(118, &mut diamond_events); assert_eq!(diamond_bank.player().score, 141_500); assert_eq!( diamond_events .iter() .filter_map(|event| event.sound_resource()) .collect::>(), [2012, 2017, 2007] ); } #[test] fn special_hole_arms_effect_seven_multiball() { let mut game = Game::new_with_seed(1, 7); let mut events = Vec::new(); game.multiball_state = MultiballState::Ready; game.record_contacts[148] = 2; game.apply_wall_rule(84, &mut events); assert_eq!(game.target_effect, 7); game.ball.in_launcher = false; game.ball.position = EFFECT_SENSOR.center; game.check_sensor_objects( MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::default(), &mut events, ); let spawned = game .secondary_ball .expect("effect seven should spawn a second ball"); assert_eq!(game.multiball_state, MultiballState::Active); assert!(!game.special_hole_active()); assert_eq!(spawned.position, vec2(17.0, 23.0)); assert_eq!( MilliVec::from_velocity_per_second(spawned.velocity), MilliVec { x: 0, y: 3_040 } ); game.advance_secondary_ball(&mut events); let moving_ball = game .secondary_ball .expect("second ball should remain active"); assert!(moving_ball.position.y > spawned.position.y); let balls_before = game.player().balls; game.drain(&mut events); assert!(game.secondary_ball.is_none()); assert_eq!(game.player().balls, balls_before); assert_eq!(game.ball.position, moving_ball.position); events.clear(); game.drain(&mut events); assert!(events.is_empty()); assert_eq!(game.player().balls, balls_before); assert_eq!(game.ball.position, vec2(17.0, 23.0)); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec { x: 0, y: 3_040 } ); assert_eq!(game.multiball_state, MultiballState::Unavailable); assert!(!game.special_hole_active()); assert_eq!(game.record_contacts[148], 0); assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed); game.fixed_update(&mut events); assert!(!game.ball.in_launcher); assert_eq!( MilliVec::from_position(game.ball.position), MilliVec { x: 17_000, y: 26_055, } ); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec { x: 0, y: 3_055 } ); assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed); game.ball.position = vec2(17.0, 31.0); game.ball.velocity = Vec2::ZERO; game.fixed_update(&mut events); assert_eq!(game.special_hole_gate, SpecialHoleGate::Enabled); } #[test] fn release_ball_consumes_the_special_hole_and_does_not_rearm_it() { let mut game = Game::new_with_seed(1, 7); let mut events = Vec::new(); game.multiball_state = MultiballState::Ready; game.record_contacts[148] = 2; game.apply_wall_rule(84, &mut events); assert_eq!(game.target_effect, 7); assert!(game.special_hole_active()); game.ball.in_launcher = false; game.ball.position = EFFECT_SENSOR.center; game.check_sensor_objects( MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::default(), &mut events, ); assert!(game.secondary_ball.is_some()); assert_eq!(game.target_effect, 0); assert!(!game.special_hole_active()); for _ in 0..4 { game.advance_secondary_ball(&mut events); } assert_eq!(game.record_contacts[148], 0); game.drain(&mut events); assert!(game.secondary_ball.is_none()); game.drain(&mut events); assert!(game.ball.in_launcher); assert_eq!(game.player().balls, 2); assert_eq!(game.multiball_state, MultiballState::Unavailable); events.clear(); game.apply_wall_rule(84, &mut events); assert!((1..=6).contains(&game.target_effect)); assert!(game.effect_target_active()); game.target_effect = 7; game.trigger_flags[usize::from(EFFECT_SENSOR.id)] = false; game.ball.position = EFFECT_SENSOR.center; game.check_sensor_objects( MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::default(), &mut events, ); assert!(game.secondary_ball.is_none()); } #[test] fn record_two_response_continues_after_special_respawn_like_live_original() { let mut game = Game::new(1); game.multiball_state = MultiballState::Active; game.record_contacts[148] = 2; game.ball.in_launcher = false; game.ball.position = vec2(157.0, 453.0); game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second(); assert!(game.fixed_update(&mut Vec::new())); assert_eq!( MilliVec::from_position(game.ball.position), MilliVec { x: 16_698, y: 21_191, } ); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec { x: -302, y: -1_809 } ); assert_eq!(game.multiball_state, MultiballState::Unavailable); assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed); } #[test] fn armed_effect_seven_also_special_respawns_before_multiball_starts() { let mut game = Game::new(1); game.multiball_state = MultiballState::Ready; game.record_contacts[148] = 2; let balls = game.player().balls; let mut events = Vec::new(); game.drain(&mut events); assert!(events.is_empty()); assert_eq!(game.player().balls, balls); assert_eq!(game.ball.position, vec2(17.0, 23.0)); assert_eq!(game.multiball_state, MultiballState::Unavailable); assert_eq!(game.record_contacts[148], 0); assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed); } #[test] fn second_ball_runs_the_same_type_four_rule_dispatch() { let sensor = TARGET_SENSORS .into_iter() .find(|sensor| sensor.id == 150) .expect("record 150 must be present"); let mut game = Game::new_with_seed(1, 7); let primary_velocity = game.ball.velocity; let mut second = Ball { position: sensor.center, velocity: vec2(12.0, -34.0), in_launcher: false, spin: Real48::ZERO, capture_age: 0, }; let old_position = MilliVec::from_position(sensor.center - vec2(sensor.radius + 1.0, 0.0)); let current_position = MilliVec::from_position(sensor.center); let movement = MilliVec { x: current_position.x.wrapping_sub(old_position.x), y: current_position.y.wrapping_sub(old_position.y), }; let mut events = Vec::new(); assert_eq!( game.check_sensor_objects_for_ball( &mut second, 2, 2, old_position, movement, &mut events, ) .action, BallAction::Keep ); assert!(game.top_targets[0]); assert!(game.magnetic_field_active(153)); assert_eq!(game.player().score, sensor.score); assert_eq!(game.ball.velocity, primary_velocity); assert_ne!(second.velocity, vec2(12.0, -34.0)); } #[test] fn ball_two_consumes_effect_seven_without_spawning_a_third_ball() { let mut game = Game::new_with_seed(1, 7); game.multiball_state = MultiballState::Ready; game.target_effect = 7; game.object_active[usize::from(EFFECT_SENSOR.id)] = true; let mut second = Ball { position: EFFECT_SENSOR.center, velocity: Vec2::ZERO, in_launcher: false, spin: Real48::ZERO, capture_age: 0, }; let mut events = Vec::new(); assert_eq!( game.check_sensor_objects_for_ball( &mut second, 2, 2, MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::default(), &mut events, ) .action, BallAction::Keep ); assert!(game.secondary_ball.is_none()); assert_eq!(game.multiball_state, MultiballState::Ready); assert_eq!(game.target_effect, 0); assert!(!game.effect_target_active()); } #[test] fn rearmed_effect_seven_does_not_replace_an_active_secondary_ball() { let mut game = Game::new_with_seed(1, 7); game.multiball_state = MultiballState::Active; game.target_effect = 7; game.object_active[usize::from(EFFECT_SENSOR.id)] = true; let secondary = Ball { position: vec2(200.0, 200.0), velocity: vec2(-12.0, 34.0), in_launcher: false, spin: Real48::ZERO, capture_age: 0, }; game.secondary_ball = Some(secondary); let mut primary = game.ball; let mut events = Vec::new(); assert_eq!( game.check_sensor_objects_for_ball( &mut primary, 1, 2, MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::default(), &mut events, ) .action, BallAction::Keep ); assert_eq!( game.secondary_ball.map(|ball| ball.position), Some(secondary.position) ); assert_eq!(game.multiball_state, MultiballState::Active); assert_eq!(game.target_effect, 0); } #[test] fn multiball_capture_age_is_per_slot_and_removes_only_ball_two() { let sensor = LOCK_HOLES[0]; let mut game = Game::new(1); game.ball.capture_age = 17; game.record_contacts[usize::from(sensor.id)] = 2; game.secondary_ball = Some(Ball { position: sensor.center, velocity: Vec2::ZERO, in_launcher: false, 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)], 99); 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_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, 2); } #[test] fn capturing_ball_one_during_multiball_promotes_ball_two() { let sensor = LOCK_HOLES[0]; let mut game = Game::new(1); 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; let survivor = Ball { position: vec2(210.0, 240.0), velocity: vec2(-12.0, 34.0), in_launcher: false, spin: Real48::ZERO, capture_age: 23, }; game.secondary_ball = Some(survivor); let mut events = Vec::new(); game.check_sensor_objects( MilliVec::from_position(sensor.center), MilliVec::default(), &mut events, ); assert!(game.secondary_ball.is_none()); assert_eq!(game.ball.position, survivor.position); assert_eq!(game.ball.velocity, survivor.velocity); assert_eq!(game.ball.capture_age, survivor.capture_age); assert_eq!(game.record_contacts[usize::from(sensor.id)], 99); assert!(game.wheel_holes[0]); } #[test] fn occupied_multiball_wheel_hole_rejects_the_surviving_ball() { let sensor = LOCK_HOLES[0]; let mut game = Game::new(1); 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)] = 99; game.wheel_holes[0] = true; let mut events = Vec::new(); let action = game.check_sensor_objects( MilliVec::from_position(sensor.center), MilliVec::default(), &mut events, ); assert_eq!(action, BallAction::Keep); assert_eq!(game.record_contacts[usize::from(sensor.id)], 99); assert_eq!(game.player().secondary_score, 0); assert!(!events.contains(&Event::Lock)); assert!(!game.ball.in_launcher); } #[test] fn center_drain_advances_to_a_fresh_ball() { let mut game = Game::new(1); game.ball.in_launcher = false; game.ball.position = vec2(157.0, 450.0); game.ball.velocity = vec2(0.0, 300.0); let events = game.update(1.0 / 30.0, 5, Controls::default()); assert!(events.contains(&Event::Drain)); assert_eq!(game.players[0].balls, 2); assert!(game.ball.in_launcher); } #[test] fn four_sided_prediction_bounds_finish_the_ball_before_record_scan() { for (position, velocity) in [ (vec2(1.0, 200.0), MilliVec { x: -2_000, y: 0 }), (vec2(339.0, 200.0), MilliVec { x: 2_000, y: 0 }), (vec2(200.0, 1.0), MilliVec { x: 0, y: -2_000 }), (vec2(200.0, 459.0), MilliVec { x: 0, y: 2_000 }), ] { let mut game = Game::new(1); game.object_active.fill(false); game.ball.in_launcher = false; game.ball.position = position; game.ball.velocity = velocity.to_velocity_per_second(); let mut events = Vec::new(); assert!(game.fixed_update(&mut events)); assert!(game.ball.in_launcher); assert_eq!(game.player().balls, 2); assert!(events.contains(&Event::Drain)); } } #[test] fn prediction_bounds_remove_only_the_escaping_multiball_slot() { let mut game = Game::new(1); game.object_active.fill(false); game.ball.in_launcher = false; game.ball.position = vec2(200.0, 200.0); let second = Ball { position: vec2(1.0, 200.0), velocity: MilliVec { x: -2_000, y: 0 }.to_velocity_per_second(), in_launcher: false, spin: Real48::ZERO, capture_age: 0, }; game.secondary_ball = Some(second); game.score_mode = ScoreMode::Multiball; assert!(game.advance_secondary_ball(&mut Vec::new())); assert!(game.secondary_ball.is_none()); assert_eq!(game.player().balls, 3); assert_eq!(game.score_mode, ScoreMode::Normal); } #[test] fn player_turns_save_and_restore_all_rule_record_state() { let mut game = Game::new(2); game.wheel_holes[2] = true; game.top_targets[1] = true; game.record_contacts[129] = 99; game.trigger_flags[149] = true; game.object_active[90] = false; game.target_effect = 3; game.drain(&mut Vec::new()); assert_eq!(game.current_player, 1); assert_eq!(game.wheel_holes, [false; 5]); assert_eq!(game.top_targets, [false; 3]); assert_eq!(game.record_contacts[129], 0); assert!(!game.trigger_flags[149]); assert!(game.object_active[90]); assert_eq!(game.target_effect, 0); assert_eq!(game.multiball_state, MultiballState::Unavailable); game.wheel_holes[4] = true; game.drain(&mut Vec::new()); assert_eq!(game.current_player, 0); assert!(game.wheel_holes[2]); assert!(!game.wheel_holes[4]); assert!(game.top_targets[1]); assert_eq!(game.record_contacts[129], 99); assert!(!game.trigger_flags[149]); assert!(!game.object_active[90]); assert_eq!(game.target_effect, 3); assert_eq!(game.multiball_state, MultiballState::Unavailable); } #[test] fn each_player_reports_a_high_score_candidate_on_their_last_ball() { let mut game = Game::new(2); game.players[0].balls = 1; game.players[0].score = 123_456; let mut events = Vec::new(); game.drain(&mut events); assert!(events.contains(&Event::HighScoreCandidate(123_456))); assert_eq!(game.current_player, 1); assert!(!game.finished); events.clear(); game.players[1].balls = 1; game.players[1].score = 654_321; game.drain(&mut events); assert!(events.contains(&Event::HighScoreCandidate(654_321))); assert!(game.finished); } #[test] fn fast_ball_cannot_tunnel_through_the_top_rail() { let mut game = Game::new(1); game.ball.in_launcher = false; game.ball.position = vec2(270.0, 30.0); game.ball.velocity = vec2(0.0, -380.0); for _ in 0..5 { game.fixed_update(&mut Vec::new()); } assert!(game.ball.position.y >= 15.0); assert!(game.ball.velocity.y > 0.0); } #[test] fn relative_record_57_matches_live_two_substep_transition() { let mut game = Game::new(1); game.ball.in_launcher = false; game.ball.position = vec2(72.530, 354.750); game.ball.velocity = MilliVec { x: 3_000, y: 0 }.to_velocity_per_second(); let mut events = Vec::new(); assert!(!game.fixed_update(&mut events)); assert_eq!( MilliVec::from_position(game.ball.position), MilliVec { x: 75_530, y: 354_765, } ); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec { x: 3_000, y: 15 } ); assert!(game.fixed_update(&mut events)); assert_eq!(game.last_collision_id, Some(57)); assert_eq!(game.ball.spin.bytes(), [0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8]); assert_eq!( MilliVec::from_position(game.ball.position), MilliVec { x: 77_369, y: 356_597, } ); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec { x: 1_839, y: 1_832 } ); } #[test] fn relative_record_72_matches_live_two_substep_transition() { let mut game = Game::new(1); game.ball.in_launcher = false; game.ball.position = vec2(238.348, 356.810); game.ball.velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second(); let mut events = Vec::new(); assert!(!game.fixed_update(&mut events)); assert_eq!( MilliVec::from_position(game.ball.position), MilliVec { x: 235_348, y: 356_825, } ); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec { x: -3_000, y: 15 } ); assert!(game.fixed_update(&mut events)); assert_eq!(game.last_collision_id, Some(72)); assert_eq!(game.ball.spin.bytes(), [0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e]); assert_eq!( MilliVec::from_position(game.ball.position), MilliVec { x: 233_253, y: 358_494, } ); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec { x: -2_095, y: 1_669 } ); } #[test] fn retained_collision_candidate_clears_the_slot_capture_age() { let mut game = Game::new(1); game.ball.in_launcher = false; game.ball.position = vec2(270.0, 30.0); game.ball.velocity = vec2(0.0, -380.0); game.ball.capture_age = 42; for _ in 0..5 { if game.fixed_update(&mut Vec::new()) { break; } } assert_eq!(game.ball.capture_age, 0); } #[test] fn collision_scan_selects_the_layer_from_the_previous_y_position() { let mut game = Game::new(1); game.object_active.fill(false); game.object_active[48] = true; let velocity = MilliVec { x: 0, y: -15_000 }; assert_eq!( game.find_static_collision_candidate( MilliVec { x: 25_000, y: 249_000, }, velocity, ) .map(|collision| collision.0), Some(48) ); assert!( game.find_static_collision_candidate( MilliVec { x: 25_000, y: 250_000, }, velocity, ) .is_none() ); assert!( game.find_static_collision_candidate( MilliVec { x: 25_000, y: 249_000, }, MilliVec { x: 0, y: -40_000 }, ) .is_none(), "the original predicted-position broadphase rejects an overshoot" ); } #[test] fn first_detected_record_wins_even_when_a_later_candidate_is_nearer() { let old = MilliVec::default(); let velocity = MilliVec { x: 10_000, y: 0 }; let predicted = old.add(velocity); let material = CollisionMaterial::line(0.6, 0.1); let first = line_collision_candidate_at( old, predicted, velocity, vec2(8.0, 1.0), vec2(8.0, -1.0), material, ) .expect("the first record must detect the farther rail"); let later = line_collision_candidate_at( old, predicted, velocity, vec2(2.0, 1.0), vec2(2.0, -1.0), material, ) .expect("the later record must detect the nearer rail"); assert!(later.surface_distance < first.surface_distance); let mut retained = None; retain_first_collision(&mut retained, (10, true, first)); retain_first_collision(&mut retained, (20, true, later)); let retained = retained.expect("one candidate must remain"); assert_eq!(retained.0, 10); assert_eq!(retained.2.surface_distance, 8_000); } #[test] fn bumper_base_score_and_kick_require_the_recovered_speed_threshold() { let mut weak = Game::new(1); weak.object_active.fill(false); weak.object_active[51] = true; weak.players[0].bumper_value = 2_000; weak.ball.in_launcher = false; weak.ball.position = vec2(165.0, 171.1); weak.ball.velocity = vec2(0.0, -20.0); let mut weak_events = Vec::new(); assert!(weak.fixed_update(&mut weak_events)); let mut fast = Game::new(1); fast.object_active.fill(false); fast.object_active[51] = true; fast.players[0].bumper_value = 2_000; fast.ball.in_launcher = false; fast.ball.position = vec2(165.0, 172.015); fast.ball.velocity = vec2(0.0, -198.5); let mut tilted = fast.clone(); tilted.tilted = true; let mut fast_events = Vec::new(); assert!(fast.fixed_update(&mut fast_events)); let mut tilted_events = Vec::new(); assert!(tilted.fixed_update(&mut tilted_events)); assert_eq!(weak.player().score, 1_000); assert_eq!(fast.player().score, 2_000); assert!(weak_events.contains(&Event::Sound(2006))); assert!(fast_events.contains(&Event::Sound(2006))); assert_eq!(weak.record_countdown(51), 5); assert_eq!(fast.record_countdown(51), 5); assert!(fast.ball.velocity.y > weak.ball.velocity.y); assert_eq!(tilted.player().score, 0); assert_eq!(tilted.record_countdown(51), 0); assert!(!tilted_events.contains(&Event::Sound(2006))); assert!(fast.ball.velocity.y > tilted.ball.velocity.y); } #[test] fn tilt_latches_and_disables_flippers_and_scoring() { let mut game = Game::new_with_seed(1, 7); assert!(launch_ball(&mut game, 1).contains(&Event::Launch)); game.players[0].secondary_score = 4_000; for _ in 0..2 { game.update( 0.0, 3, Controls { nudge: Nudge::Right, ..Controls::default() }, ); } assert!(game.tilted); assert_eq!(game.player().secondary_score, 4_000); game.flippers.left_raised = true; game.flippers.right_raised = true; let score = game.player().score; game.ball.in_launcher = false; game.ball.position = vec2(201.0, 285.0); game.ball.velocity = Vec2::ZERO; game.update( 1.0 / 30.0, 5, Controls { left_flipper: true, right_flipper: true, ..Controls::default() }, ); assert!(!game.flippers.left_raised); assert!(!game.flippers.right_raised); assert_eq!(game.player().score, score); } #[test] fn flippers_use_the_recovered_binary_positions() { let mut game = Game::new(1); let wall = |id| { WALLS .iter() .find(|wall| wall.id == id) .expect("flipper wall exists") .segment }; let left_leading = game.live_wall_segment(66, wall(66)); let right_leading = game.live_wall_segment(81, wall(81)); assert_eq!( (left_leading.start, left_leading.end), (vec2(112.0, 386.0), vec2(141.0, 413.0)) ); assert_eq!( (right_leading.start, right_leading.end), (vec2(216.0, 411.0), vec2(177.0, 427.0)) ); let events = game.update( 0.030, 3, Controls { left_flipper: true, right_flipper: true, ..Controls::default() }, ); assert_eq!( events .iter() .filter(|event| **event == Event::FlipperMove) .count(), 2 ); let left_leading = game.live_wall_segment(66, wall(66)); let left_trailing = game.live_wall_segment(68, wall(68)); let right_leading = game.live_wall_segment(81, wall(81)); let right_trailing = game.live_wall_segment(83, wall(83)); assert_eq!( (left_leading.start, left_leading.end), (vec2(98.0, 382.0), vec2(131.0, 369.0)) ); assert_eq!( (left_trailing.start, left_trailing.end), (vec2(137.0, 384.0), vec2(116.0, 405.0)) ); assert_eq!( game.live_circle_center(67, vec2(134.0, 419.0)), vec2(133.0, 377.0) ); assert_eq!( (right_leading.start, right_leading.end), (vec2(197.0, 405.0), vec2(171.0, 378.0)) ); assert_eq!( (right_trailing.start, right_trailing.end), (vec2(183.0, 368.0), vec2(217.0, 383.0)) ); assert_eq!( game.live_circle_center(82, vec2(179.0, 419.0)), vec2(181.0, 376.0) ); assert_eq!( game.live_circle_bounds(67, vec2(133.0, 377.0), 9.0), ( MilliVec { x: 118_000, y: 362_000, }, MilliVec { x: 150_000, y: 476_000, }, ) ); assert_eq!( game.live_circle_bounds(82, vec2(181.0, 376.0), 9.0), ( MilliVec { x: 167_000, y: 362_000, }, MilliVec { x: 191_000, y: 476_000, }, ) ); } #[test] fn raised_flipper_tips_use_their_asymmetric_swept_bounds() { for (object_id, center) in [(67_u8, vec2(133.0, 377.0)), (82, vec2(181.0, 376.0))] { let mut game = Game::new(1); game.object_active.fill(false); game.object_active[usize::from(object_id)] = true; if object_id == 67 { game.flippers.left_raised = true; } else { game.flippers.right_raised = true; } let old_position = MilliVec::from_position(vec2(center.x, center.y + 12.0)); let velocity = MilliVec { x: 0, y: -3_800 }; // The scanner carries prediction separately from mutable motion; // keep this point outside the old radius box while the motion // still approaches the tip. let predicted = old_position.add(MilliVec { x: 0, y: 4_000 }); let generic_max_y = MilliVec::from_position(vec2(center.x, center.y + 14.0)).y; assert!(predicted.y > generic_max_y); assert_eq!( game.find_static_collision_candidate_in_range( old_position, predicted, velocity, object_id..=object_id, ) .map(|candidate| candidate.0), Some(object_id), "the original swept tip bounds must admit the path for record {object_id}" ); } } #[test] fn each_flipper_edge_has_the_recovered_movement_sound() { let mut game = Game::new(1); let raised = Controls { left_flipper: true, right_flipper: true, ..Controls::default() }; assert!(game.update(0.029, 3, raised).is_empty()); let press_events = game.update(0.001_1, 3, raised); assert_eq!( press_events, [ Event::FlipperMove, Event::Sound(2021), Event::FlipperMove, Event::Sound(2021), ], "each original move_flipper call starts WAVE 2021" ); assert!(game.update(0.030, 3, raised).is_empty()); let release_events = game.update(0.030, 3, Controls::default()); assert_eq!( release_events, [ Event::FlipperMove, Event::Sound(2021), Event::FlipperMove, Event::Sound(2021), ], "the original also plays the sound while returning" ); assert_eq!(Event::FlipperMove.sound_resource(), None); assert_eq!(Event::Sound(2021).sound_resource(), Some(2021)); } #[test] fn raising_flipper_uses_the_reconstructed_moving_response() { let mut game = Game::new(1); game.ball.in_launcher = false; game.ball.position = vec2(125.0, 390.0); game.ball.velocity = Vec2::ZERO; game.pending_flipper_edges[0] = -1; game.apply_flipper_kicks(); let velocity_after_edge = game.ball.velocity; assert_eq!(game.ball.position, vec2(138.124, 355.0)); let raw_velocity = MilliVec::from_velocity_per_second(velocity_after_edge); assert_eq!( raw_velocity, MilliVec { x: 2_209, y: -5_891 } ); assert_eq!(game.pending_flipper_edges, [0, 0]); game.apply_flipper_kicks(); assert_eq!( game.ball.velocity, velocity_after_edge, "holding a raised flipper must not add another impulse" ); } #[test] fn returning_flipper_uses_the_raised_record_geometry() { let mut game = Game::new(1); game.flippers.left_raised = true; game.ball.in_launcher = false; game.ball.position = vec2(110.0, 410.0); game.ball.velocity = vec2(100.0, 200.0); let mut events = Vec::new(); game.timer_tick(0.030, 0, &mut events); assert_eq!(events, [Event::FlipperMove, Event::Sound(2021)]); let raw_velocity = MilliVec::from_velocity_per_second(game.ball.velocity); assert_eq!(raw_velocity, MilliVec { x: 765, y: 3_997 }); assert_eq!(game.ball.position, vec2(110.380, 411.984)); assert_eq!(game.pending_flipper_edges, [0, 0]); } #[test] fn recovered_control_sounds_use_the_original_resource_numbers() { for resource in [ 2002, 2006, 2007, 2008, 2011, 2012, 2015, 2016, 2019, 2020, 2021, ] { assert_eq!(Event::Sound(resource).sound_resource(), Some(resource)); } assert_eq!(Event::Launch.sound_resource(), None); assert_eq!(Event::Media.sound_resource(), None); } #[test] fn nudge_and_tilt_emit_the_original_ordered_sound_sequence() { let mut game = Game::new_with_seed(1, 7); game.ball.in_launcher = false; let controls = Controls { nudge: Nudge::Right, ..Controls::default() }; assert_eq!( game.update(0.0, 3, controls), [Event::Sound(2019), Event::Nudge] ); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec { x: 690, y: 0 } ); assert_eq!(game.tilt_counter, 25); game.tilt_counter = 15; assert_eq!( game.update(0.0, 3, controls), [Event::Sound(2019), Event::Tilt, Event::Sound(2020)] ); } #[test] fn center_nudge_and_tilt_decay_match_the_detail_timer() { let mut game = Game::new_with_seed(1, 7); let events = game.update( 0.0, 3, Controls { nudge: Nudge::Center, ..Controls::default() }, ); assert_eq!(events, [Event::Sound(2019), Event::Nudge]); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec { x: -84, y: -660 } ); assert_eq!(game.tilt_counter, 25); game.update(0.029, 3, Controls::default()); assert_eq!(game.tilt_counter, 25); game.update(0.001_1, 3, Controls::default()); assert_eq!(game.tilt_counter, 24); } #[test] fn tilt_counter_decays_once_per_selected_detail_callback() { for (detail, interval) in [(1, 0.050), (2, 0.040), (3, 0.030), (4, 0.020), (5, 0.010)] { let mut game = Game::new(1); game.tilt_counter = 2; game.update(interval - 0.001, detail, Controls::default()); assert_eq!( game.tilt_counter, 2, "detail {detail} decayed before its callback" ); game.update(0.001_1, detail, Controls::default()); assert_eq!( game.tilt_counter, 1, "detail {detail} did not decay at its callback" ); } } #[test] fn multiball_nudge_consumes_active_draws_then_updates_both_saved_slots() { let mut game = Game::new_with_seed(1, 7); game.ball.in_launcher = false; game.ball.velocity = MilliVec { x: 100, y: 200 }.to_velocity_per_second(); game.secondary_ball = Some(Ball { position: vec2(200.0, 200.0), velocity: MilliVec { x: -300, y: 400 }.to_velocity_per_second(), in_launcher: false, spin: Real48::ZERO, capture_age: 0, }); let mut reference = BorlandRandom::new(7); let _discarded_active_x = reference.real48(); let _discarded_active_y = reference.below(20); let shared_impulse = MilliVec { x: (i32::from(reference.below(21)) - 10) * 15, y: -(i32::from(reference.below(100)) + 50) * 15, }; let _tilt_threshold = reference.below(10); game.apply_nudge(Nudge::Center, &mut Vec::new()); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec { x: 100 + shared_impulse.x, y: 200 + shared_impulse.y, } ); let secondary = game.secondary_ball.expect("slot two must remain active"); assert_eq!( MilliVec::from_velocity_per_second(secondary.velocity), MilliVec { x: -300 + shared_impulse.x, y: 400 + shared_impulse.y, } ); assert_eq!(game.random.seed(), reference.seed()); } #[test] fn claw_sprite_rects_follow_the_recovered_four_row_sheet() { let source = |frame, bank| { Claw { active: true, frame, bank, ..Claw::default() } .sprite_source() .map(|rect| (rect.x, rect.y, rect.w, rect.h)) }; assert_eq!( source(1, ClawSpriteBank::Closing), Some((0.0, 0.0, 100.0, 74.0)) ); assert_eq!( source(9, ClawSpriteBank::Closing), Some((800.0, 0.0, 100.0, 74.0)) ); assert_eq!( source(10, ClawSpriteBank::Closing), Some((0.0, 74.0, 100.0, 74.0)) ); assert_eq!( source(18, ClawSpriteBank::Closing), Some((800.0, 74.0, 100.0, 74.0)) ); assert_eq!( source(1, ClawSpriteBank::Opening), Some((0.0, 148.0, 100.0, 74.0)) ); assert_eq!( source(18, ClawSpriteBank::Opening), Some((800.0, 222.0, 100.0, 74.0)) ); assert_eq!(Claw::default().sprite_source(), None); } #[test] fn claw_capture_holds_releases_and_returns_in_original_frame_order() { let mut game = Game::new(1); game.ball.in_launcher = false; game.ball.position = CLAW_TRIGGER_CENTER; game.ball.velocity = vec2(120.0, -40.0); let held_position = game.ball.position; let mut events = Vec::new(); game.begin_claw_capture(6, &mut events); assert_eq!(events, [Event::ClawCapture, Event::Sound(2015)]); assert_eq!(game.claw.frame, 10); assert_eq!(game.claw.bank, ClawSpriteBank::Closing); assert!(game.claw.ball_suspended); assert_eq!(game.ball.velocity, Vec2::ZERO); game.timer_tick(CLAW_FRAME_SECONDS / 3.0, 0, &mut events); assert_eq!(game.ball.position, held_position); assert_eq!(game.claw.frame, 10); for expected_frame in [9, 8, 7, 6] { game.update_claw(CLAW_FRAME_SECONDS, &mut events); assert_eq!(game.claw.frame, expected_frame); assert!(game.claw.ball_suspended); } game.update_claw(CLAW_FRAME_SECONDS, &mut events); assert!(events.ends_with(&[Event::ClawRelease, Event::Sound(2016)])); assert_eq!(game.claw.frame, 6); assert_eq!(game.claw.bank, ClawSpriteBank::Opening); assert!(!game.claw.ball_suspended); assert_eq!(game.ball.position, vec2(270.0, 94.0)); assert!((game.ball.velocity.x - -152.0).abs() < 0.001); assert!((game.ball.velocity.y - 228.0).abs() < 0.001); for expected_frame in [7, 8, 9, 10] { game.update_claw(CLAW_FRAME_SECONDS, &mut events); assert_eq!(game.claw.frame, expected_frame); assert!(game.claw.active); } game.update_claw(CLAW_FRAME_SECONDS, &mut events); assert_eq!(game.claw.frame, 10); 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_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true))); 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(100.0, 0.0); assert_eq!( game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Keep ); assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true))); 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_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); 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); 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)); } #[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_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true))); 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_eq!( game.claw.capture_entry, Some((ClawBallSlot::Primary, false)) ); 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() { let mut game = Game::new_with_seed(1, 7); game.begin_claw_scenario(6); let detail = u8::try_from(detail + 1).expect("five detail levels fit u8"); game.update(0.0, detail, Controls::default()); let mut events = Vec::new(); game.update_claw(frame_seconds - 0.001, &mut events); assert_eq!(game.claw.frame, 10); game.update_claw(0.001_1, &mut events); assert_eq!(game.claw.frame, 9); } } #[test] fn claw_uses_the_original_circular_trigger_not_a_broad_rectangle() { let mut game = Game::new(1); game.ball.in_launcher = false; game.ball.position = CLAW_TRIGGER_CENTER + vec2(CLAW_TRIGGER_RADIUS + 0.1, 0.0); game.check_sensor_objects( MilliVec::from_position(game.ball.position), MilliVec::default(), &mut Vec::new(), ); assert!(!game.claw.active); game.ball.position = CLAW_TRIGGER_CENTER; let mut events = Vec::new(); game.check_sensor_objects( MilliVec::from_position(game.ball.position), MilliVec::default(), &mut events, ); assert!(!game.claw.active); assert_eq!(events, [Event::Sound(2015)]); game.check_sensor_objects( MilliVec::from_position(game.ball.position), MilliVec::default(), &mut events, ); assert!(game.claw.active); assert_eq!(events, [Event::Sound(2015), Event::ClawCapture]); assert!(CLAW_TERMINAL_FRAMES.contains(&game.claw.target_frame)); } #[test] fn top_sensor_uses_the_original_score_and_contact_latch() { let mut game = Game::new_with_seed(1, 7); game.ball.in_launcher = false; game.ball.position = vec2(205.0, 55.0); game.ball.velocity = vec2(100.0, 200.0); let mut events = Vec::new(); game.check_sensor_objects( MilliVec::from_position(vec2(205.0, 60.0)), MilliVec { x: 0, y: -5_000 }, &mut events, ); assert_eq!(game.player().score, 500); assert_eq!(events, [Event::Target, Event::Sound(2004)]); assert!(game.object_active[153]); assert!(!game.object_active[6]); assert!(!game.object_active[154]); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec { x: 1_012, y: 1_926 } ); let mut field_velocity = MilliVec { x: 0, y: 2_015 }; game.apply_magnetic_fields( MilliVec { x: 15_000, y: 350_000, }, &mut field_velocity, ); assert_eq!(field_velocity, MilliVec { x: 0, y: -3_513 }); game.apply_magnetic_fields( MilliVec { x: 15_000, y: 317_000, }, &mut field_velocity, ); assert!(!game.object_active[153]); game.check_sensor_objects( MilliVec::from_position(game.ball.position), MilliVec::default(), &mut events, ); assert_eq!(game.player().score, 500, "contact must score only once"); game.ball.position = vec2(205.0, 62.0); game.check_sensor_objects( MilliVec::from_position(game.ball.position), MilliVec::default(), &mut events, ); game.ball.position = vec2(205.0, 55.0); game.check_sensor_objects( MilliVec::from_position(vec2(205.0, 60.0)), MilliVec { x: 0, y: -5_000 }, &mut events, ); assert_eq!(game.player().score, 1_000); } #[test] fn type_four_latch_is_not_cleared_by_a_ball_outside_its_broadphase() { let sensor = TARGET_SENSORS .into_iter() .find(|sensor| sensor.id == 140) .expect("record 140 must be present"); let mut game = Game::new(1); let mut primary = game.ball; primary.position = sensor.center; let mut events = Vec::new(); game.check_target_sensors( &mut primary, MilliVec::from_position(sensor.center), MilliVec::default(), 140..=147, &mut events, ); assert_eq!(game.player().score, sensor.score); let mut unrelated = primary; unrelated.position = vec2(200.0, 200.0); let unrelated_position = MilliVec::from_position(unrelated.position); game.check_target_sensors( &mut unrelated, unrelated_position, MilliVec::default(), 140..=147, &mut events, ); game.check_target_sensors( &mut primary, MilliVec::from_position(sensor.center), MilliVec::default(), 140..=147, &mut events, ); assert_eq!(game.player().score, sensor.score); } #[test] fn magnetic_rectangles_use_old_position_for_entry_and_prediction_for_exit() { let mut game = Game::new_with_seed(1, 7); game.object_active[153] = true; let seed_before = game.random.seed(); let mut entering_from_outside = MilliVec { x: 0, y: -3_000 }; game.apply_magnetic_fields( MilliVec { x: 15_000, y: 390_000, }, &mut entering_from_outside, ); assert_eq!(entering_from_outside, MilliVec { x: 0, y: -3_000 }); assert_eq!(game.random.seed(), seed_before); assert!(game.object_active[153]); let mut exiting_sideways = MilliVec { x: -2_000, y: 0 }; game.apply_magnetic_fields( MilliVec { x: 1_000, y: 350_000, }, &mut exiting_sideways, ); assert_eq!(exiting_sideways.x, -1_800); assert!(!game.object_active[153]); game.object_active[153] = true; game.tilted = true; let tilted_seed = game.random.seed(); let mut tilted_velocity = MilliVec { x: 200, y: 300 }; game.apply_magnetic_fields( MilliVec { x: 15_000, y: 350_000, }, &mut tilted_velocity, ); assert_eq!(tilted_velocity, MilliVec { x: 200, y: 300 }); assert_eq!(game.random.seed(), tilted_seed); assert!(game.object_active[153]); } #[test] fn record_six_mutates_motion_after_earlier_candidate_detection() { let mut game = Game::new_with_seed(1, 7); game.object_active.fill(false); game.object_active[5] = true; game.object_active[6] = true; game.ball.in_launcher = false; game.ball.position = vec2(149.0, 430.0); game.ball.velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second(); let old_position = MilliVec::from_position(game.ball.position); let mut expected_game = game.clone(); let mut expected_motion = MilliVec { x: -2_000, y: 15 }; let candidate = expected_game .find_static_collision_candidate_in_range( old_position, old_position.add(expected_motion), expected_motion, 1..=5, ) .expect("record five must be detected before record six"); assert_eq!(candidate.0, 5); assert!(expected_game.apply_magnetic_record(6, old_position, &mut expected_motion)); let expected_response = candidate .2 .resolve(expected_motion, expected_game.ball.spin); let mut expected_velocity = expected_response.velocity; expected_velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); assert!(game.fixed_update(&mut Vec::new())); assert_eq!(game.last_collision_id, Some(5)); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), expected_velocity ); assert_eq!(game.random.seed(), expected_game.random.seed()); } #[test] fn wheel_holes_accumulate_and_transfer_the_per_player_secondary_score() { let mut game = Game::new(1); game.ball.in_launcher = false; let mut events = Vec::new(); let expected_bonus = [10_000, 30_000, 70_000, 150_000, 0]; for (index, sensor) in LOCK_HOLES.into_iter().enumerate() { complete_stationary_type_three_capture(&mut game, sensor.center, &mut events); assert_eq!(game.player().secondary_score, expected_bonus[index]); assert!(game.ball.in_launcher); } assert_eq!(game.player().score, 310_000); assert_eq!(game.player().score_multiplier, 2); assert_eq!(game.player().extra_balls, 2); assert!(game.wheel_holes.iter().all(|filled| *filled)); assert_eq!( events.iter().filter(|event| **event == Event::Lock).count(), 5 ); complete_stationary_type_three_capture(&mut game, SPECIAL_HOLE_SENSOR.center, &mut events); assert!(game.wheel_holes.iter().all(|filled| *filled)); assert_eq!(game.multiball_state, MultiballState::Ready); assert_eq!(game.record_contacts[148], 2); assert_eq!(events.last(), Some(&Event::Wheel)); assert_eq!( events .iter() .filter(|event| **event == Event::Sound(2015)) .count(), 6 ); } #[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); game.wheel_holes = [true, true, true, true, false]; game.record_contacts[129..=133].fill(2); game.players[0].secondary_score = 150_000; game.score_mode = ScoreMode::Multiball; let mut events = Vec::new(); assert_eq!( game.complete_lock_hole(4, 2, &mut events), BallAction::Remove ); assert_eq!(game.player().score, 620_000); 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(); assert_eq!(reset_by_special_hole.panel_frame, Some(0)); game.score_mode = ScoreMode::Normal; assert_eq!( game.complete_lock_hole(4, 1, &mut events), BallAction::Reset ); assert_eq!(game.player().score, 940_000); assert_eq!(game.player().secondary_score, 0); assert_eq!(game.player().score_multiplier, 3); assert_eq!(game.panel_frame, Some(0)); } #[test] fn type_three_capture_pulls_holds_and_publishes_contact_sentinels() { let sensor = LOCK_HOLES[0]; let mut game = Game::new(1); game.ball.in_launcher = false; game.ball.position = sensor.center + vec2(5.0, 0.0); game.ball.velocity = vec2(100.0, 0.0); let previous = MilliVec::from_position(game.ball.position); let mut events = Vec::new(); assert_eq!( capture_primary_record_step( &mut game, sensor.id, sensor.center, sensor.radius, previous, &mut events, ), CaptureStep::Holding ); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec { x: 750, y: -150 } ); assert_eq!(game.record_contacts[usize::from(sensor.id)], 0); assert_eq!(game.ball.capture_age, 0); game.ball.position = sensor.center; game.ball.velocity = Vec2::ZERO; assert_eq!( capture_primary_record_step( &mut game, sensor.id, sensor.center, sensor.radius, MilliVec::from_position(sensor.center), &mut events, ), CaptureStep::Holding ); assert_eq!(game.record_contacts[usize::from(sensor.id)], 1); assert_eq!(game.ball.capture_age, 5); assert_eq!(events, [Event::Sound(2015)]); game.ball.capture_age = 300; assert_eq!( capture_primary_record_step( &mut game, sensor.id, sensor.center, sensor.radius, MilliVec::from_position(sensor.center), &mut events, ), CaptureStep::Complete ); assert_eq!(game.record_contacts[usize::from(sensor.id)], 99); assert_eq!(game.ball.capture_age, 0); } #[test] fn production_type_three_pull_tests_the_pre_movement_position() { let sensor = LOCK_HOLES[0]; let mut game = Game::new(1); game.object_active.fill(false); game.object_active[usize::from(sensor.id)] = true; game.ball.in_launcher = false; game.ball.position = sensor.center + vec2(7.5, 0.0); game.ball.velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second(); let old_position = MilliVec::from_position(game.ball.position); let mut expected_velocity = MilliVec { x: 3_800, y: 15 }; expected_velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]); expected_velocity.x = Real48::from_i32(expected_velocity.x) .multiply(damping) .round_i32() .wrapping_sub(150); expected_velocity.y = Real48::from_i32(expected_velocity.y) .multiply(damping) .round_i32() .wrapping_sub(150); assert!(!game.fixed_update(&mut Vec::new())); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), expected_velocity ); assert_eq!( MilliVec::from_position(game.ball.position), old_position.add(expected_velocity) ); } #[test] fn production_type_three_contacted_rim_participates_in_candidate_ordering() { let sensor = LOCK_HOLES[0]; let mut game = Game::new(1); game.object_active.fill(false); game.object_active[usize::from(sensor.id)] = true; game.record_contacts[usize::from(sensor.id)] = 99; game.ball.in_launcher = false; game.ball.position = sensor.center + vec2(17.0, 0.0); game.ball.velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second(); let old_position = MilliVec::from_position(game.ball.position); assert!(game.fixed_update(&mut Vec::new())); let velocity = MilliVec::from_velocity_per_second(game.ball.velocity); assert!(velocity.x > 0, "the type-three rim must reflect the ball"); assert_eq!(game.last_collision_id, Some(sensor.id)); assert_eq!( MilliVec::from_position(game.ball.position), old_position.add(velocity) ); } #[test] fn type_three_broadphase_skip_preserves_contact_state() { let sensor = LOCK_HOLES[0]; let mut game = Game::new(1); game.object_active.fill(false); game.object_active[usize::from(sensor.id)] = true; game.record_contacts[usize::from(sensor.id)] = 1; game.ball.in_launcher = false; game.ball.position = sensor.center + vec2(23.0, 0.0); game.ball.velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second(); game.fixed_update(&mut Vec::new()); assert_eq!(game.record_contacts[usize::from(sensor.id)], 1); } #[test] fn production_type_four_randomizes_motion_before_position_publication() { let sensor = TARGET_SENSORS .into_iter() .find(|sensor| sensor.id == 150) .expect("record 150 must be present"); let mut game = Game::new_with_seed(1, 7); game.object_active.fill(false); game.object_active[usize::from(sensor.id)] = true; game.ball.in_launcher = false; game.ball.position = vec2(205.0, 58.0); game.ball.velocity = MilliVec { x: 0, y: -2_000 }.to_velocity_per_second(); let old_position = MilliVec::from_position(game.ball.position); let mut expected_game = game.clone(); let mut expected_ball = game.ball; expected_ball.velocity = MilliVec { x: 0, y: -1_985 }.to_velocity_per_second(); expected_game.randomize_trigger_velocity(&mut expected_ball); let expected_velocity = MilliVec::from_velocity_per_second(expected_ball.velocity); game.fixed_update(&mut Vec::new()); assert_eq!(game.player().score, 500); assert_eq!( MilliVec::from_velocity_per_second(game.ball.velocity), expected_velocity ); assert_eq!( MilliVec::from_position(game.ball.position), old_position.add(expected_velocity) ); assert_eq!(game.random.seed(), expected_game.random.seed()); } #[test] fn panel_completion_uses_all_281_frames_and_original_sound_boundaries() { let mut game = Game::new(1); game.panel_frame = Some(0); game.wheel_holes.fill(true); for record_id in 129..=133 { game.record_contacts[record_id] = 99; } let mut events = Vec::new(); for _ in 0..282 { game.timer_tick(0.030, 0, &mut events); } assert_eq!(game.panel_frame, None); assert_eq!(game.wheel_holes, [false; 5]); assert!( game.record_contacts[129..=133] .iter() .all(|contact| *contact == 0) ); assert_eq!( events, [ Event::Sound(2013), Event::StopSound, Event::Sound(2012), Event::Sound(2013), Event::StopSound, Event::Sound(2013), Event::StopSound, Event::Sound(2012), Event::Sound(2013), ] ); } #[test] fn target_rotation_uses_six_callbacks_and_rotates_player_state() { let mut game = Game::new(1); game.ball.in_launcher = false; game.wheel_holes = [true, false, true, false, false]; game.record_contacts[129..=133].copy_from_slice(&[11, 22, 33, 44, 55]); game.target_rotation_state = Some(0); let mut events = Vec::new(); for expected in 1..=6 { game.timer_tick(0.030, 0, &mut events); assert_eq!(game.target_rotation_state, Some(expected)); } assert_eq!(events, [Event::Sound(2011)]); assert_eq!(game.wheel_holes, [false, true, false, false, true]); assert_eq!(&game.record_contacts[129..=133], &[22, 33, 44, 55, 11]); game.timer_tick(0.030, 0, &mut events); assert_eq!(game.target_rotation_state, None); } #[test] fn target_rotation_pauses_in_launcher_and_survives_normal_drain() { let mut game = Game::new(1); game.target_rotation_state = Some(2); game.timer_tick(0.030, 0, &mut Vec::new()); assert_eq!(game.target_rotation_state, Some(2)); game.ball.in_launcher = false; game.timer_tick(0.030, 0, &mut Vec::new()); assert_eq!(game.target_rotation_state, Some(3)); game.drain(&mut Vec::new()); assert_eq!(game.target_rotation_state, Some(3)); game.timer_tick(0.030, 0, &mut Vec::new()); assert_eq!(game.target_rotation_state, Some(3)); } #[test] fn record_countdowns_advance_once_per_active_timer_callback() { let mut game = Game::new(1); game.record_countdowns[51] = 5; game.timer_tick(0.030, 0, &mut Vec::new()); assert_eq!( game.record_countdown(51), 5, "launcher idle pauses countdowns" ); game.ball.in_launcher = false; for expected in (0..5).rev() { game.timer_tick(0.030, 0, &mut Vec::new()); assert_eq!(game.record_countdown(51), expected); } } #[test] fn claw_release_table_decodes_the_original_thousandth_pixel_coordinates() { for (frame, expected_position, expected_velocity) in [ (1, vec2(258.0, 78.0), MilliVec { x: -2_280, y: 0 }), ( 6, vec2(270.0, 94.0), MilliVec { x: -1_520, y: 2_280, }, ), ( 7, vec2(275.0, 97.0), MilliVec { x: -1_178, y: 2_660, }, ), (18, vec2(325.0, 92.0), MilliVec { x: 0, y: 1_000 }), ] { let (position, velocity) = claw_release(frame); assert_eq!(position, expected_position); assert_eq!( MilliVec::from_velocity_per_second(velocity), expected_velocity ); } } #[test] fn drain_clears_latched_tilt() { let mut game = Game::new(1); game.tilted = true; game.ball.in_launcher = false; game.ball.position = vec2(157.0, 454.0); game.ball.velocity = vec2(0.0, 200.0); let held = Controls { left_flipper: true, ..Controls::default() }; let events = game.update(0.030, 3, held); assert!(!game.tilted); assert!(game.ball.in_launcher); assert!(!events.contains(&Event::Sound(2008))); assert!(game.flipper_release_latch[0]); game.update(0.0, 3, held); assert!(!game.flipper_inputs.left_raised); game.update(0.0, 3, Controls::default()); game.update(0.0, 3, held); assert!(game.flipper_inputs.left_raised); } #[test] fn ball_end_preserves_or_releases_flippers_like_key_message_latches() { let mut live_player = Game::new(1); live_player.flipper_inputs.left_raised = true; live_player.flippers.left_raised = true; live_player.drain(&mut Vec::new()); assert!(live_player.flipper_inputs.left_raised); assert!(live_player.flippers.left_raised); let mut finished_player = Game::new(2); finished_player.players[0].balls = 1; finished_player.flipper_inputs.left_raised = true; finished_player.flippers.left_raised = true; finished_player.drain(&mut Vec::new()); assert!(!finished_player.flipper_inputs.left_raised); assert!(finished_player.flippers.left_raised); assert!(finished_player.flipper_release_latch[0]); let mut events = Vec::new(); finished_player.timer_tick(0.030, 0, &mut events); assert!(!finished_player.flippers.left_raised); assert_eq!(events, [Event::FlipperMove, Event::Sound(2021)]); let held = Controls { left_flipper: true, ..Controls::default() }; finished_player.update(0.0, 3, held); assert!(!finished_player.flipper_inputs.left_raised); finished_player.update(0.0, 3, Controls::default()); finished_player.update(0.0, 3, held); assert!(finished_player.flipper_inputs.left_raised); } #[test] fn waiting_ball_uses_the_same_nudge_tilt_counter() { let mut game = Game::new_with_seed(1, 7); for attempt in 0..2 { let events = game.update( 0.0, 3, Controls { nudge: Nudge::Right, ..Controls::default() }, ); assert_eq!(events.contains(&Event::Tilt), attempt == 1); } assert!(game.tilted); assert!(game.ball.in_launcher); } #[test] fn tilted_waiting_ball_can_still_be_launched() { let mut game = Game::new_with_seed(1, 7); for _ in 0..2 { game.update( 0.0, 3, Controls { nudge: Nudge::Right, ..Controls::default() }, ); } assert!(game.tilted); assert!(game.ball.in_launcher); game.update( 0.0, 3, Controls { launch_down: true, ..Controls::default() }, ); assert!(game.launcher_frame() > 0); let events = game.update(0.0, 3, Controls::default()); assert!(events.contains(&Event::Launch)); assert!(!events.contains(&Event::Sound(2002))); assert!(!game.ball.in_launcher); assert!(game.tilted); } }