fix(rules): scope double scoring to the active ball

The binary stores the diamond-bank double flag at receiver +0x0bdc and clears
it in normal_ball_end. Rust stored that flag on Player, so it incorrectly
survived every normal drain and player turn. The special effect-seven respawn
is intentionally different because it bypasses normal_ball_end.

Represent the flag as an explicit ball-double state on Game. Clear it only on
the normal ball-end path, preserve it through the special respawn, and make the
first completed diamond bank after a normal drain re-enable double scoring
without adding 100,000 secondary points. Keep the independent multiball factor,
so both active factors still stack to 4x.

Test Plan:
- `cargo test --workspace --all-targets --all-features` -- 117 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-23 19:58:39 +02:00
parent df66530b86
commit 6e6b7f4c58
3 changed files with 55 additions and 19 deletions
+47 -13
View File
@@ -170,7 +170,6 @@ pub struct Player {
pub extra_balls: u8,
pub bumper_value: u32,
pub diamond_segments: u8,
pub double_score: bool,
pub media_level: u8,
rules: RuleState,
}
@@ -185,7 +184,6 @@ impl Default for Player {
extra_balls: 0,
bumper_value: 1_000,
diamond_segments: 0,
double_score: false,
media_level: 0,
rules: RuleState::default(),
}
@@ -222,6 +220,13 @@ enum ScoreMode {
Multiball,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum BallDoubleState {
#[default]
Inactive,
Active,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum WheelResetGate {
#[default]
@@ -350,6 +355,7 @@ pub struct Game {
multiball_state: MultiballState,
wheel_reset_gate: WheelResetGate,
score_mode: ScoreMode,
ball_double: BallDoubleState,
}
impl Game {
@@ -392,6 +398,7 @@ impl Game {
multiball_state: MultiballState::Unavailable,
wheel_reset_gate: WheelResetGate::Enabled,
score_mode: ScoreMode::Normal,
ball_double: BallDoubleState::Inactive,
}
}
@@ -1311,18 +1318,18 @@ impl Game {
};
self.add_score(award, events);
if segments == 8 {
self.players[self.current_player].double_score = true;
self.ball_double = BallDoubleState::Active;
for object_id in [153, 6, 154] {
self.object_active[object_id] = true;
}
}
events.push(Event::Sound(2017));
} else {
let player = &mut self.players[self.current_player];
if player.double_score {
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 {
player.double_score = true;
self.ball_double = BallDoubleState::Active;
}
events.push(Event::Sound(2007));
}
@@ -1358,7 +1365,7 @@ impl Game {
// 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 permanent double scoring: that collision's 1,500
// 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);
}
@@ -2013,8 +2020,12 @@ impl Game {
} else {
1
};
let permanent_multiplier = if self.player().double_score { 2 } else { 1 };
let multiplier = multiball_multiplier * permanent_multiplier;
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);
@@ -2046,6 +2057,7 @@ impl Game {
self.wheel_reset_gate = WheelResetGate::Suppressed;
return;
}
self.ball_double = BallDoubleState::Inactive;
self.wheel_reset_gate = WheelResetGate::Enabled;
let player = &mut self.players[self.current_player];
if player.extra_balls > 0 {
@@ -2491,7 +2503,7 @@ mod tests {
game.players[0].diamond_segments = 8;
let mut events = Vec::new();
assert!(!game.player().double_score);
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));
@@ -2501,7 +2513,7 @@ mod tests {
}
assert_eq!(game.player().diamond_segments, 9);
assert_eq!(game.player().score, 3 * 1_500 + 24_464 + 2 * 1_500);
assert!(game.player().double_score);
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));
@@ -2519,14 +2531,14 @@ mod tests {
}
#[test]
fn score_helper_stacks_multiball_and_permanent_double_and_honors_tilt() {
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.players[0].double_score = true;
game.ball_double = BallDoubleState::Active;
game.add_score(100, &mut events);
assert_eq!(game.player().score, 600);
@@ -2535,6 +2547,28 @@ mod tests {
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.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);