Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
155 changes: 153 additions & 2 deletions crates/game-logic/src/engine/core/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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));
Expand All @@ -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![],
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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"
);
}
}
4 changes: 4 additions & 0 deletions crates/game-logic/src/engine/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ pub struct GameEngine {
character: Box<dyn Character>,
/// 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 {
Expand All @@ -52,6 +55,7 @@ impl GameEngine {
pve_engine: PveEngine::new(),
character: select_character(character_slug),
timer_bonus_given: false,
ball_in_play: false,
}
}

Expand Down
6 changes: 6 additions & 0 deletions crates/game-logic/src/engine/core/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand Down
4 changes: 4 additions & 0 deletions crates/shared/src/screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -249,6 +252,7 @@ impl From<String> for ScreenEventType {
"RailStart" => Self::RailStart,
"RailEnd" => Self::RailEnd,
"BallHit" => Self::BallHit,
"BallInPlay" => Self::BallInPlay,
"BossDefeated" => Self::BossDefeated,
"BossCleared" => Self::BossCleared,
"GameOver" => Self::GameOver,
Expand Down
Loading