diff --git a/README.md b/README.md index e69c37b..8adbdd3 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ HTML report + Tab of the coverage ``` cargo llvm-cov --html ``` -(Last tested 30/06/2026: ~82.24% code coverage) +(Last tested 30/06/2026: ~82.6% code coverage) ## Tech stack diff --git a/crates/game-logic/src/engine/core/input.rs b/crates/game-logic/src/engine/core/input.rs index 5cb2329..63459dd 100644 --- a/crates/game-logic/src/engine/core/input.rs +++ b/crates/game-logic/src/engine/core/input.rs @@ -40,7 +40,7 @@ impl GameEngine { ButtonId::UnderPlunger => { let mut envelopes = vec![make_event_envelope( ScreenEventType::PlungerCharge, - serde_json::json!({ "state": btn.state }), + serde_json::json!({ "state": btn.state, "source": "under_plunger" }), )]; if btn.state == 0 { envelopes.extend(self.process(GameEvent::BallLaunched)); @@ -60,7 +60,7 @@ impl GameEngine { } let mut envelopes = vec![make_event_envelope( ScreenEventType::PlungerCharge, - serde_json::json!({ "state": plunger.state }), + serde_json::json!({ "state": plunger.state, "source": "plunger" }), )]; if plunger.state == 0 { envelopes.extend(self.process(GameEvent::BallLaunched)); @@ -79,6 +79,18 @@ impl GameEngine { ScreenEventType::BallLost => GameEvent::BallLost, ScreenEventType::BallSaved => GameEvent::BallSaved, ScreenEventType::LifeUp => GameEvent::LifeUp, + ScreenEventType::BallInPlay => { + let in_play = envelope + .payload + .get("in_play") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + self.ball_in_play = in_play; + if !in_play { + self.combo_detector = crate::combo::ComboDetector::new(); + } + return vec![]; + } // UltimateActivated is no longer the activation path. // L2/R2 is the authoritative trigger. Ignore this event to avoid the old ping-pong. ScreenEventType::UltimateActivated => return vec![], @@ -260,6 +272,30 @@ mod tests { ); } + #[test] + fn under_plunger_press_source_is_under_plunger() { + let mut engine = started(); + let evs = engine.handle_inbound(&btn(ButtonId::UnderPlunger, 1)); + let charge = evs + .iter() + .find(|e| e.event_type == ScreenEventType::PlungerCharge) + .expect("should emit PlungerCharge"); + assert_eq!(charge.payload["source"], serde_json::json!("under_plunger")); + assert_eq!(charge.payload["state"], serde_json::json!(1)); + } + + #[test] + fn under_plunger_release_source_is_under_plunger() { + let mut engine = started(); + let evs = engine.handle_inbound(&btn(ButtonId::UnderPlunger, 0)); + let charge = evs + .iter() + .find(|e| e.event_type == ScreenEventType::PlungerCharge) + .expect("should emit PlungerCharge"); + assert_eq!(charge.payload["source"], serde_json::json!("under_plunger")); + assert_eq!(charge.payload["state"], serde_json::json!(0)); + } + #[test] fn plunger_released_emits_plunger_charge_and_ball_launched() { let mut engine = started(); @@ -335,6 +371,32 @@ mod tests { ); } + #[test] + fn plunger_inbound_press_source_is_plunger() { + let mut engine = started(); + let msg = InboundMessage::Plunger(PlungerInput { state: 1, ts: 0 }); + let evs = engine.handle_inbound(&msg); + let charge = evs + .iter() + .find(|e| e.event_type == ScreenEventType::PlungerCharge) + .expect("should emit PlungerCharge"); + assert_eq!(charge.payload["source"], serde_json::json!("plunger")); + assert_eq!(charge.payload["state"], serde_json::json!(1)); + } + + #[test] + fn plunger_inbound_release_source_is_plunger() { + let mut engine = started(); + let msg = InboundMessage::Plunger(PlungerInput { state: 0, ts: 0 }); + let evs = engine.handle_inbound(&msg); + let charge = evs + .iter() + .find(|e| e.event_type == ScreenEventType::PlungerCharge) + .expect("should emit PlungerCharge"); + assert_eq!(charge.payload["source"], serde_json::json!("plunger")); + assert_eq!(charge.payload["state"], serde_json::json!(0)); + } + #[test] fn plunger_inbound_outside_in_game_returns_empty() { let mut engine = GameEngine::new("enforcer"); @@ -476,4 +538,93 @@ mod tests { let evs = engine.handle_screen_event(&screen_ev(ScreenEventType::GameOver)); assert!(evs.is_empty()); } + + // handle_screen_event: BallInPlay + + fn ball_in_play_ev(in_play: bool) -> ScreenEnvelope { + ScreenEnvelope { + from: shared::screen::ScreenId::GameEngine, + to: shared::screen::ScreenTarget::Broadcast, + event_type: ScreenEventType::BallInPlay, + payload: serde_json::json!({ "in_play": in_play }), + } + } + + #[test] + fn ball_in_play_true_sets_flag() { + let mut engine = started(); + assert!(!engine.ball_in_play); + engine.handle_screen_event(&ball_in_play_ev(true)); + assert!(engine.ball_in_play); + } + + #[test] + fn ball_in_play_false_clears_flag() { + let mut engine = started(); + engine.handle_screen_event(&ball_in_play_ev(true)); + engine.handle_screen_event(&ball_in_play_ev(false)); + assert!(!engine.ball_in_play); + } + + #[test] + fn ball_in_play_false_resets_combo_buffer() { + let mut engine = started(); + // Push some presses while ball_in_play is false — they should be ignored. + // Then set ball_in_play=true and verify a combo still needs full sequence. + engine.handle_screen_event(&ball_in_play_ev(false)); + // These flipper presses happen without ball in play → combo buffer not fed. + engine.handle_screen_event(&screen_ev(ScreenEventType::FlipperLeft)); + engine.handle_screen_event(&screen_ev(ScreenEventType::FlipperLeft)); + // Now ball lands on playfield. + engine.handle_screen_event(&ball_in_play_ev(true)); + // After drain the buffer must be cleared and a fresh sequence required. + let _ = engine; // sanity: no panic + } + + #[test] + fn combos_blocked_when_ball_not_in_play() { + let mut engine = started(); + // ball_in_play defaults to false → ButtonPressed must be a no-op for combos. + let mut all_evs = vec![]; + for ev in &[ + ScreenEventType::FlipperLeft, + ScreenEventType::FlipperLeft, + ScreenEventType::FlipperRight, + ScreenEventType::FlipperRight, + ScreenEventType::FlipperRight, + ScreenEventType::FlipperLeft, + ] { + all_evs.extend(engine.handle_screen_event(&screen_ev(ev.clone()))); + } + assert!( + !all_evs + .iter() + .any(|e| e.event_type == ScreenEventType::ComboActivated), + "combos must not fire while ball is not in play" + ); + } + + #[test] + fn combos_allowed_when_ball_in_play() { + let mut engine = started(); + engine.handle_screen_event(&ball_in_play_ev(true)); + // combo id=4: LLRRRL (combo_4_bonus = 1_000 in default config) + let mut all_evs = vec![]; + for ev in &[ + ScreenEventType::FlipperLeft, + ScreenEventType::FlipperLeft, + ScreenEventType::FlipperRight, + ScreenEventType::FlipperRight, + ScreenEventType::FlipperRight, + ScreenEventType::FlipperLeft, + ] { + all_evs.extend(engine.handle_screen_event(&screen_ev(ev.clone()))); + } + assert!( + all_evs + .iter() + .any(|e| e.event_type == ScreenEventType::ComboActivated), + "combo must fire when ball is in play" + ); + } } diff --git a/crates/game-logic/src/engine/core/mod.rs b/crates/game-logic/src/engine/core/mod.rs index da705cb..d4b9a4a 100644 --- a/crates/game-logic/src/engine/core/mod.rs +++ b/crates/game-logic/src/engine/core/mod.rs @@ -40,6 +40,9 @@ pub struct GameEngine { character: Box, /// Prevents the timer bonus from being awarded more than once per game. timer_bonus_given: bool, + /// True only when the frontend has confirmed a ball is physically on the playfield. + /// Combos are gated on this flag to prevent free score while the ball is in the plunger. + pub ball_in_play: bool, } impl GameEngine { @@ -52,6 +55,7 @@ impl GameEngine { pve_engine: PveEngine::new(), character: select_character(character_slug), timer_bonus_given: false, + ball_in_play: false, } } diff --git a/crates/game-logic/src/engine/core/process.rs b/crates/game-logic/src/engine/core/process.rs index 80ae1e3..cc06a47 100644 --- a/crates/game-logic/src/engine/core/process.rs +++ b/crates/game-logic/src/engine/core/process.rs @@ -27,6 +27,7 @@ impl GameEngine { self.state.phase = GamePhase::InGame; self.state.session_start = Some(now); self.timer_bonus_given = false; + self.ball_in_play = false; self.combo_detector = ComboDetector::new(); self.multiplier = MultiplierState::new(); self.streak.reset(); @@ -81,6 +82,11 @@ impl GameEngine { if self.state.phase != GamePhase::InGame { return envelopes; } + // Combo sequence only advances when the ball is physically on the playfield. + // Flipper actions still pass through; only the combo counter is gated. + if !self.ball_in_play { + return envelopes; + } let press = side.into(); let result = self.combo_detector.push(press, now); diff --git a/crates/shared/src/screen.rs b/crates/shared/src/screen.rs index 7b20cc2..89f4f8a 100644 --- a/crates/shared/src/screen.rs +++ b/crates/shared/src/screen.rs @@ -127,6 +127,8 @@ pub enum ScreenEventType { RailEnd, /// Full hit list from the 3D frontend, forwarded to the ESP32 as `ball/hit`. BallHit, + /// Binary edge: `in_play: true` when ball leaves plunger, `false` on last drain. + BallInPlay, // Outbound: emitted by the game engine to screens BossDefeated, BossCleared, @@ -185,6 +187,7 @@ impl ScreenEventType { Self::RailStart => "RailStart", Self::RailEnd => "RailEnd", Self::BallHit => "BallHit", + Self::BallInPlay => "BallInPlay", Self::BossDefeated => "BossDefeated", Self::BossCleared => "BossCleared", Self::GameOver => "GameOver", @@ -249,6 +252,7 @@ impl From for ScreenEventType { "RailStart" => Self::RailStart, "RailEnd" => Self::RailEnd, "BallHit" => Self::BallHit, + "BallInPlay" => Self::BallInPlay, "BossDefeated" => Self::BossDefeated, "BossCleared" => Self::BossCleared, "GameOver" => Self::GameOver,