2 Commits
Author SHA1 Message Date
ddidderr a6967b4d1b fix(divergence): reject occupied multiball wheel slots
The original leaves contact owner 2 after either ball fills a wheel hole during
multiball. Once play collapses to one ball, that sentinel permits one more
capture and award in the visibly occupied hole before becoming permanent.

Deliberately replace the completed wheel contact with the permanent 99 sentinel
for both ball slots. This keeps visual occupancy, collision admission, and
scoring consistent: a filled hole cannot consume or reward another ball. The
special reserve hole and claw retain their original contact behavior.

Document `divergence` as the required Conventional Commit scope for future fixes
that intentionally differ from original-game behavior.

Test Plan:
- `just test` -- passed (142 game tests and 8 service tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md AGENTS.md` -- passed
- `git diff --cached --check` -- passed
2026-08-31 19:54:11 +02:00
ddidderr a394df23e7 fix(multiball): match capture collapse timing
Capture removal and ordinary drains do not clear the original game's shared
multiball flag at the same point. The clone treated both transitions alike,
which could end double scoring before ball two's remaining slot pass or leave
it active after ball two entered a capture hole. It also failed to pause the
returning claw until collapse and to clear the flag on a fifth lock.

Collapse capture-driven multiball state at the next timer callback, retain the
immediate drain behavior, and end the mode explicitly when all five lock
contacts complete. Calculate lock awards from the live contact words so a
second ball still settling in another hole is counted. Stage effect-seven slot
creation until the primary substep batch returns, matching the timer's spawn
request ordering.

The original leaves a multiball capture contact as value 2, so a later single
ball may enter that same wheel slot once more and convert it to the permanent
99 sentinel. This possibly unintended original-game quirk remains for binary
parity.

Test Plan:
- `just test` -- passed (141 game tests and 8 service tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-31 19:54:10 +02:00
3 changed files with 155 additions and 8 deletions
+4
View File
@@ -5,6 +5,10 @@
Automatically commit changes once a full feature, bugfix, refactor, or other
coherent unit of work is finished. Do not wait for the user to ask for a commit.
Use `divergence` as the Conventional Commit scope for every change that
deliberately differs from original-game behavior to fix an original bug, for
example `fix(divergence): reject occupied wheel slots`.
## Versioning Policy
Only update the version, when the user explicitly asks for it.
+17
View File
@@ -8,6 +8,23 @@ and this project adheres to
## [Unreleased]
### Fixed
- Deliberately diverge from the original game's multiball contact sentinel so a
reserve ball that fills a wheel hole leaves it permanently occupied; the
surviving ball can no longer enter and score that visibly filled slot again.
- Match capture-driven multiball collapse timing so the surviving slot keeps
double scoring through the current callback, then returns to normal scoring
on the next callback and the returning claw remains paused until that
collapse; completing the fifth wheel lock still ends multiball scoring
immediately.
- Derive wheel-lock awards from the original live contact words so two balls
settling into different slots at once receive the same accumulated award as
the original game.
- Stage an effect-seven reserve-ball request until ball 1's full substep batch
has returned, preventing the new slot from participating in collision and
capture rules before the original creates it.
## [1.1.0] - 2026-08-29
### Added
+134 -8
View File
@@ -592,19 +592,31 @@ impl Game {
self.update_target_rotation(events);
}
let panel_active = self.update_panel_completion(events);
// The original collapses an inactive two-ball slot immediately before
// the next physics pass. Capture removal does not clear multiball
// scoring until that collapse, so the surviving slot retains double
// scoring for the remainder of the callback in which it was captured.
if self.secondary_ball.is_none() && self.score_mode == ScoreMode::Multiball {
self.score_mode = ScoreMode::Normal;
}
if !self.claw.ball_suspended && !panel_active {
let had_secondary_ball = self.secondary_ball.is_some();
let mut spawned_secondary = None;
if had_secondary_ball {
self.score_mode = ScoreMode::Multiball;
}
for _ in 0..substeps {
if self.fixed_update(events) || self.finished || self.claw.ball_suspended {
let stop = self.fixed_update(events);
// Effect seven publishes its spawn request during record 149,
// but the original timer does not create slot two until the
// complete slot-one simulation pass has returned.
if !had_secondary_ball && spawned_secondary.is_none() {
spawned_secondary = self.secondary_ball.take();
}
if stop || self.finished || self.claw.ball_suspended {
break;
}
}
if had_secondary_ball && self.secondary_ball.is_none() {
self.score_mode = ScoreMode::Normal;
}
if had_secondary_ball && !self.finished && !self.claw.ball_suspended {
if self.secondary_ball.is_some() {
for _ in 0..substeps {
@@ -626,6 +638,10 @@ impl Game {
}
}
}
if let Some(spawned) = spawned_secondary {
debug_assert!(self.secondary_ball.is_none());
self.secondary_ball = Some(spawned);
}
}
self.pending_flipper_edges[0] =
@@ -1731,7 +1747,17 @@ impl Game {
events: &mut Vec<Event>,
) -> BallAction {
self.wheel_holes[index] = true;
let filled = self.wheel_holes.iter().filter(|filled| **filled).count();
// 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];
@@ -1746,6 +1772,7 @@ impl Game {
if ball_count == 1 {
self.panel_frame = Some(0);
}
self.score_mode = ScoreMode::Normal;
}
events.push(Event::Lock);
if ball_count == 1 {
@@ -2088,7 +2115,7 @@ impl Game {
}
fn update_claw(&mut self, dt: f32, events: &mut Vec<Event>) {
if !self.claw.active {
if !self.claw.active || self.score_mode == ScoreMode::Multiball {
return;
}
self.claw.frame_accumulator += dt;
@@ -3420,15 +3447,71 @@ mod tests {
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)], 2);
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_pauses_the_returning_claw_until_slot_collapse() {
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.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, 1);
game.score_mode = ScoreMode::Normal;
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, 2);
}
#[test]
@@ -3460,10 +3543,35 @@ mod tests {
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_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);
@@ -4529,6 +4637,23 @@ mod tests {
);
}
#[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);
@@ -4546,6 +4671,7 @@ mod tests {
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();