From 26a2567c91156d35ca948c4d30f04dea97091cc8 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 31 Aug 2026 23:11:00 +0200 Subject: [PATCH] fix(divergence): latch multiball claw capture state A multiball Greifarm capture could lose its two-ball restriction while the ball was still being pulled: capture age remains zero during nonstationary pulling, so a later callback with one remaining ball rewrote the decision. Represent the capture decision as an explicit optional latch and clear it only when that entry exits or the claw state resets. When a held secondary ball is promoted after the primary drains or is captured, migrate record 89 ownership to ball one as well, so the released ball can clear its contact normally. Test Plan: - `just --justfile tdkpin-rs/justfile test` -- passed (149 game/service tests) - `just --justfile tdkpin-rs/justfile clippy` -- passed - `just --justfile tdkpin-rs/justfile build-production` -- passed - `just --justfile tdkpin-rs/justfile web-build` -- passed - `cargo +nightly fmt --manifest-path tdkpin-rs/Cargo.toml --all -- --check` -- passed - `rumdl check --flavor commonmark tdkpin-rs/CHANGELOG.md tdkpin-rs/AGENTS.md` -- passed - `git diff --cached --check` -- passed --- tdkpin-rs/CHANGELOG.md | 2 ++ tdkpin-rs/src/game.rs | 60 ++++++++++++++++++++++++----------- tdkpin-rs/web/tdkpin-rs.wasm | Bin 1332282 -> 1332496 bytes 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index e5363b2..175f221 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to latch the two-ball capture rule at each entry, exclude the launcher release while that capture is active, and let later single-ball re-entries use all four original release choices. +- Preserve that entry decision through the complete pull phase and migrate the + Greifarm contact owner when a held secondary ball becomes the primary ball. ## [1.2.0] - 2026-08-31 diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index 8988eae..8156e3b 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -143,7 +143,7 @@ pub struct Claw { pub bank: ClawSpriteBank, pub ball_suspended: bool, captured_slot: Option, - capture_restricted: bool, + capture_restricted: Option, target_frame: u8, frame_accumulator: f32, } @@ -156,7 +156,7 @@ impl Default for Claw { bank: ClawSpriteBank::Opening, ball_suspended: false, captured_slot: None, - capture_restricted: false, + capture_restricted: None, target_frame: 10, frame_accumulator: 0.0, } @@ -979,10 +979,7 @@ impl Game { .secondary_ball .take() .expect("a multiball capture must leave the other slot active"); - if self.claw.captured_slot == Some(ClawBallSlot::Secondary) { - self.claw.captured_slot = Some(ClawBallSlot::Primary); - self.score_mode = ScoreMode::Normal; - } + self.promote_claw_secondary_to_primary(); return true; } } @@ -1049,7 +1046,7 @@ impl Game { ) { CaptureStep::Complete => { let terminal_frame = - self.next_claw_terminal_frame(self.claw.capture_restricted); + self.next_claw_terminal_frame(self.claw.capture_restricted == Some(true)); self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events); action = BallAction::Suspend; } @@ -1599,7 +1596,7 @@ impl Game { CaptureStep::Holding | CaptureStep::Outside => {} CaptureStep::Complete => { let terminal_frame = - self.next_claw_terminal_frame(self.claw.capture_restricted); + self.next_claw_terminal_frame(self.claw.capture_restricted == Some(true)); self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events); action = BallAction::Suspend; } @@ -1663,6 +1660,12 @@ impl Game { || 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.claw.capture_restricted = None; + } return CaptureStep::Outside; } let dx = center.x.wrapping_sub(current_position.x); @@ -1680,11 +1683,11 @@ impl Game { && contact_allowed && !(record_id == 148 && self.special_hole_gate == SpecialHoleGate::Suppressed) { - if record_id == 89 && ball.capture_age == 0 { + if record_id == 89 && ball.capture_age == 0 && self.claw.capture_restricted.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_restricted = ball_count > 1; + self.claw.capture_restricted = Some(ball_count > 1); } if ball.capture_age < 300 { let mut velocity = MilliVec::from_velocity_per_second(ball.velocity); @@ -1744,7 +1747,7 @@ impl Game { self.record_contacts[contact_index] = 0; ball.capture_age = 0; if record_id == 89 { - self.claw.capture_restricted = false; + self.claw.capture_restricted = None; } } let velocity = MilliVec::from_velocity_per_second(ball.velocity); @@ -2123,6 +2126,16 @@ impl Game { self.claw.ball_suspended && self.claw.captured_slot == Some(slot) } + fn promote_claw_secondary_to_primary(&mut self) { + if self.claw.captured_slot == Some(ClawBallSlot::Secondary) { + self.claw.captured_slot = Some(ClawBallSlot::Primary); + 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) } @@ -2169,7 +2182,7 @@ impl Game { self.claw.bank = ClawSpriteBank::Closing; self.claw.ball_suspended = true; self.claw.captured_slot = Some(ClawBallSlot::Primary); - self.claw.capture_restricted = false; + self.claw.capture_restricted = None; self.claw.frame_accumulator = 0.0; self.ball.velocity = Vec2::ZERO; self.ball.spin = Real48::ZERO; @@ -2244,7 +2257,7 @@ impl Game { (ball.position, ball.velocity) = release; } else { (self.ball.position, self.ball.velocity) = release; - self.claw.captured_slot = Some(ClawBallSlot::Primary); + self.promote_claw_secondary_to_primary(); } } None => { @@ -2291,8 +2304,7 @@ impl Game { .secondary_ball .take() .expect("the claw-held secondary ball must remain in slot two"); - self.claw.captured_slot = Some(ClawBallSlot::Primary); - self.score_mode = ScoreMode::Normal; + self.promote_claw_secondary_to_primary(); events.push(Event::Drain); return; } @@ -4501,7 +4513,7 @@ mod tests { game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Keep ); - assert!(game.claw.capture_restricted); + assert_eq!(game.claw.capture_restricted, Some(true)); assert_eq!( game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), @@ -4565,6 +4577,14 @@ mod tests { ); 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_restricted, Some(true)); + game.ball.velocity = Vec2::ZERO; game.ball.capture_age = 300; @@ -4572,7 +4592,7 @@ mod tests { game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Suspend ); - assert!(game.claw.capture_restricted); + assert_eq!(game.claw.capture_restricted, Some(true)); assert!(matches!(game.claw.target_frame, 1 | 6 | 7)); } @@ -4716,10 +4736,12 @@ mod tests { BallAction::Suspend ); game.secondary_ball = Some(secondary); + assert_eq!(game.record_contacts[89], 2); 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.score_mode, ScoreMode::Normal); assert!(events.contains(&Event::Drain)); } @@ -4762,7 +4784,7 @@ mod tests { game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Suspend ); - assert!(game.claw.capture_restricted); + assert_eq!(game.claw.capture_restricted, Some(true)); game.secondary_ball = None; game.score_mode = ScoreMode::Normal; @@ -4786,7 +4808,7 @@ mod tests { game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Keep ); - assert!(!game.claw.capture_restricted); + assert_eq!(game.claw.capture_restricted, Some(false)); assert_eq!( game.check_sensor_objects(claw_position, MilliVec::default(), &mut events), BallAction::Suspend diff --git a/tdkpin-rs/web/tdkpin-rs.wasm b/tdkpin-rs/web/tdkpin-rs.wasm index bad9928f66203259b522bdaf305568a580888601..c9d0a06855937d9251f27141c855f8ed1393b4ec 100755 GIT binary patch delta 3512 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 Ct6%H@ delta 3155 zcmbQRDsb0|zzx14Oy4#&`-^P%7h!y9s`%>~qcCGVV;yrnV;y@vYb|39Q$1rHV=YfT zV=ZevV=dSANH4}OLY$1|%oo@d7#tb5N5nE7m0`TOU9O7pCNtyp=|5^2?U{8MZcKNo zW7M3US;r_ly`zp%neoQ-HFbiGi%N!XCU3poiU#Vj>(Ns|ob^Om*$j8jc z#LC32z~IPW#c&I3x;5hsb_E8fhK7cQ1qYiLr<>L@DjBI*Fx_EyWKb04Vc}K~;bl@_ zP!OuK7EFKG6&Ms$ESP?<^Ds{rZeUbrWS(x_z$i7{uAWg+5^e^> zwrlKAYj19kYG90E7E)z$P+-tyn8cyP?8xNK%b=h-{X{dPsP!w0 zsKD&Vx%FCdj4)z(Nf|@HY3lAg6SVk*``#ex_1_ib01}%(joEi!Y8cZD=ikj2+ zw=jxxswgmMG5ugy)S7<3g|UWFdwNVOqprA)0;A&thAah61xCjm%vp|%j*N~>3L4WF zv@-tX)KOs6U}|Ah)R?}kjnR=)M?qOZQ9(gLWBP|SMh$te>;z^-Bao^o%!(`ujJgc- zm=!g+!ET>!-p*(%sN&4PP~|d3;AdMy1B0S~g4Xobc1CMP&FKf*8TA--rhjN>G-A}5 zuH3KwL*jP|z|uF_<%*U~-gHWaoBdRMg~F zV01jelqK-9v7vzhP&yq$r#C~Io+;{QGro=dVCk71f$0EiY~@{ zMxE_1x)@~{88x=E^)Pxfa^2^F#qIRMUPeR4o6{HfGEQf_Io-OC(Vy|=^zJ@J4IXtz z#$p9EM<%x-1=a2Q`xu#-8Eb~hsTkz zP*G>P-DE~jMvdtmlNqzcbwE+ilH&!1UBSe(g0LDKOi11N!uyYez|D~M0u>Cfye z0yDs!mq&rok->~fKtW=TW#9gewo$dIA*^ie&L2!Cs0JAu=fP%pE6#>j@!l3-eq9EYP%dEibD3GN9l8`WC zmYcqciA9h}NqTx>5VJ&(w9~FRea9aQhA4?kE3jlM@MAY5 z3ew9R?Tgp8wKz2})+k9k8FVPgEYEUO$Wj7(*Kr}EKQF6-w1P~BlFW3*24<0ZX~z}y z3M|=co&Ga6E6F<5*Es$E&)nSM#MrDLtsvV`qbRK)<)qND+_An`LBLU>tjMu}u~0$A zNuy)AV*_Jx(E&#W4_+n(X>h5}5yi`{AiZ3Xg@>8jkx5Yi9x`scT+ra-R*+C&vSwre zN%L@WnaUIk7CPEha(6e|d1D+#Q1R96s4 zR}z>$pNT~VOUN)tD+qu?h5;Nh46u;lz#B3Gpo9Pp8D`9o5x^HROb&>!;lvd-42ZB{ z1BDF(sG5O$nicA422j;AjnVN{or0i(h?jJhqO70-iz7sYOF;-E!o|Z13L0@<1_g=f z)xpf(QsR!RS&)S6c$2|R+KfrSkwH<=kwKAb`h#HR7$Gia1_lL3fsQ6ng~19c5T^Tu zFiVIFfZV{S#l!=aht(*o(;GvWRT#ymFAHH-66ADbP-JvuP!xA$P-I|oP!ODcGlW^4 zQF=OKD6?=qD5@029T(IqFlH+MWT zV}%JCD_o%P=7iZQ2)C4}9^F<>0=5dG+sXp6l>^0AW~i+kAU_Kza4B#I`~uYzN?ag6 z^C~b{F=#6ADu|mgsM;#O`qP&tgH8($(5JE zkx>zxBDr)K7!DtqNEBS{3g0wJJRAYgKr+uT|j_vT$r+ zWnkcAU|@(57hvFKU|{HAWnkb%;&-t!FbE>?yV3Ylrp59V3(BxFFsLJ`YM