diff --git a/tdkpin-rs/AGENTS.md b/tdkpin-rs/AGENTS.md index 015262b..8ecbace 100644 --- a/tdkpin-rs/AGENTS.md +++ b/tdkpin-rs/AGENTS.md @@ -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. diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index ade964b..0a44280 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to ### 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 diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index 3b3c28a..ef80267 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -1747,6 +1747,11 @@ impl Game { 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] @@ -3450,7 +3455,7 @@ mod tests { 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)); @@ -3538,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);