From 445af4a0302d7603fd453b1cde014372ed51894d Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 23 Aug 2026 18:04:30 +0200 Subject: [PATCH] fix(game): dispatch sensors for both ball slots Store capture progression with each recovered ball slot and pass the original one-based ball number through type-3 and type-4 handling. Multiball captures now remove only the captured slot, promote the survivor when slot one exits, and preserve the effect-seven argument guard that prevents ball two from spawning a third ball. Test Plan: - cargo test --all-targets - cargo clippy --all-targets --all-features -- -D warnings - rumdl check CHANGELOG.md RECONSTRUCTION.md README.md - git diff --check --- tdkpin-rs/CHANGELOG.md | 3 + tdkpin-rs/RECONSTRUCTION.md | 2 +- tdkpin-rs/src/game.rs | 391 ++++++++++++++++++++++++++++++------ 3 files changed, 336 insertions(+), 60 deletions(-) diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index 93a9bf1..9f455da 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to ### Fixed +- Run type-3 captures and type-4 triggers for both live ball slots, including + per-slot capture age/contact ownership, exact captured-slot removal, survivor + promotion, and the effect-seven ball-number guard against a third ball. - Spawn effect-seven ball 2 from record 148 at `(17,23)` with exact `(0,3040)` millipixel velocity, instead of launching a guessed second ball upward from the shooter lane. diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index 82b0baf..f907197 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -24,7 +24,7 @@ implementation. | Help and languages | Exact | Original resource images 1001-1005 are displayed directly. | | Playfield collision layout | Recovered | All 109 active type-2 line objects and 40 static active type-1 circles are transcribed from the original 175-object registration table. The registration routine converts its sideways inputs with `screen = (y, x - 20)` and accumulates explicitly relative objects. Type-2 records retain every recovered Real48 normal/tangent response pair and registered one-sided orientation. Type-1 records retain their swept-circle radius, radial rebound, tangent coupling, and bumper kick. Each flipper uses its exact two line records plus moving tip circle in both positions. Moving-flipper contact ports `1000:7ed9` rather than fitting live samples: delta-specific pivots/edges, integer cross gates, radial/penetration calculations, response-record gain, and position/velocity publication are tested against all four C harness directions and the raised release geometry. Object 174 is overwritten with the live first ball and Rust handles its ball-to-ball role directly. | | Ball launcher and nudge input | Recovered | The initial 32-bit fixed-point coordinates decode to `(325, 413)` in the right shooter lane. Each Down keydown subtracts `15*50 = 750` millipixels, release subtracts another `15*100 = 1500`, and the result follows the recovered randomized `-3800` lower and `-2280` weak upper clamp branches. The ten decoration frames use the same strict 750-millipixel thresholds. Left Shift and keypad 3 apply their directional `(50-Random(20))*15` impulses; Space uses the recovered Real48 horizontal factor and `(60-Random(20))*15` vertical impulse. Each nudge adds 25 to the wrapping 16-bit tilt counter, compares it with `30+Random(10)`, and the detail timer decrements a nonzero counter once per callback. | -| Physics arithmetic | Recovered Real48 core | Production movement uses the original 10 ms millipixel substep, `+15` vertical acceleration, Real48 `3800/speed` clamp, type-2 distance/cross gates, type-1 midpoint normal, surface-distance candidate ordering, persistent Real48 spin, and the common impulse response. Dynamic records 174/175 transfer normal impulse to the other ball before applying `normal_velocity-1000` to the moving ball; effect seven spawns record/slot 2 from record 148 coordinates `(17000,23000)` with exact `(0,3040)` velocity. The original Borland seed update, high-word `Random(n)`, and normalized Real48 random register result drive launcher variation, effects, claw terminals, magnetic fields, and trigger response. Type-4 triggers retain a separate transient entry flag; type-3 captures retain per-player 16-bit contact words, deep-inside pull/hold progression to age 300, first-contact sound, and `99`/`2` completion sentinels. Zero-spin C harnesses and retained-spin Wine probes are tested separately rather than conflated. | +| Physics arithmetic | Recovered Real48 core | Production movement uses the original 10 ms millipixel substep, `+15` vertical acceleration, Real48 `3800/speed` clamp, type-2 distance/cross gates, type-1 midpoint normal, surface-distance candidate ordering, persistent Real48 spin, and the common impulse response. Dynamic records 174/175 transfer normal impulse to the other ball before applying `normal_velocity-1000` to the moving ball; effect seven spawns record/slot 2 from record 148 coordinates `(17000,23000)` with exact `(0,3040)` velocity. Both slots traverse the same type-3/type-4 dispatcher. Capture age is saved per slot, contact words retain ball ownership, a completed multiball capture removes exactly that slot and promotes the survivor when needed, and effect seven's argument-2 guard prevents a third ball. The original Borland seed update, high-word `Random(n)`, and normalized Real48 random register result drive launcher variation, effects, claw terminals, magnetic fields, and trigger response. Type-4 triggers retain a shared transient entry flag; type-3 captures retain per-player 16-bit contact words, deep-inside pull/hold progression to age 300, first-contact sound, and `99`/`2` completion sentinels. Zero-spin C harnesses and retained-spin Wine probes are tested separately rather than conflated. | | Rules | Recovered gameplay paths | Player count, controls, the five three-line bumper-value groups, four three-line TDK-diamond groups, five doubling-value lock holes, wheel-reset target, seven-way effect selector/consumer including multiball, permanent double scoring, and four exact media/extra-ball thresholds follow original help/code paths, globals, and object flags. The ninth diamond pays the original 24,464 completion value; the following completed bank enables double scoring, and later completions add 100,000 to the per-player secondary score. Bumpers 51-53 and targets 140-147/150-152 use the original 5/20/10 active-callback countdowns and exact overlay-A render rectangles. Record 121 runs the six-callback DAT600 target rotation with exact 91x90 frames/target points and rotates the five contact/item values at state 6. Completing all five lock holes suspends physics for the full 281-callback panel animation, clears contact/item state at the recovered boundaries, and uses the exact WAVE 2013/2012/stop sequence. Turn changes mirror the original save/load of all 175 collision record states: wheel/top targets, active/contact slots, selected effect, and multiball readiness remain attached to their player. Claw contact and all initially active type-4 targets use recovered records. The top three targets score 500 each and independently enable the left, center, or right magnetic field record; each field pulls the ball upward until it exits and then deactivates. The claw state machine and release table have live differential coverage for all four random terminals. | | Numeric scoring | Recovered gameplay values | Static scores come from the initialized 175-object ledger. Dynamic bumper progression, target-bank completion, diamond awards, 10k-160k lock bonuses, 310k transfer, six effect values, multiball mode, and all four media thresholds are transcribed from `1000:b476`, `1000:c4e1`, `1000:bc36`, and live state probes. Lock and effect awards share the original per-player secondary score and display multiplier; the fifth hole transfers and clears it, increments the multiplier, and grants the recovered ball award. Score mutation uses the original 32-bit wrapping behavior, and each add operation can advance at most one media threshold. | | High scores | Recovered visible flow; portable storage | The original 276-byte table is decoded as ten `IWIK`-XOR-obfuscated little-endian scores plus ten 22-byte names. Each player is checked immediately when their own last ball is lost; qualifying scores use the original signed-high/unsigned-low comparison and a `TDK Pinball Player`-prefilled name screen before the table is shown and play resumes. Persisted updates use portable JSON rather than rewriting the Win16 file. | diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index 9eff76b..d13570f 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -190,6 +190,7 @@ pub struct Ball { pub velocity: Vec2, pub in_launcher: bool, spin: Real48, + capture_age: i32, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -212,6 +213,13 @@ enum CaptureStep { Complete, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BallAction { + Keep, + Reset, + Remove, +} + #[derive(Clone, Copy, Debug)] struct RuleState { wheel_holes: [bool; 5], @@ -251,6 +259,7 @@ impl Default for Ball { velocity: Vec2::ZERO, in_launcher: true, spin: Real48::ZERO, + capture_age: 0, } } } @@ -290,7 +299,6 @@ pub struct Game { target_effect: u8, claw_frame_seconds: f32, multiball_state: MultiballState, - capture_age: i32, } impl Game { @@ -333,7 +341,6 @@ impl Game { target_effect: 0, claw_frame_seconds: CLAW_FRAME_SECONDS, multiball_state: MultiballState::Unavailable, - capture_age: 0, } } @@ -796,6 +803,7 @@ impl Game { velocity.y += GRAVITY_MILLI_PER_STEP; velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); self.apply_magnetic_fields(old_position, &mut velocity); + let movement_velocity = velocity; let mut best_collision = self.find_static_collision(old_position, velocity, ball.spin); let mut transferred_primary_velocity = None; @@ -849,7 +857,17 @@ impl Game { events.push(Event::Bumper); events.push(Event::Sound(2006)); } - self.secondary_ball = Some(ball); + if self.check_sensor_objects_for_ball( + &mut ball, + 2, + 2, + old_position, + movement_velocity, + events, + ) == BallAction::Keep + { + self.secondary_ball = Some(ball); + } } fn apply_wall_rule(&mut self, object_id: u8, events: &mut Vec) { @@ -955,44 +973,94 @@ impl Game { movement_velocity: MilliVec, events: &mut Vec, ) { + let ball_count = if self.secondary_ball.is_some() { 2 } else { 1 }; + let mut ball = self.ball; + match self.check_sensor_objects_for_ball( + &mut ball, + 1, + ball_count, + old_position, + movement_velocity, + events, + ) { + BallAction::Keep => self.ball = ball, + BallAction::Reset => self.reset_ball_to_launcher(), + BallAction::Remove => { + self.ball = self + .secondary_ball + .take() + .expect("a multiball capture must leave the other slot active"); + } + } + } + + 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, + ) -> BallAction { if !self.claw.active { match self.capture_record_step( + ball, + ball_number, + ball_count, 89, CLAW_TRIGGER_CENTER, CLAW_TRIGGER_RADIUS, old_position, events, ) { - CaptureStep::Holding => return, + CaptureStep::Holding => return BallAction::Keep, CaptureStep::Complete => { - let terminal_frame = self.next_claw_terminal_frame(); - self.begin_claw_capture_after_hold(terminal_frame, events); - return; + if ball_count == 1 { + let terminal_frame = self.next_claw_terminal_frame(); + self.begin_claw_capture_after_hold(ball, terminal_frame, events); + return BallAction::Keep; + } + return BallAction::Remove; } CaptureStep::Outside => {} } } - let current_position = MilliVec::from_position(self.ball.position); - if self.check_lock_holes(old_position, events) - || self.check_wheel_reset(old_position, events) + if let Some(action) = + self.check_lock_holes(ball, ball_number, ball_count, old_position, events) { - return; + return action; } - self.check_effect_sensor(old_position, current_position, movement_velocity, events); - self.check_target_sensors(old_position, current_position, movement_velocity, events); + if let Some(action) = + self.check_wheel_reset(ball, ball_number, ball_count, old_position, events) + { + return action; + } + self.check_effect_sensor( + ball, + ball_number, + old_position, + movement_velocity, + events, + ); + self.check_target_sensors(ball, old_position, movement_velocity, events); + BallAction::Keep } - #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)] 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, events: &mut Vec, ) -> CaptureStep { - let current_position = MilliVec::from_position(self.ball.position); + let current_position = MilliVec::from_position(ball.position); let center = MilliVec::from_position(center); let radius = (radius * 1_000.0).round() as i32; let dx = center.x.wrapping_sub(current_position.x); @@ -1003,27 +1071,28 @@ impl Game { 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 ball_count = if self.secondary_ball.is_some() { 2 } else { 1 }; let contact_allowed = - contact == 0 || (contact != 99 && ball_count == 1) || contact == 1; + contact == 0 || (contact != 99 && ball_count == 1) || contact == ball_number; if surface_distance < -11_000 && contact_allowed { - if self.capture_age < 300 { - let mut velocity = MilliVec::from_velocity_per_second(self.ball.velocity); + 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 self.capture_age == 0 { - self.record_contacts[contact_index] = 1; - events.push(Event::Sound(2015)); + if ball.capture_age == 0 { + self.record_contacts[contact_index] = ball_number; + if !self.tilted { + events.push(Event::Sound(2015)); + } } - self.capture_age = if record_id == 89 { + ball.capture_age = if record_id == 89 { 300 } else { - self.capture_age.wrapping_add(5) + ball.capture_age.wrapping_add(5) }; } else { let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]); @@ -1040,29 +1109,41 @@ impl Game { velocity.y.wrapping_sub(150) }; } - self.ball.velocity = velocity.to_velocity_per_second(); + ball.velocity = velocity.to_velocity_per_second(); return CaptureStep::Holding; } - self.capture_age = 0; + if record_id == 89 && ball_count == 1 { + return CaptureStep::Complete; + } + ball.capture_age = 0; self.record_contacts[contact_index] = if record_id == 148 { 2 } else { 99 }; + if ball_count > 1 { + self.record_contacts[contact_index] = 2; + } return CaptureStep::Complete; } - if surface_distance > -11_000 && contact == 1 { + if surface_distance > -11_000 && contact == ball_number { self.record_contacts[contact_index] = 0; - self.capture_age = 0; + ball.capture_age = 0; } CaptureStep::Outside } fn check_lock_holes( &mut self, + ball: &mut Ball, + ball_number: u16, + ball_count: u16, old_position: MilliVec, events: &mut Vec, - ) -> bool { + ) -> 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, @@ -1070,7 +1151,7 @@ impl Game { events, ) { CaptureStep::Outside => continue, - CaptureStep::Holding => return true, + CaptureStep::Holding => return Some(BallAction::Keep), CaptureStep::Complete => {} } let filled_before = self.wheel_holes.iter().filter(|filled| **filled).count(); @@ -1087,43 +1168,60 @@ impl Game { player.extra_balls = player.extra_balls.wrapping_add(1); self.panel_frame = Some(0); } - self.reset_ball_to_launcher(); events.push(Event::Lock); - return true; + return Some(if ball_count == 1 { + BallAction::Reset + } else { + BallAction::Remove + }); } - false + None } fn check_wheel_reset( &mut self, + ball: &mut Ball, + ball_number: u16, + ball_count: u16, old_position: MilliVec, events: &mut Vec, - ) -> bool { + ) -> Option { match self.capture_record_step( + ball, + ball_number, + ball_count, WHEEL_RESET_SENSOR.id, WHEEL_RESET_SENSOR.center, WHEEL_RESET_SENSOR.radius, old_position, events, ) { - CaptureStep::Outside => return false, - CaptureStep::Holding => return true, + CaptureStep::Outside => return None, + CaptureStep::Holding => return Some(BallAction::Keep), CaptureStep::Complete => {} } self.wheel_holes.fill(false); self.multiball_state = MultiballState::Ready; - self.reset_ball_to_launcher(); events.push(Event::Wheel); - true + Some(if ball_count == 1 { + BallAction::Reset + } else { + BallAction::Remove + }) } fn check_effect_sensor( &mut self, + ball: &mut Ball, + ball_number: u16, old_position: MilliVec, - current_position: MilliVec, movement_velocity: MilliVec, events: &mut Vec, ) { + if self.tilted { + return; + } + let current_position = MilliVec::from_position(ball.position); let effect_index = usize::from(EFFECT_SENSOR.id); if self.object_active[effect_index] { let touched = path_intersects_circle( @@ -1139,7 +1237,7 @@ impl Game { EFFECT_SENSOR.center, EFFECT_SENSOR.radius, ); - if entered && !self.tilted { + if entered { self.add_score(EFFECT_SENSOR.score, events); match self.target_effect { 1..=5 => { @@ -1153,12 +1251,13 @@ impl Game { self.add_score(transfer, events); self.players[self.current_player].secondary_score = 0; } - 7 => { + 7 if ball_number != 2 && self.multiball_state == MultiballState::Ready => { 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::Unavailable; } @@ -1168,18 +1267,22 @@ impl Game { self.object_active[effect_index] = false; events.push(Event::Target); events.push(Event::Sound(2004)); - self.randomize_trigger_velocity(); + self.randomize_trigger_velocity(ball); } } } fn check_target_sensors( &mut self, + ball: &mut Ball, old_position: MilliVec, - current_position: MilliVec, movement_velocity: MilliVec, events: &mut Vec, ) { + if self.tilted { + return; + } + let current_position = MilliVec::from_position(ball.position); for sensor in TARGET_SENSORS { let touched = path_intersects_circle( old_position, @@ -1195,7 +1298,7 @@ impl Game { sensor.center, sensor.radius, ); - if !entered || self.tilted { + if !entered { continue; } self.add_score(sensor.score, events); @@ -1207,19 +1310,19 @@ impl Game { let field_id = [153, 6, 154][usize::from(sensor.id - 150)]; self.object_active[field_id] = true; } - self.randomize_trigger_velocity(); + self.randomize_trigger_velocity(ball); } } - fn randomize_trigger_velocity(&mut self) { - let mut velocity = MilliVec::from_velocity_per_second(self.ball.velocity); + 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(); - self.ball.velocity = velocity.to_velocity_per_second(); + ball.velocity = velocity.to_velocity_per_second(); } fn reset_ball_to_launcher(&mut self) { @@ -1319,7 +1422,12 @@ impl Game { events.push(Event::Sound(2015)); } - fn begin_claw_capture_after_hold(&mut self, terminal_frame: u8, events: &mut Vec) { + fn begin_claw_capture_after_hold( + &mut self, + ball: &mut Ball, + terminal_frame: u8, + events: &mut Vec, + ) { debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame)); if self.claw.active { return; @@ -1329,8 +1437,8 @@ impl Game { self.claw.bank = ClawSpriteBank::Closing; self.claw.ball_suspended = true; self.claw.frame_accumulator = 0.0; - self.ball.velocity = Vec2::ZERO; - self.ball.spin = Real48::ZERO; + ball.velocity = Vec2::ZERO; + ball.spin = Real48::ZERO; events.push(Event::ClawCapture); } @@ -1419,7 +1527,6 @@ impl Game { self.flipper_inputs = Flippers::default(); self.flippers = Flippers::default(); self.pending_flipper_edges.fill(0); - self.capture_age = 0; self.secondary_ball = None; self.nudge_shake = 0.0; self.launcher_charge = 0.0; @@ -1554,6 +1661,29 @@ mod tests { } } + 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 result = game.capture_record_step( + &mut ball, + 1, + if game.secondary_ball.is_some() { 2 } else { 1 }, + record_id, + center, + radius, + previous_position, + events, + ); + game.ball = ball; + result + } + #[test] fn player_count_is_bounded() { assert_eq!(Game::new(0).players.len(), 1); @@ -1863,6 +1993,140 @@ mod tests { assert_eq!(game.ball.position, moving_ball.position); } + #[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, + ), + 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, + ), + 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 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, + }); + let mut events = Vec::new(); + + game.advance_secondary_ball(&mut events); + + assert!(game.secondary_ball.is_none()); + assert_eq!(game.ball.capture_age, 17); + assert_eq!(game.record_contacts[usize::from(sensor.id)], 2); + assert!(game.wheel_holes[0]); + assert!(events.contains(&Event::Lock)); + } + + #[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)], 2); + assert!(game.wheel_holes[0]); + } + #[test] fn center_drain_advances_to_a_fresh_ball() { let mut game = Game::new(1); @@ -2433,7 +2697,14 @@ mod tests { let mut events = Vec::new(); assert_eq!( - game.capture_record_step(sensor.id, sensor.center, sensor.radius, previous, &mut events), + capture_primary_record_step( + &mut game, + sensor.id, + sensor.center, + sensor.radius, + previous, + &mut events, + ), CaptureStep::Holding ); assert_eq!( @@ -2441,12 +2712,13 @@ mod tests { MilliVec { x: 750, y: -150 } ); assert_eq!(game.record_contacts[usize::from(sensor.id)], 0); - assert_eq!(game.capture_age, 0); + assert_eq!(game.ball.capture_age, 0); game.ball.position = sensor.center; game.ball.velocity = Vec2::ZERO; assert_eq!( - game.capture_record_step( + capture_primary_record_step( + &mut game, sensor.id, sensor.center, sensor.radius, @@ -2456,12 +2728,13 @@ mod tests { CaptureStep::Holding ); assert_eq!(game.record_contacts[usize::from(sensor.id)], 1); - assert_eq!(game.capture_age, 5); + assert_eq!(game.ball.capture_age, 5); assert_eq!(events, [Event::Sound(2015)]); - game.capture_age = 300; + game.ball.capture_age = 300; assert_eq!( - game.capture_record_step( + capture_primary_record_step( + &mut game, sensor.id, sensor.center, sensor.radius, @@ -2471,7 +2744,7 @@ mod tests { CaptureStep::Complete ); assert_eq!(game.record_contacts[usize::from(sensor.id)], 99); - assert_eq!(game.capture_age, 0); + assert_eq!(game.ball.capture_age, 0); } #[test]