From f6ca307b2e76513754f70e70af2e90ab56d392af Mon Sep 17 00:00:00 2001 From: Jeck0v Date: Fri, 26 Jun 2026 18:19:07 +0200 Subject: [PATCH 1/6] refacto: oracle ulti --- crates/game-logic/src/engine/config.rs | 5 + crates/game-logic/src/engine/core/emit.rs | 4 +- crates/game-logic/src/engine/core/ulti.rs | 372 ++++++++++++++++++++-- crates/game-logic/src/engine/states.rs | 20 +- 4 files changed, 364 insertions(+), 37 deletions(-) diff --git a/crates/game-logic/src/engine/config.rs b/crates/game-logic/src/engine/config.rs index 0ccc47c..54edf2a 100644 --- a/crates/game-logic/src/engine/config.rs +++ b/crates/game-logic/src/engine/config.rs @@ -56,6 +56,7 @@ pub struct GameConfigPatch { pub oracle_ulti_duration_ms: Option, pub oracle_slow_factor: Option, pub oracle_time_rate: Option, + pub oracle_activation_min_ratio: Option, pub streak_window_ms: Option, pub streak_tier_1_count: Option, pub streak_tier_2_count: Option, @@ -150,6 +151,8 @@ pub struct GameConfig { pub oracle_ulti_duration_ms: u64, pub oracle_slow_factor: f32, pub oracle_time_rate: f32, + /// Fraction of charge_max below which time_slow cannot be activated (default 5%). + pub oracle_activation_min_ratio: f32, // Streak multiplier pub streak_window_ms: u64, @@ -230,6 +233,7 @@ impl Default for GameConfig { oracle_ulti_duration_ms: 5_000, oracle_slow_factor: 0.25, oracle_time_rate: 1.0, + oracle_activation_min_ratio: 0.05, streak_window_ms: 2_000, streak_tier_1_count: 2, streak_tier_2_count: 5, @@ -322,6 +326,7 @@ pub fn apply_patch(patch: GameConfigPatch) { apply!(oracle_ulti_duration_ms); apply!(oracle_slow_factor); apply!(oracle_time_rate); + apply!(oracle_activation_min_ratio); apply!(streak_window_ms); apply!(streak_tier_1_count); apply!(streak_tier_2_count); diff --git a/crates/game-logic/src/engine/core/emit.rs b/crates/game-logic/src/engine/core/emit.rs index b98c0fb..05145ef 100644 --- a/crates/game-logic/src/engine/core/emit.rs +++ b/crates/game-logic/src/engine/core/emit.rs @@ -14,12 +14,12 @@ impl GameEngine { let charge_max = self.character.stats().charge_profile.charge_max; let displayed_charge = if self.state.is_ulti_active(now) { - self.state.residual_charge_with_max(now, charge_max) + self.state.residual_charge(now) } else { self.state.ultimate_charge }; let ulti_ready = - !self.state.is_ulti_active(now) && self.state.ultimate_charge >= charge_max; + !self.state.is_ulti_active(now) && self.state.ultimate_charge >= self.activation_min_charge(); let mut payload = serde_json::json!({ "score": self.state.score, diff --git a/crates/game-logic/src/engine/core/ulti.rs b/crates/game-logic/src/engine/core/ulti.rs index 44730cd..d30eb6d 100644 --- a/crates/game-logic/src/engine/core/ulti.rs +++ b/crates/game-logic/src/engine/core/ulti.rs @@ -7,7 +7,38 @@ use crate::player::personnages::character::UltiShape; use super::{GHOST_CYCLE, GameEngine, make_event_envelope}; +/// Minimum charge required to activate a time_slow ulti at `ratio` of `charge_max`. +/// Always returns at least 1 to avoid a zero threshold. +pub(crate) fn activation_min_charge_for(charge_max: u32, ratio: f32) -> u32 { + ((charge_max as f32 * ratio).ceil() as u32).max(1) +} + impl GameEngine { + /// Returns the ulti_id Ghost's next cycle would produce without advancing the index. + fn peek_ghost_ulti_id(&self) -> &'static str { + GHOST_CYCLE[(self.state.ghost_cycle_index as usize) % GHOST_CYCLE.len()] + } + + /// True when the next ulti to activate resolves to `time_slow` (Oracle or Ghost at that slot). + fn next_ulti_is_time_slow(&self) -> bool { + match self.character.slug() { + "oracle" => true, + "ghost" => self.peek_ghost_ulti_id() == "time_slow", + _ => false, + } + } + + /// Minimum charge required to fire the current character's next ulti. + /// `time_slow` variants accept partial activation; all others require full charge. + pub(super) fn activation_min_charge(&self) -> u32 { + let charge_max = self.character.stats().charge_profile.charge_max; + if self.next_ulti_is_time_slow() { + activation_min_charge_for(charge_max, config::get().oracle_activation_min_ratio) + } else { + charge_max + } + } + /// Expire a sustained ulti that has run its full duration. /// Called at the top of every `process` to avoid needing a background timer. pub(super) fn try_expire_ulti(&mut self, now: Instant) { @@ -17,24 +48,25 @@ impl GameEngine { { self.state.ulti_ends_at = None; self.state.ultimate_charge = 0; + self.state.ulti_start_charge = 0; self.state.ulti_active_id = None; self.state.ulti_multiplier_override = None; } } - /// Handle an L2/R2 press: activates the ulti if charge is full, or cancels - /// it if a cancellable ulti is already running. + /// Handle an L2/R2 press: activates the ulti when charge meets the threshold, or cancels + /// a cancellable running ulti. pub(super) fn process_ulti_press(&mut self, now: Instant) -> Vec { let mut envelopes = Vec::new(); if self.state.is_ulti_active(now) { if self.state.ulti_cancellable { - let charge_max = self.character.stats().charge_profile.charge_max; - let residual = self.state.residual_charge_with_max(now, charge_max); + let residual = self.state.residual_charge(now); let ulti_id = self.state.ulti_active_id.clone().unwrap_or_default(); self.state.ulti_ends_at = Some(now); self.state.ultimate_charge = residual; + self.state.ulti_start_charge = 0; self.state.ulti_active_id = None; self.state.ulti_multiplier_override = None; @@ -48,9 +80,10 @@ impl GameEngine { envelopes.push(self.emit_score_update(None)); } } else { - let charge_max = self.character.stats().charge_profile.charge_max; - if self.state.ultimate_charge >= charge_max { - envelopes.extend(self.activate_ulti(now)); + let min_charge = self.activation_min_charge(); + if self.state.ultimate_charge >= min_charge { + let activation_charge = self.state.ultimate_charge; + envelopes.extend(self.activate_ulti(now, activation_charge)); envelopes.push(self.emit_score_update(None)); } } @@ -58,10 +91,11 @@ impl GameEngine { envelopes } - fn activate_ulti(&mut self, now: Instant) -> Vec { + fn activate_ulti(&mut self, now: Instant, activation_charge: u32) -> Vec { let mut envelopes = Vec::new(); let character_slug = self.character.slug(); let cfg = config::get(); + let charge_max = self.character.stats().charge_profile.charge_max; let (ulti_id, shape): (&'static str, UltiShape) = if character_slug == "ghost" { let idx = (self.state.ghost_cycle_index as usize) % GHOST_CYCLE.len(); @@ -87,21 +121,33 @@ impl GameEngine { (self.character.ulti_id(), self.character.ulti_shape()) }; - match &shape { + // Apply shape-specific state changes; compute the actual emission duration for Sustained. + let emitted_duration_ms: u64 = match &shape { UltiShape::Instant => { self.state.ultimate_charge = 0; + self.state.ulti_start_charge = 0; if ulti_id == "multiball_split" { self.state.multiball_active = true; } + 0 } UltiShape::Sustained { - duration_ms, + duration_ms: full_duration, cancellable, } => { - self.state.ulti_ends_at = Some(now + Duration::from_millis(*duration_ms)); - self.state.ulti_duration_ms = *duration_ms; + // Scale duration proportionally to the committed charge. + // At full charge the result is identical to the configured duration. + let actual = if charge_max > 0 { + (*full_duration * activation_charge as u64 / charge_max as u64).max(1) + } else { + *full_duration + }; + self.state.ulti_ends_at = Some(now + Duration::from_millis(actual)); + self.state.ulti_duration_ms = actual; self.state.ulti_cancellable = *cancellable; self.state.ulti_active_id = Some(ulti_id.to_string()); + self.state.ulti_start_charge = activation_charge; + self.state.ultimate_charge = 0; if ulti_id == "rampage" { let rampage_mult = cfg.viper_rampage_multiplier; @@ -110,38 +156,36 @@ impl GameEngine { ScreenEventType::MultiplierUpdate, serde_json::json!({ "multiplier": rampage_mult, - "duration_ms": duration_ms, + "duration_ms": actual, }), )); } + actual } UltiShape::Inherited => { unreachable!("ghost always resolves to a concrete shape before this point"); } - } + }; let mut triggered = serde_json::json!({ "character": character_slug, "ulti_id": ulti_id, + "activation_charge": activation_charge, }); match &shape { UltiShape::Instant => { triggered["shape"] = serde_json::json!("instant"); triggered["cancellable"] = serde_json::json!(false); } - UltiShape::Sustained { - duration_ms, - cancellable, - } => { + UltiShape::Sustained { cancellable, .. } => { triggered["shape"] = serde_json::json!("sustained"); triggered["cancellable"] = serde_json::json!(cancellable); - triggered["duration_ms"] = serde_json::json!(duration_ms); - let payload = match ulti_id { + triggered["duration_ms"] = serde_json::json!(emitted_duration_ms); + triggered["payload"] = match ulti_id { "rampage" => serde_json::json!({ "multiplier": cfg.viper_rampage_multiplier }), "time_slow" => serde_json::json!({ "slow_factor": cfg.oracle_slow_factor }), _ => serde_json::json!({}), }; - triggered["payload"] = payload; } UltiShape::Inherited => unreachable!(), } @@ -153,3 +197,289 @@ impl GameEngine { envelopes } } + +#[cfg(test)] +mod tests { + use std::time::Instant; + + use shared::screen::ScreenEventType; + + use crate::engine::config; + use crate::engine::events::GameEvent; + + use super::{GameEngine, activation_min_charge_for}; + + fn oracle_engine() -> GameEngine { + let mut e = GameEngine::new("oracle"); + e.process(GameEvent::StartGame); + e + } + + fn ghost_engine() -> GameEngine { + let mut e = GameEngine::new("ghost"); + e.process(GameEvent::StartGame); + e + } + + // ── Oracle partial activation ────────────────────────────────────────────── + + #[test] + fn oracle_does_not_trigger_below_threshold() { + let mut engine = oracle_engine(); + let charge_max = engine.character.stats().charge_profile.charge_max; + let min = activation_min_charge_for(charge_max, config::get().oracle_activation_min_ratio); + if min == 0 { + return; + } + engine.state.ultimate_charge = min - 1; + let envelopes = engine.process_ulti_press(Instant::now()); + assert!( + !envelopes + .iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered), + "should not trigger below threshold" + ); + assert!(!engine.state.is_ulti_active(Instant::now())); + } + + #[test] + fn oracle_triggers_at_exact_threshold() { + let mut engine = oracle_engine(); + let charge_max = engine.character.stats().charge_profile.charge_max; + let min = activation_min_charge_for(charge_max, config::get().oracle_activation_min_ratio); + engine.state.ultimate_charge = min; + let envelopes = engine.process_ulti_press(Instant::now()); + assert!( + envelopes + .iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered), + "should trigger at threshold" + ); + } + + #[test] + fn oracle_half_charge_sends_half_duration() { + let mut engine = oracle_engine(); + let charge_max = engine.character.stats().charge_profile.charge_max; + let half = charge_max / 2; + engine.state.ultimate_charge = half; + let full_duration = config::get().oracle_ulti_duration_ms; + + let envelopes = engine.process_ulti_press(Instant::now()); + let triggered = envelopes + .iter() + .find(|e| e.event_type == ScreenEventType::UltimateTriggered) + .expect("should trigger"); + + let emitted = triggered.payload["duration_ms"].as_u64().unwrap(); + let expected = full_duration * half as u64 / charge_max as u64; + assert_eq!(emitted, expected); + } + + #[test] + fn oracle_full_charge_duration_unchanged() { + let mut engine = oracle_engine(); + let charge_max = engine.character.stats().charge_profile.charge_max; + engine.state.ultimate_charge = charge_max; + let full_duration = config::get().oracle_ulti_duration_ms; + + let envelopes = engine.process_ulti_press(Instant::now()); + let triggered = envelopes + .iter() + .find(|e| e.event_type == ScreenEventType::UltimateTriggered) + .expect("should trigger at full charge"); + assert_eq!(triggered.payload["duration_ms"].as_u64().unwrap(), full_duration); + } + + #[test] + fn oracle_cancel_residual_based_on_start_charge() { + let mut engine = oracle_engine(); + let charge_max = engine.character.stats().charge_profile.charge_max; + let start_charge = charge_max / 2; + engine.state.ultimate_charge = start_charge; + + // Activate at half charge. + engine.process_ulti_press(Instant::now()); + assert!(engine.state.is_ulti_active(Instant::now())); + + // Immediately cancel — residual should be close to start_charge. + let envelopes = engine.process_ulti_press(Instant::now()); + let stopped = envelopes + .iter() + .find(|e| e.event_type == ScreenEventType::UltimateStopped) + .expect("should emit UltimateStopped"); + let residual = stopped.payload["ultimate_charge"].as_u64().unwrap() as u32; + + assert!(residual <= start_charge, "residual must not exceed start charge"); + assert!( + residual >= start_charge * 9 / 10, + "residual {residual} should be ≥90% of start_charge {start_charge} on immediate cancel" + ); + } + + #[test] + fn oracle_triggered_payload_contains_activation_charge() { + let mut engine = oracle_engine(); + let charge_max = engine.character.stats().charge_profile.charge_max; + let half = charge_max / 2; + engine.state.ultimate_charge = half; + + let envelopes = engine.process_ulti_press(Instant::now()); + let triggered = envelopes + .iter() + .find(|e| e.event_type == ScreenEventType::UltimateTriggered) + .unwrap(); + assert_eq!( + triggered.payload["activation_charge"].as_u64().unwrap() as u32, + half + ); + } + + // ── Ghost partial activation ─────────────────────────────────────────────── + + #[test] + fn ghost_does_not_advance_cycle_on_rejected_press() { + let mut engine = ghost_engine(); + // Index 0 = multiball_split — requires full charge. + engine.state.ghost_cycle_index = 0; + let charge_max = engine.character.stats().charge_profile.charge_max; + engine.state.ultimate_charge = charge_max / 2; + + let idx_before = engine.state.ghost_cycle_index; + engine.process_ulti_press(Instant::now()); + assert_eq!( + engine.state.ghost_cycle_index, idx_before, + "cycle must not advance on a rejected press" + ); + } + + #[test] + fn ghost_triggers_partial_when_time_slow() { + let mut engine = ghost_engine(); + // Index 2 = time_slow. + engine.state.ghost_cycle_index = 2; + let charge_max = engine.character.stats().charge_profile.charge_max; + let min = activation_min_charge_for(charge_max, config::get().oracle_activation_min_ratio); + engine.state.ultimate_charge = min; + + let envelopes = engine.process_ulti_press(Instant::now()); + let triggered = envelopes + .iter() + .find(|e| e.event_type == ScreenEventType::UltimateTriggered) + .expect("should trigger"); + assert_eq!(triggered.payload["ulti_id"], serde_json::json!("time_slow")); + assert_eq!(engine.state.ghost_cycle_index, 3, "cycle must advance after acceptance"); + } + + #[test] + fn ghost_requires_full_charge_for_multiball_split() { + let mut engine = ghost_engine(); + engine.state.ghost_cycle_index = 0; + let charge_max = engine.character.stats().charge_profile.charge_max; + engine.state.ultimate_charge = charge_max - 1; + + let envelopes = engine.process_ulti_press(Instant::now()); + assert!( + !envelopes + .iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered), + "multiball_split should require full charge" + ); + } + + #[test] + fn ghost_requires_full_charge_for_rampage() { + let mut engine = ghost_engine(); + engine.state.ghost_cycle_index = 1; + let charge_max = engine.character.stats().charge_profile.charge_max; + engine.state.ultimate_charge = charge_max - 1; + + let envelopes = engine.process_ulti_press(Instant::now()); + assert!( + !envelopes + .iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered), + "rampage should require full charge" + ); + } + + // ── ScoreUpdate.ulti_ready ───────────────────────────────────────────────── + + #[test] + fn ulti_ready_true_for_oracle_at_threshold() { + let mut engine = oracle_engine(); + let charge_max = engine.character.stats().charge_profile.charge_max; + let min = activation_min_charge_for(charge_max, config::get().oracle_activation_min_ratio); + engine.state.ultimate_charge = min; + let update = engine.emit_score_update(None); + assert_eq!(update.payload["ulti_ready"], serde_json::json!(true)); + } + + #[test] + fn ulti_ready_false_for_oracle_below_threshold() { + let mut engine = oracle_engine(); + let charge_max = engine.character.stats().charge_profile.charge_max; + let min = activation_min_charge_for(charge_max, config::get().oracle_activation_min_ratio); + if min == 0 { + return; + } + engine.state.ultimate_charge = min - 1; + let update = engine.emit_score_update(None); + assert_eq!(update.payload["ulti_ready"], serde_json::json!(false)); + } + + #[test] + fn ulti_ready_true_for_ghost_time_slow_at_threshold() { + let mut engine = ghost_engine(); + engine.state.ghost_cycle_index = 2; // time_slow slot + let charge_max = engine.character.stats().charge_profile.charge_max; + let min = activation_min_charge_for(charge_max, config::get().oracle_activation_min_ratio); + engine.state.ultimate_charge = min; + let update = engine.emit_score_update(None); + assert_eq!(update.payload["ulti_ready"], serde_json::json!(true)); + } + + #[test] + fn ulti_ready_false_for_ghost_multiball_below_full_charge() { + let mut engine = ghost_engine(); + engine.state.ghost_cycle_index = 0; // multiball_split slot + let charge_max = engine.character.stats().charge_profile.charge_max; + engine.state.ultimate_charge = charge_max - 1; + let update = engine.emit_score_update(None); + assert_eq!(update.payload["ulti_ready"], serde_json::json!(false)); + } + + // ── Regression: existing full-charge behaviour ───────────────────────────── + + #[test] + fn viper_still_requires_full_charge() { + let mut engine = GameEngine::new("viper"); + engine.process(GameEvent::StartGame); + let charge_max = engine.character.stats().charge_profile.charge_max; + engine.state.ultimate_charge = charge_max - 1; + + let envelopes = engine.process_ulti_press(Instant::now()); + assert!( + !envelopes + .iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered), + "viper rampage should still need full charge" + ); + } + + #[test] + fn enforcer_still_requires_full_charge() { + let mut engine = GameEngine::new("enforcer"); + engine.process(GameEvent::StartGame); + let charge_max = engine.character.stats().charge_profile.charge_max; + engine.state.ultimate_charge = charge_max - 1; + + let envelopes = engine.process_ulti_press(Instant::now()); + assert!( + !envelopes + .iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered), + "enforcer multiball should still need full charge" + ); + } +} diff --git a/crates/game-logic/src/engine/states.rs b/crates/game-logic/src/engine/states.rs index cae24c8..6735469 100644 --- a/crates/game-logic/src/engine/states.rs +++ b/crates/game-logic/src/engine/states.rs @@ -74,6 +74,8 @@ pub struct GameState { pub ulti_duration_ms: u64, pub ulti_cancellable: bool, pub ulti_active_id: Option, + /// Charge committed at activation — used to compute residual on cancel/display. + pub ulti_start_charge: u32, /// Overrides `effective_multiplier` to a fixed value while ulti is active (Viper rampage). pub ulti_multiplier_override: Option, @@ -103,6 +105,7 @@ impl GameState { ulti_duration_ms: 0, ulti_cancellable: false, ulti_active_id: None, + ulti_start_charge: 0, ulti_multiplier_override: None, ghost_cycle_index: 0, } @@ -126,20 +129,9 @@ impl GameState { self.ulti_ends_at.map(|t| now < t).unwrap_or(false) } + /// Compute remaining charge during a sustained ulti (linear drain from `ulti_start_charge`). + /// Used both to display the charge bar and to restore partial charge on cancel. pub fn residual_charge(&self, now: Instant) -> u32 { - let Some(ends_at) = self.ulti_ends_at else { - return 0; - }; - if now >= ends_at || self.ulti_duration_ms == 0 { - return 0; - } - // charge_max is not stored here use residual_charge_with_max instead. - 0 - } - - /// Compute how much ultimate charge remains during a sustained ulti (linear drain). - /// Used to display the charge bar and to restore partial charge on cancel. - pub fn residual_charge_with_max(&self, now: Instant, charge_max: u32) -> u32 { let Some(ends_at) = self.ulti_ends_at else { return 0; }; @@ -147,7 +139,7 @@ impl GameState { return 0; } let remaining_ms = ends_at.duration_since(now).as_millis() as u64; - ((charge_max as u64 * remaining_ms) / self.ulti_duration_ms) as u32 + ((self.ulti_start_charge as u64 * remaining_ms) / self.ulti_duration_ms) as u32 } } From 2d192cf9c40f57eec1ef9c8a6ab13a2a5494c728 Mon Sep 17 00:00:00 2001 From: Jeck0v Date: Fri, 26 Jun 2026 18:33:23 +0200 Subject: [PATCH 2/6] Refacto: oracle gameplay + backend logic --- crates/game-logic/src/engine/core/charge.rs | 35 ++-- crates/game-logic/src/engine/core/ulti.rs | 19 +-- crates/game-logic/src/engine/mod.rs | 1 + .../game-logic/src/engine/services/charge.rs | 150 ++++++++++++++++++ crates/game-logic/src/engine/services/mod.rs | 2 + crates/game-logic/src/engine/services/ulti.rs | 125 +++++++++++++++ crates/game-logic/src/engine/states.rs | 8 +- 7 files changed, 306 insertions(+), 34 deletions(-) create mode 100644 crates/game-logic/src/engine/services/charge.rs create mode 100644 crates/game-logic/src/engine/services/mod.rs create mode 100644 crates/game-logic/src/engine/services/ulti.rs diff --git a/crates/game-logic/src/engine/core/charge.rs b/crates/game-logic/src/engine/core/charge.rs index c394151..59eac8a 100644 --- a/crates/game-logic/src/engine/core/charge.rs +++ b/crates/game-logic/src/engine/core/charge.rs @@ -1,6 +1,7 @@ use std::time::Instant; use crate::engine::config; +use crate::engine::services::charge::{score_to_charge, time_to_charge}; use crate::engine::states::GamePhase; use super::{ChargeSource, GameEngine}; @@ -17,15 +18,15 @@ impl GameEngine { return; } let delta_s = config::get().pve_tick_interval_ms as f32 / 1000.0; - self.state.time_charge_buffer += time_rate * delta_s; - let to_add = self.state.time_charge_buffer.floor() as u32; - if to_add > 0 { - self.state.time_charge_buffer -= to_add as f32; + let (gain, new_buffer) = + time_to_charge(time_rate, delta_s, self.state.time_charge_buffer); + self.state.time_charge_buffer = new_buffer; + if gain > 0 { let charge_max = stats.charge_profile.charge_max; self.state.ultimate_charge = self .state .ultimate_charge - .saturating_add(to_add) + .saturating_add(gain) .min(charge_max); } } @@ -37,24 +38,22 @@ impl GameEngine { if self.state.is_ulti_active(now) { return; } - let stats = self.character.stats(); - let profile = stats.charge_profile; + let profile = self.character.stats().charge_profile; let weight = match source { ChargeSource::Bumper => profile.weight_bumper, ChargeSource::Rail => profile.weight_rail, ChargeSource::Combo => profile.weight_combo, ChargeSource::Other => profile.weight_other, }; - let charge_ratio = config::get().ultime_charge_ratio; - let weighted = (base_pts as f32 * weight).round() as u32; - self.state.point_buffer = self.state.point_buffer.saturating_add(weighted); - let gain = self.state.point_buffer / charge_ratio; - self.state.point_buffer %= charge_ratio; - let charge_max = profile.charge_max; - self.state.ultimate_charge = self - .state - .ultimate_charge - .saturating_add(gain) - .min(charge_max); + let (new_charge, new_buffer) = score_to_charge( + base_pts, + weight, + self.state.point_buffer, + config::get().ultime_charge_ratio, + self.state.ultimate_charge, + profile.charge_max, + ); + self.state.ultimate_charge = new_charge; + self.state.point_buffer = new_buffer; } } diff --git a/crates/game-logic/src/engine/core/ulti.rs b/crates/game-logic/src/engine/core/ulti.rs index d30eb6d..f6b5d82 100644 --- a/crates/game-logic/src/engine/core/ulti.rs +++ b/crates/game-logic/src/engine/core/ulti.rs @@ -3,16 +3,11 @@ use std::time::{Duration, Instant}; use shared::screen::{ScreenEnvelope, ScreenEventType}; use crate::engine::config; +use crate::engine::services::ulti::{activation_min_charge_for, scale_duration}; use crate::player::personnages::character::UltiShape; use super::{GHOST_CYCLE, GameEngine, make_event_envelope}; -/// Minimum charge required to activate a time_slow ulti at `ratio` of `charge_max`. -/// Always returns at least 1 to avoid a zero threshold. -pub(crate) fn activation_min_charge_for(charge_max: u32, ratio: f32) -> u32 { - ((charge_max as f32 * ratio).ceil() as u32).max(1) -} - impl GameEngine { /// Returns the ulti_id Ghost's next cycle would produce without advancing the index. fn peek_ghost_ulti_id(&self) -> &'static str { @@ -135,13 +130,7 @@ impl GameEngine { duration_ms: full_duration, cancellable, } => { - // Scale duration proportionally to the committed charge. - // At full charge the result is identical to the configured duration. - let actual = if charge_max > 0 { - (*full_duration * activation_charge as u64 / charge_max as u64).max(1) - } else { - *full_duration - }; + let actual = scale_duration(*full_duration, activation_charge, charge_max); self.state.ulti_ends_at = Some(now + Duration::from_millis(actual)); self.state.ulti_duration_ms = actual; self.state.ulti_cancellable = *cancellable; @@ -207,7 +196,9 @@ mod tests { use crate::engine::config; use crate::engine::events::GameEvent; - use super::{GameEngine, activation_min_charge_for}; + use crate::engine::services::ulti::activation_min_charge_for; + + use super::GameEngine; fn oracle_engine() -> GameEngine { let mut e = GameEngine::new("oracle"); diff --git a/crates/game-logic/src/engine/mod.rs b/crates/game-logic/src/engine/mod.rs index 5b9854f..ee5e414 100644 --- a/crates/game-logic/src/engine/mod.rs +++ b/crates/game-logic/src/engine/mod.rs @@ -6,6 +6,7 @@ pub mod core; pub mod events; pub mod pve; pub mod scoring; +pub mod services; pub mod states; pub use core::GameEngine; diff --git a/crates/game-logic/src/engine/services/charge.rs b/crates/game-logic/src/engine/services/charge.rs new file mode 100644 index 0000000..3fb3b24 --- /dev/null +++ b/crates/game-logic/src/engine/services/charge.rs @@ -0,0 +1,150 @@ +/// Apply a scoring event to the charge system. +/// +/// `base_pts` is the raw score before multipliers. +/// `weight` is the per-source, per-character coefficient. +/// Returns `(new_charge, new_buffer)`. +pub fn score_to_charge( + base_pts: u64, + weight: f32, + buffer: u32, + charge_ratio: u32, + current_charge: u32, + charge_max: u32, +) -> (u32, u32) { + let weighted = (base_pts as f32 * weight).round() as u32; + let buffer = buffer.saturating_add(weighted); + let gain = buffer / charge_ratio; + let new_buffer = buffer % charge_ratio; + let new_charge = current_charge.saturating_add(gain).min(charge_max); + (new_charge, new_buffer) +} + +/// Accumulate time-based charge for one tick of `delta_s` seconds. +/// +/// Returns `(gain, new_buffer)`. Gain is the integer charge units earned; +/// the fractional remainder is carried in `new_buffer` for the next tick. +pub fn time_to_charge(time_rate: f32, delta_s: f32, buffer: f32) -> (u32, f32) { + let new_buffer = buffer + time_rate * delta_s; + let gain = new_buffer.floor() as u32; + (gain, new_buffer - gain as f32) +} + +#[cfg(test)] +mod tests { + use super::*; + + //score_to_charge + + #[test] + fn one_hit_at_ratio_gives_one_unit() { + // 100 pts, weight 1.0, ratio 100 → gain 1 + let (charge, buf) = score_to_charge(100, 1.0, 0, 100, 0, 80); + assert_eq!(charge, 1); + assert_eq!(buf, 0); + } + + #[test] + fn partial_hit_accumulates_in_buffer() { + // 50 pts → 50 in buffer, no gain yet + let (charge, buf) = score_to_charge(50, 1.0, 0, 100, 0, 80); + assert_eq!(charge, 0); + assert_eq!(buf, 50); + } + + #[test] + fn buffer_carries_over_between_hits() { + // 50 pts buffer from before + 50 pts → 100 total → gain 1 + let (charge, buf) = score_to_charge(50, 1.0, 50, 100, 0, 80); + assert_eq!(charge, 1); + assert_eq!(buf, 0); + } + + #[test] + fn charge_capped_at_max() { + // Huge points should not exceed charge_max + let (charge, _) = score_to_charge(100_000, 1.0, 0, 100, 79, 80); + assert_eq!(charge, 80); + } + + #[test] + fn weight_below_one_reduces_gain() { + // weight 0.3, 100 pts → 30 weighted, below ratio 100 → no gain, buffer 30 + let (charge, buf) = score_to_charge(100, 0.3, 0, 100, 0, 80); + assert_eq!(charge, 0); + assert_eq!(buf, 30); + } + + #[test] + fn weight_above_one_amplifies_gain() { + // weight 2.0, 100 pts → 200 weighted → gain 2 + let (charge, buf) = score_to_charge(100, 2.0, 0, 100, 0, 80); + assert_eq!(charge, 2); + assert_eq!(buf, 0); + } + + #[test] + fn zero_weight_produces_no_gain() { + let (charge, buf) = score_to_charge(10_000, 0.0, 0, 100, 0, 80); + assert_eq!(charge, 0); + assert_eq!(buf, 0); + } + + #[test] + fn existing_charge_preserved_on_no_gain() { + let (charge, _) = score_to_charge(10, 0.0, 0, 100, 42, 80); + assert_eq!(charge, 42); + } + + // time_to_charge + + #[test] + fn one_second_at_rate_one_gives_one_unit() { + let (gain, buf) = time_to_charge(1.0, 1.0, 0.0); + assert_eq!(gain, 1); + assert!((buf - 0.0).abs() < 1e-6); + } + + #[test] + fn sub_second_tick_accumulates_in_buffer() { + // 250 ms tick (pve_tick_interval_ms = 250), rate 1.0 → 0.25 per tick + let (gain, buf) = time_to_charge(1.0, 0.25, 0.0); + assert_eq!(gain, 0); + assert!((buf - 0.25).abs() < 1e-6); + } + + #[test] + fn four_ticks_of_250ms_give_one_unit() { + let mut buf = 0.0f32; + let mut total_gain = 0u32; + for _ in 0..4 { + let (g, b) = time_to_charge(1.0, 0.25, buf); + total_gain += g; + buf = b; + } + assert_eq!(total_gain, 1); + assert!(buf.abs() < 1e-6); + } + + #[test] + fn zero_rate_produces_no_gain() { + let (gain, buf) = time_to_charge(0.0, 1.0, 0.0); + assert_eq!(gain, 0); + assert!((buf - 0.0).abs() < 1e-6); + } + + #[test] + fn high_rate_gives_multiple_units_per_tick() { + // rate 4.0, delta 1.0s → 4 units + let (gain, buf) = time_to_charge(4.0, 1.0, 0.0); + assert_eq!(gain, 4); + assert!(buf.abs() < 1e-6); + } + + #[test] + fn existing_buffer_adds_to_new_accumulation() { + // Start with 0.9 in buffer, add 0.25 → 1.15 → gain 1, buf 0.15 + let (gain, buf) = time_to_charge(1.0, 0.25, 0.9); + assert_eq!(gain, 1); + assert!((buf - 0.15).abs() < 1e-5); + } +} diff --git a/crates/game-logic/src/engine/services/mod.rs b/crates/game-logic/src/engine/services/mod.rs new file mode 100644 index 0000000..9608d1c --- /dev/null +++ b/crates/game-logic/src/engine/services/mod.rs @@ -0,0 +1,2 @@ +pub mod charge; +pub mod ulti; diff --git a/crates/game-logic/src/engine/services/ulti.rs b/crates/game-logic/src/engine/services/ulti.rs new file mode 100644 index 0000000..1d041e4 --- /dev/null +++ b/crates/game-logic/src/engine/services/ulti.rs @@ -0,0 +1,125 @@ +/// Minimum charge required to activate a time_slow ulti at `ratio` of `charge_max`. +/// Always at least 1 to prevent a zero threshold. +pub fn activation_min_charge_for(charge_max: u32, ratio: f32) -> u32 { + ((charge_max as f32 * ratio).ceil() as u32).max(1) +} + +/// Scale a full ulti duration down to the fraction of charge actually committed. +/// Returns at least 1 ms. At `charge_max` the result equals `full_duration_ms`. +pub fn scale_duration(full_duration_ms: u64, activation_charge: u32, charge_max: u32) -> u64 { + if charge_max == 0 { + return full_duration_ms; + } + (full_duration_ms * activation_charge as u64 / charge_max as u64).max(1) +} + +/// Linear drain: how much charge remains given `remaining_ms` left out of `duration_ms`. +/// `start_charge` is the charge committed at activation. +pub fn residual_charge(start_charge: u32, remaining_ms: u64, duration_ms: u64) -> u32 { + if duration_ms == 0 { + return 0; + } + let clamped = remaining_ms.min(duration_ms); + ((start_charge as u64 * clamped) / duration_ms) as u32 +} + +#[cfg(test)] +mod tests { + use super::*; + + // activation_min_charge_for + + #[test] + fn min_charge_5_percent_of_80() { + // ceil(80 * 0.05) = ceil(4.0) = 4 + assert_eq!(activation_min_charge_for(80, 0.05), 4); + } + + #[test] + fn min_charge_rounds_up() { + // ceil(80 * 0.06) = ceil(4.8) = 5 + assert_eq!(activation_min_charge_for(80, 0.06), 5); + } + + #[test] + fn min_charge_zero_ratio_returns_one() { + // ceil(0) = 0, clamped to 1 + assert_eq!(activation_min_charge_for(80, 0.0), 1); + } + + #[test] + fn min_charge_full_ratio_returns_charge_max() { + assert_eq!(activation_min_charge_for(80, 1.0), 80); + } + + #[test] + fn min_charge_zero_max_returns_one() { + assert_eq!(activation_min_charge_for(0, 0.05), 1); + } + + // scale_duration + + #[test] + fn scale_full_charge_is_identity() { + assert_eq!(scale_duration(5_000, 80, 80), 5_000); + } + + #[test] + fn scale_half_charge_halves_duration() { + assert_eq!(scale_duration(5_000, 40, 80), 2_500); + } + + #[test] + fn scale_one_unit_minimum_never_zero() { + // smallest non-zero charge over a large max: floor rounds to 0, clamped to 1 + assert_eq!(scale_duration(5_000, 1, 10_000), 1); + } + + #[test] + fn scale_zero_charge_max_returns_full_duration() { + assert_eq!(scale_duration(5_000, 0, 0), 5_000); + } + + #[test] + fn scale_quarter_charge_rounds_down() { + // 5000 * 20 / 80 = 1250 + assert_eq!(scale_duration(5_000, 20, 80), 1_250); + } + + // residual_charge + + #[test] + fn residual_at_start_equals_start_charge() { + // remaining == duration → no time elapsed + assert_eq!(residual_charge(80, 5_000, 5_000), 80); + } + + #[test] + fn residual_at_half_time() { + assert_eq!(residual_charge(80, 2_500, 5_000), 40); + } + + #[test] + fn residual_at_zero_remaining() { + assert_eq!(residual_charge(80, 0, 5_000), 0); + } + + #[test] + fn residual_zero_duration_returns_zero() { + assert_eq!(residual_charge(80, 0, 0), 0); + } + + #[test] + fn residual_clamped_when_remaining_exceeds_duration() { + // remaining > duration should behave as if remaining == duration + assert_eq!(residual_charge(80, 9_999, 5_000), 80); + } + + #[test] + fn residual_proportional_to_start_charge() { + // Same time ratio, different start charges → proportional residual + let r40 = residual_charge(40, 2_500, 5_000); + let r80 = residual_charge(80, 2_500, 5_000); + assert_eq!(r80, r40 * 2); + } +} diff --git a/crates/game-logic/src/engine/states.rs b/crates/game-logic/src/engine/states.rs index 6735469..f431ab7 100644 --- a/crates/game-logic/src/engine/states.rs +++ b/crates/game-logic/src/engine/states.rs @@ -135,11 +135,15 @@ impl GameState { let Some(ends_at) = self.ulti_ends_at else { return 0; }; - if now >= ends_at || self.ulti_duration_ms == 0 { + if now >= ends_at { return 0; } let remaining_ms = ends_at.duration_since(now).as_millis() as u64; - ((self.ulti_start_charge as u64 * remaining_ms) / self.ulti_duration_ms) as u32 + crate::engine::services::ulti::residual_charge( + self.ulti_start_charge, + remaining_ms, + self.ulti_duration_ms, + ) } } From 41cf8c49aa2d4c1f7281a1ddecd6168279c6b4ee Mon Sep 17 00:00:00 2001 From: Jeck0v Date: Fri, 26 Jun 2026 18:36:06 +0200 Subject: [PATCH 3/6] fix fmt --- crates/game-logic/src/engine/core/charge.rs | 3 +-- crates/game-logic/src/engine/core/emit.rs | 4 ++-- crates/game-logic/src/engine/core/ulti.rs | 15 ++++++++++++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/game-logic/src/engine/core/charge.rs b/crates/game-logic/src/engine/core/charge.rs index 59eac8a..04197c6 100644 --- a/crates/game-logic/src/engine/core/charge.rs +++ b/crates/game-logic/src/engine/core/charge.rs @@ -18,8 +18,7 @@ impl GameEngine { return; } let delta_s = config::get().pve_tick_interval_ms as f32 / 1000.0; - let (gain, new_buffer) = - time_to_charge(time_rate, delta_s, self.state.time_charge_buffer); + let (gain, new_buffer) = time_to_charge(time_rate, delta_s, self.state.time_charge_buffer); self.state.time_charge_buffer = new_buffer; if gain > 0 { let charge_max = stats.charge_profile.charge_max; diff --git a/crates/game-logic/src/engine/core/emit.rs b/crates/game-logic/src/engine/core/emit.rs index 05145ef..5a21d2b 100644 --- a/crates/game-logic/src/engine/core/emit.rs +++ b/crates/game-logic/src/engine/core/emit.rs @@ -18,8 +18,8 @@ impl GameEngine { } else { self.state.ultimate_charge }; - let ulti_ready = - !self.state.is_ulti_active(now) && self.state.ultimate_charge >= self.activation_min_charge(); + let ulti_ready = !self.state.is_ulti_active(now) + && self.state.ultimate_charge >= self.activation_min_charge(); let mut payload = serde_json::json!({ "score": self.state.score, diff --git a/crates/game-logic/src/engine/core/ulti.rs b/crates/game-logic/src/engine/core/ulti.rs index f6b5d82..b7e9741 100644 --- a/crates/game-logic/src/engine/core/ulti.rs +++ b/crates/game-logic/src/engine/core/ulti.rs @@ -279,7 +279,10 @@ mod tests { .iter() .find(|e| e.event_type == ScreenEventType::UltimateTriggered) .expect("should trigger at full charge"); - assert_eq!(triggered.payload["duration_ms"].as_u64().unwrap(), full_duration); + assert_eq!( + triggered.payload["duration_ms"].as_u64().unwrap(), + full_duration + ); } #[test] @@ -301,7 +304,10 @@ mod tests { .expect("should emit UltimateStopped"); let residual = stopped.payload["ultimate_charge"].as_u64().unwrap() as u32; - assert!(residual <= start_charge, "residual must not exceed start charge"); + assert!( + residual <= start_charge, + "residual must not exceed start charge" + ); assert!( residual >= start_charge * 9 / 10, "residual {residual} should be ≥90% of start_charge {start_charge} on immediate cancel" @@ -359,7 +365,10 @@ mod tests { .find(|e| e.event_type == ScreenEventType::UltimateTriggered) .expect("should trigger"); assert_eq!(triggered.payload["ulti_id"], serde_json::json!("time_slow")); - assert_eq!(engine.state.ghost_cycle_index, 3, "cycle must advance after acceptance"); + assert_eq!( + engine.state.ghost_cycle_index, 3, + "cycle must advance after acceptance" + ); } #[test] From 566b85dda384eb4baeac2f964dbb602b0b82e93b Mon Sep 17 00:00:00 2001 From: Jeck0v Date: Fri, 26 Jun 2026 18:42:34 +0200 Subject: [PATCH 4/6] Add tests unitaire --- crates/game-logic/src/engine/core/input.rs | 309 +++++++++++++++++ crates/game-logic/src/engine/core/process.rs | 334 +++++++++++++++++++ 2 files changed, 643 insertions(+) diff --git a/crates/game-logic/src/engine/core/input.rs b/crates/game-logic/src/engine/core/input.rs index 6bf6c29..0ec7a22 100644 --- a/crates/game-logic/src/engine/core/input.rs +++ b/crates/game-logic/src/engine/core/input.rs @@ -121,3 +121,312 @@ impl GameEngine { self.process(event) } } + +#[cfg(test)] +mod tests { + use shared::events::{ButtonInput, GyroInput, InboundMessage, PlungerInput}; + use shared::model::ButtonId; + use shared::screen::{ScreenEnvelope, ScreenEventType, ScreenId, ScreenTarget}; + + use crate::engine::events::GameEvent; + use crate::engine::states::GamePhase; + + use super::GameEngine; + + fn started() -> GameEngine { + let mut e = GameEngine::new("enforcer"); + e.process(GameEvent::StartGame); + e + } + + fn btn(id: ButtonId, state: u8) -> InboundMessage { + InboundMessage::Button(ButtonInput { id, state, ts: 0 }) + } + + fn screen_ev(event_type: ScreenEventType) -> ScreenEnvelope { + ScreenEnvelope { + from: ScreenId::GameEngine, + to: ScreenTarget::Broadcast, + event_type, + payload: serde_json::json!({}), + } + } + + // handle_inbound: flipper buttons + + #[test] + fn l1_press_emits_flipper_left() { + let mut engine = started(); + let evs = engine.handle_inbound(&btn(ButtonId::L1, 1)); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::FlipperLeft)); + } + + #[test] + fn r1_press_emits_flipper_right() { + let mut engine = started(); + let evs = engine.handle_inbound(&btn(ButtonId::R1, 1)); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::FlipperRight)); + } + + #[test] + fn l1_release_emits_flipper_left_but_no_button_pressed_event() { + let mut engine = started(); + // state=0 → release; should not trigger ButtonPressed (no combo push) + let evs = engine.handle_inbound(&btn(ButtonId::L1, 0)); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::FlipperLeft)); + // No ScoreUpdate/ScoreDelta from combo processing + assert!(!evs.iter().any(|e| e.event_type == ScreenEventType::ScoreUpdate)); + } + + // handle_inbound: ulti (L2/R2) + + #[test] + fn l2_press_during_game_triggers_ulti_path() { + let mut engine = started(); + // Charge to full so ulti actually fires + let charge_max = engine.character.stats().charge_profile.charge_max; + engine.state.ultimate_charge = charge_max; + let evs = engine.handle_inbound(&btn(ButtonId::L2, 1)); + assert!(evs + .iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered)); + } + + #[test] + fn r2_press_during_game_triggers_ulti_path() { + let mut engine = started(); + let charge_max = engine.character.stats().charge_profile.charge_max; + engine.state.ultimate_charge = charge_max; + let evs = engine.handle_inbound(&btn(ButtonId::R2, 1)); + assert!(evs + .iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered)); + } + + #[test] + fn l2_press_outside_in_game_returns_empty() { + let mut engine = GameEngine::new("enforcer"); + // Phase is Idle + let evs = engine.handle_inbound(&btn(ButtonId::L2, 1)); + assert!(evs.is_empty()); + } + + #[test] + fn l2_release_does_not_trigger_ulti() { + let mut engine = started(); + let charge_max = engine.character.stats().charge_profile.charge_max; + engine.state.ultimate_charge = charge_max; + // state=0 → release, should be ignored + let evs = engine.handle_inbound(&btn(ButtonId::L2, 0)); + assert!(!evs + .iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered)); + } + + // handle_inbound: plunger + + #[test] + fn plunger_held_emits_plunger_charge_during_game() { + let mut engine = started(); + let evs = engine.handle_inbound(&btn(ButtonId::UnderPlunger, 1)); + assert!(evs + .iter() + .any(|e| e.event_type == ScreenEventType::PlungerCharge)); + } + + #[test] + fn plunger_released_emits_plunger_charge_and_ball_launched() { + let mut engine = started(); + // state=0 → release fires BallLaunched (no-op in process, but the inbound path runs) + let evs = engine.handle_inbound(&btn(ButtonId::UnderPlunger, 0)); + assert!(evs + .iter() + .any(|e| e.event_type == ScreenEventType::PlungerCharge)); + } + + #[test] + fn plunger_outside_in_game_returns_empty() { + let mut engine = GameEngine::new("enforcer"); + let evs = engine.handle_inbound(&btn(ButtonId::UnderPlunger, 0)); + assert!(evs.is_empty()); + } + + // handle_inbound: gyro + + #[test] + fn gyro_tilt_triggers_tilt_detected() { + let mut engine = started(); + let msg = InboundMessage::Gyro(GyroInput { + ax: 0.0, + ay: 0.0, + az: 0.0, + tilt: true, + }); + let evs = engine.handle_inbound(&msg); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::TiltPenalty)); + } + + #[test] + fn gyro_no_tilt_returns_empty() { + let mut engine = started(); + let msg = InboundMessage::Gyro(GyroInput { + ax: 0.0, + ay: 0.0, + az: 0.0, + tilt: false, + }); + let evs = engine.handle_inbound(&msg); + assert!(evs.is_empty()); + } + + // handle_inbound: plunger (direct InboundMessage::Plunger) + + #[test] + fn plunger_inbound_release_processes_ball_launched() { + let mut engine = started(); + let msg = InboundMessage::Plunger(PlungerInput { state: 0, ts: 0 }); + // BallLaunched is a no-op in process but must not panic + let evs = engine.handle_inbound(&msg); + assert!(evs.is_empty()); + } + + #[test] + fn plunger_inbound_held_returns_empty() { + let mut engine = started(); + let msg = InboundMessage::Plunger(PlungerInput { state: 1, ts: 0 }); + let evs = engine.handle_inbound(&msg); + assert!(evs.is_empty()); + } + + // handle_screen_event + + #[test] + fn screen_start_game_sets_in_game() { + let mut engine = GameEngine::new("enforcer"); + engine.handle_screen_event(&screen_ev(ScreenEventType::StartGame)); + assert_eq!(engine.state.phase, GamePhase::InGame); + } + + #[test] + fn screen_end_game_sets_game_over() { + let mut engine = started(); + engine.handle_screen_event(&screen_ev(ScreenEventType::EndGame)); + assert_eq!(engine.state.phase, GamePhase::GameOver); + } + + #[test] + fn screen_ball_lost_decrements_lives() { + let mut engine = started(); + let before = engine.state.lives; + engine.handle_screen_event(&screen_ev(ScreenEventType::BallLost)); + assert_eq!(engine.state.lives, before - 1); + } + + #[test] + fn screen_ball_saved_is_no_op() { + let mut engine = started(); + let evs = engine.handle_screen_event(&screen_ev(ScreenEventType::BallSaved)); + assert!(evs.is_empty()); + } + + #[test] + fn screen_life_up_increments_lives() { + let mut engine = started(); + let before = engine.state.lives; + engine.handle_screen_event(&screen_ev(ScreenEventType::LifeUp)); + assert_eq!(engine.state.lives, before + 1); + } + + #[test] + fn screen_ultimate_activated_is_ignored() { + let mut engine = started(); + let before = engine.state.score; + let evs = engine.handle_screen_event(&screen_ev(ScreenEventType::UltimateActivated)); + assert!(evs.is_empty()); + assert_eq!(engine.state.score, before); + } + + #[test] + fn screen_capacity_l2_triggers_ulti_path() { + let mut engine = started(); + let charge_max = engine.character.stats().charge_profile.charge_max; + engine.state.ultimate_charge = charge_max; + let evs = engine.handle_screen_event(&screen_ev(ScreenEventType::CapacityL2)); + assert!(evs + .iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered)); + } + + #[test] + fn screen_bumper_adds_score() { + let mut engine = started(); + let before = engine.state.score; + engine.handle_screen_event(&screen_ev(ScreenEventType::Bumper)); + assert!(engine.state.score > before); + } + + #[test] + fn screen_bumper_with_ball_id_propagates() { + let mut engine = started(); + let mut env = screen_ev(ScreenEventType::Bumper); + env.payload = serde_json::json!({ "ball_id": "ball-42" }); + let evs = engine.handle_screen_event(&env); + let update = evs + .iter() + .find(|e| e.event_type == ScreenEventType::ScoreUpdate) + .unwrap(); + assert_eq!(update.payload["ball_id"], serde_json::json!("ball-42")); + } + + #[test] + fn screen_bumper_triangle_scores_more_than_regular() { + let score_for = |ev_type: ScreenEventType| { + let mut e = started(); + let before = e.state.score; + e.handle_screen_event(&screen_ev(ev_type)); + e.state.score - before + }; + assert!(score_for(ScreenEventType::BumperTriangle) > score_for(ScreenEventType::Bumper)); + } + + #[test] + fn screen_portal_used_adds_score() { + let mut engine = started(); + let before = engine.state.score; + engine.handle_screen_event(&screen_ev(ScreenEventType::PortalUsed)); + assert!(engine.state.score > before); + } + + #[test] + fn screen_flipper_left_emits_no_crash() { + let mut engine = started(); + // ButtonPressed in InGame with no combo match → ComboResult::None → no envelopes + let evs = engine.handle_screen_event(&screen_ev(ScreenEventType::FlipperLeft)); + // just ensure it doesn't panic; combo result may be empty + let _ = evs; + } + + #[test] + fn screen_ball_saver_ready_adds_score() { + let mut engine = started(); + let before = engine.state.score; + engine.handle_screen_event(&screen_ev(ScreenEventType::BallSaverReady)); + assert!(engine.state.score > before); + } + + #[test] + fn screen_multiball_triggered_scores() { + let mut engine = started(); + let before = engine.state.score; + engine.handle_screen_event(&screen_ev(ScreenEventType::MultiballTriggered)); + assert!(engine.state.score > before); + } + + #[test] + fn screen_unknown_event_returns_empty() { + let mut engine = started(); + // GameOver is not handled by handle_screen_event → falls through to "other" + let evs = engine.handle_screen_event(&screen_ev(ScreenEventType::GameOver)); + assert!(evs.is_empty()); + } +} diff --git a/crates/game-logic/src/engine/core/process.rs b/crates/game-logic/src/engine/core/process.rs index 80a5688..88871e2 100644 --- a/crates/game-logic/src/engine/core/process.rs +++ b/crates/game-logic/src/engine/core/process.rs @@ -301,3 +301,337 @@ impl GameEngine { envelopes } } + +#[cfg(test)] +mod tests { + use shared::screen::ScreenEventType; + + use crate::engine::events::{GameEvent, GameOverReason}; + use crate::engine::states::GamePhase; + + use super::GameEngine; + + fn started(slug: &str) -> GameEngine { + let mut e = GameEngine::new(slug); + e.process(GameEvent::StartGame); + e + } + + // EndGame + + #[test] + fn end_game_sets_phase_game_over() { + let mut engine = started("enforcer"); + engine.process(GameEvent::EndGame); + assert_eq!(engine.state.phase, GamePhase::GameOver); + } + + #[test] + fn end_game_emits_game_over_event() { + let mut engine = started("enforcer"); + let evs = engine.process(GameEvent::EndGame); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::GameOver)); + } + + // BallLaunched / BallSaved + + #[test] + fn ball_launched_is_no_op() { + let mut engine = started("enforcer"); + let before = engine.state.score; + let evs = engine.process(GameEvent::BallLaunched); + assert!(evs.is_empty()); + assert_eq!(engine.state.score, before); + } + + #[test] + fn ball_saved_is_no_op() { + let mut engine = started("enforcer"); + let evs = engine.process(GameEvent::BallSaved); + assert!(evs.is_empty()); + } + + // BallLost + + #[test] + fn ball_lost_decrements_lives() { + let mut engine = started("enforcer"); + let before = engine.state.lives; + engine.process(GameEvent::BallLost); + assert_eq!(engine.state.lives, before - 1); + } + + #[test] + fn ball_lost_emits_life_update_when_lives_remain() { + let mut engine = started("enforcer"); + // default 3 lives → losing one leaves 2 + let evs = engine.process(GameEvent::BallLost); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::LifeUpdate)); + } + + #[test] + fn ball_lost_increments_balls_lost_counter() { + let mut engine = started("enforcer"); + engine.process(GameEvent::BallLost); + assert_eq!(engine.state.balls_lost_since_start, 1); + } + + #[test] + fn ball_lost_on_last_life_triggers_game_over() { + let mut engine = started("enforcer"); + engine.state.lives = 1; + let evs = engine.process(GameEvent::BallLost); + assert_eq!(engine.state.phase, GamePhase::GameOver); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::GameOver)); + } + + #[test] + fn ball_lost_ignored_outside_in_game() { + let mut engine = GameEngine::new("enforcer"); + // still Idle, not started + let evs = engine.process(GameEvent::BallLost); + assert!(evs.is_empty()); + assert_eq!(engine.state.balls_lost_since_start, 0); + } + + // TiltDetected + + #[test] + fn first_tilt_emits_penalty() { + let mut engine = started("enforcer"); + engine.state.score = 10_000; + let evs = engine.process(GameEvent::TiltDetected); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::TiltPenalty)); + assert!(engine.state.score < 10_000); + } + + #[test] + fn third_tilt_locks_score() { + let mut engine = started("enforcer"); + engine.process(GameEvent::TiltDetected); + engine.process(GameEvent::TiltDetected); + engine.process(GameEvent::TiltDetected); + assert!(engine.state.cheating_detected); + let evs = engine + .state + .tilt_state + .count; + assert_eq!(evs, 3); + } + + #[test] + fn third_tilt_emits_cheating_detected() { + let mut engine = started("enforcer"); + engine.process(GameEvent::TiltDetected); + engine.process(GameEvent::TiltDetected); + let evs = engine.process(GameEvent::TiltDetected); + assert!(evs + .iter() + .any(|e| e.event_type == ScreenEventType::CheatingDetected)); + } + + // LifeUp + + #[test] + fn life_up_increments_lives() { + let mut engine = started("enforcer"); + let before = engine.state.lives; + engine.process(GameEvent::LifeUp); + assert_eq!(engine.state.lives, before + 1); + } + + #[test] + fn life_up_emits_life_update() { + let mut engine = started("enforcer"); + let evs = engine.process(GameEvent::LifeUp); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::LifeUpdate)); + } + + // MultiballTriggered / MultiballWin + + #[test] + fn multiball_triggered_scores_and_emits_multiball_win() { + let mut engine = started("enforcer"); + let before = engine.state.score; + let evs = engine.process(GameEvent::MultiballTriggered); + assert!(engine.state.score > before); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::MultiballWin)); + } + + #[test] + fn multiball_triggered_ignored_outside_in_game() { + let mut engine = GameEngine::new("enforcer"); + let evs = engine.process(GameEvent::MultiballTriggered); + assert!(evs.is_empty()); + } + + #[test] + fn multiball_win_emits_score_delta_and_update() { + let mut engine = started("enforcer"); + let evs = engine.process(GameEvent::MultiballWin); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::ScoreDelta)); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::ScoreUpdate)); + } + + // PortalUsed + + #[test] + fn portal_used_adds_score_and_emits_events() { + let mut engine = started("enforcer"); + let before = engine.state.score; + let evs = engine.process(GameEvent::PortalUsed { ball_id: None }); + assert!(engine.state.score > before); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::ScoreDelta)); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::ScoreUpdate)); + } + + #[test] + fn portal_used_ball_id_propagated() { + let mut engine = started("enforcer"); + let evs = engine.process(GameEvent::PortalUsed { + ball_id: Some("ball-xyz".into()), + }); + let update = evs + .iter() + .find(|e| e.event_type == ScreenEventType::ScoreUpdate) + .unwrap(); + assert_eq!(update.payload["ball_id"], serde_json::json!("ball-xyz")); + } + + // BallSaverReady + + #[test] + fn ball_saver_ready_adds_score() { + let mut engine = started("enforcer"); + let before = engine.state.score; + engine.process(GameEvent::BallSaverReady); + assert!(engine.state.score > before); + } + + #[test] + fn ball_saver_ready_emits_ball_saver_event() { + let mut engine = started("enforcer"); + let evs = engine.process(GameEvent::BallSaverReady); + assert!(evs.iter().any(|e| e.event_type == ScreenEventType::BallSaverReady)); + } + + #[test] + fn ball_saver_ready_ignored_outside_in_game() { + let mut engine = GameEngine::new("enforcer"); + let evs = engine.process(GameEvent::BallSaverReady); + assert!(evs.is_empty()); + } + + // BumperTriangleHit + + #[test] + fn bumper_triangle_scores_more_than_regular_bumper() { + let score_for = |event: GameEvent| { + let mut e = started("enforcer"); + let before = e.state.score; + e.process(event); + e.state.score - before + }; + let regular = score_for(GameEvent::BumperHit { pts: 100, ball_id: None }); + let triangle = score_for(GameEvent::BumperTriangleHit { pts: 150, ball_id: None }); + assert!(triangle > regular); + } + + // BossDefeated + + #[test] + fn boss_defeated_emits_event_with_id() { + let mut engine = started("enforcer"); + let evs = engine.process(GameEvent::BossDefeated { boss_id: 2 }); + let ev = evs + .iter() + .find(|e| e.event_type == ScreenEventType::BossDefeated) + .expect("should emit BossDefeated"); + assert_eq!(ev.payload["boss_id"], serde_json::json!(2)); + } + + // GameOverTriggered + + #[test] + fn game_over_triggered_sets_phase() { + let mut engine = started("enforcer"); + engine.process(GameEvent::GameOverTriggered { + reason: GameOverReason::PlayerQuit, + }); + assert_eq!(engine.state.phase, GamePhase::GameOver); + } + + // ScoreMultiplierActivated + + #[test] + fn score_multiplier_activated_emits_multiplier_update() { + let mut engine = started("enforcer"); + let evs = engine.process(GameEvent::ScoreMultiplierActivated); + assert!(evs + .iter() + .any(|e| e.event_type == ScreenEventType::MultiplierUpdate)); + } + + // UltimateActivated (legacy no-op) + + #[test] + fn ultimate_activated_legacy_is_no_op() { + let mut engine = started("enforcer"); + let before = engine.state.score; + let evs = engine.process(GameEvent::UltimateActivated { + player_id: "p1".into(), + }); + assert!(evs.is_empty()); + assert_eq!(engine.state.score, before); + } + + // ComboActivated + + #[test] + fn combo_activated_with_bonus_adds_score() { + let mut engine = started("enforcer"); + let before = engine.state.score; + engine.process(GameEvent::ComboActivated(crate::combo::ComboEffect { + combo_id: 0, + bonus_pts: 1_000, + sequence: vec![], + })); + assert!(engine.state.score > before); + } + + #[test] + fn combo_activated_emits_combo_activated_event() { + let mut engine = started("enforcer"); + let evs = engine.process(GameEvent::ComboActivated(crate::combo::ComboEffect { + combo_id: 0, + bonus_pts: 500, + sequence: vec![], + })); + assert!(evs + .iter() + .any(|e| e.event_type == ScreenEventType::ComboActivated)); + } + + #[test] + fn combo_activated_zero_bonus_does_not_add_score() { + let mut engine = started("enforcer"); + let before = engine.state.score; + engine.process(GameEvent::ComboActivated(crate::combo::ComboEffect { + combo_id: 0, + bonus_pts: 0, + sequence: vec![], + })); + assert_eq!(engine.state.score, before); + } + + // ButtonPressed outside InGame + + #[test] + fn button_pressed_ignored_outside_in_game() { + let mut engine = GameEngine::new("enforcer"); + let evs = engine.process(GameEvent::ButtonPressed { + side: crate::engine::events::ButtonSide::Left, + }); + assert!(evs.is_empty()); + } +} From aef4110d4674a0057b76dfc42e284dc1decd5416 Mon Sep 17 00:00:00 2001 From: Jeck0v Date: Fri, 26 Jun 2026 18:44:06 +0200 Subject: [PATCH 5/6] Update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c61ab05..fc2a29a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ docker compose up -d mosquitto # Broker only (for MQTT Explorer) ```bash rustup default stable # Rust 1.89+ cargo build # Build workspace -cargo test # 158 tests (api + game-logic + screen-hub + mqtt-bridge + shared) +cargo test # 173 tests (api + game-logic + screen-hub + mqtt-bridge + shared) ``` Documentation of the codebase: ```bash @@ -120,7 +120,7 @@ HTML report + Tab of the coverage ``` cargo llvm-cov --html ``` -(Last tested 18/06/2026: ~68.55% code coverage) +(Last tested 26/06/2026: ~69.19% code coverage) ## Tech stack From 4860b41bcb81c08801196e0a2a14470014d2dee0 Mon Sep 17 00:00:00 2001 From: Jeck0v Date: Fri, 26 Jun 2026 18:45:11 +0200 Subject: [PATCH 6/6] fix clippy --- crates/game-logic/src/engine/core/input.rs | 67 +++++++++----- crates/game-logic/src/engine/core/process.rs | 91 ++++++++++++++------ 2 files changed, 109 insertions(+), 49 deletions(-) diff --git a/crates/game-logic/src/engine/core/input.rs b/crates/game-logic/src/engine/core/input.rs index 0ec7a22..f47ec35 100644 --- a/crates/game-logic/src/engine/core/input.rs +++ b/crates/game-logic/src/engine/core/input.rs @@ -158,14 +158,20 @@ mod tests { fn l1_press_emits_flipper_left() { let mut engine = started(); let evs = engine.handle_inbound(&btn(ButtonId::L1, 1)); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::FlipperLeft)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::FlipperLeft) + ); } #[test] fn r1_press_emits_flipper_right() { let mut engine = started(); let evs = engine.handle_inbound(&btn(ButtonId::R1, 1)); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::FlipperRight)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::FlipperRight) + ); } #[test] @@ -173,9 +179,15 @@ mod tests { let mut engine = started(); // state=0 → release; should not trigger ButtonPressed (no combo push) let evs = engine.handle_inbound(&btn(ButtonId::L1, 0)); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::FlipperLeft)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::FlipperLeft) + ); // No ScoreUpdate/ScoreDelta from combo processing - assert!(!evs.iter().any(|e| e.event_type == ScreenEventType::ScoreUpdate)); + assert!( + !evs.iter() + .any(|e| e.event_type == ScreenEventType::ScoreUpdate) + ); } // handle_inbound: ulti (L2/R2) @@ -187,9 +199,10 @@ mod tests { let charge_max = engine.character.stats().charge_profile.charge_max; engine.state.ultimate_charge = charge_max; let evs = engine.handle_inbound(&btn(ButtonId::L2, 1)); - assert!(evs - .iter() - .any(|e| e.event_type == ScreenEventType::UltimateTriggered)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered) + ); } #[test] @@ -198,9 +211,10 @@ mod tests { let charge_max = engine.character.stats().charge_profile.charge_max; engine.state.ultimate_charge = charge_max; let evs = engine.handle_inbound(&btn(ButtonId::R2, 1)); - assert!(evs - .iter() - .any(|e| e.event_type == ScreenEventType::UltimateTriggered)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered) + ); } #[test] @@ -218,9 +232,10 @@ mod tests { engine.state.ultimate_charge = charge_max; // state=0 → release, should be ignored let evs = engine.handle_inbound(&btn(ButtonId::L2, 0)); - assert!(!evs - .iter() - .any(|e| e.event_type == ScreenEventType::UltimateTriggered)); + assert!( + !evs.iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered) + ); } // handle_inbound: plunger @@ -229,9 +244,10 @@ mod tests { fn plunger_held_emits_plunger_charge_during_game() { let mut engine = started(); let evs = engine.handle_inbound(&btn(ButtonId::UnderPlunger, 1)); - assert!(evs - .iter() - .any(|e| e.event_type == ScreenEventType::PlungerCharge)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::PlungerCharge) + ); } #[test] @@ -239,9 +255,10 @@ mod tests { let mut engine = started(); // state=0 → release fires BallLaunched (no-op in process, but the inbound path runs) let evs = engine.handle_inbound(&btn(ButtonId::UnderPlunger, 0)); - assert!(evs - .iter() - .any(|e| e.event_type == ScreenEventType::PlungerCharge)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::PlungerCharge) + ); } #[test] @@ -263,7 +280,10 @@ mod tests { tilt: true, }); let evs = engine.handle_inbound(&msg); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::TiltPenalty)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::TiltPenalty) + ); } #[test] @@ -352,9 +372,10 @@ mod tests { let charge_max = engine.character.stats().charge_profile.charge_max; engine.state.ultimate_charge = charge_max; let evs = engine.handle_screen_event(&screen_ev(ScreenEventType::CapacityL2)); - assert!(evs - .iter() - .any(|e| e.event_type == ScreenEventType::UltimateTriggered)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::UltimateTriggered) + ); } #[test] diff --git a/crates/game-logic/src/engine/core/process.rs b/crates/game-logic/src/engine/core/process.rs index 88871e2..80ae1e3 100644 --- a/crates/game-logic/src/engine/core/process.rs +++ b/crates/game-logic/src/engine/core/process.rs @@ -330,7 +330,10 @@ mod tests { fn end_game_emits_game_over_event() { let mut engine = started("enforcer"); let evs = engine.process(GameEvent::EndGame); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::GameOver)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::GameOver) + ); } // BallLaunched / BallSaved @@ -366,7 +369,10 @@ mod tests { let mut engine = started("enforcer"); // default 3 lives → losing one leaves 2 let evs = engine.process(GameEvent::BallLost); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::LifeUpdate)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::LifeUpdate) + ); } #[test] @@ -382,7 +388,10 @@ mod tests { engine.state.lives = 1; let evs = engine.process(GameEvent::BallLost); assert_eq!(engine.state.phase, GamePhase::GameOver); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::GameOver)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::GameOver) + ); } #[test] @@ -401,7 +410,10 @@ mod tests { let mut engine = started("enforcer"); engine.state.score = 10_000; let evs = engine.process(GameEvent::TiltDetected); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::TiltPenalty)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::TiltPenalty) + ); assert!(engine.state.score < 10_000); } @@ -412,10 +424,7 @@ mod tests { engine.process(GameEvent::TiltDetected); engine.process(GameEvent::TiltDetected); assert!(engine.state.cheating_detected); - let evs = engine - .state - .tilt_state - .count; + let evs = engine.state.tilt_state.count; assert_eq!(evs, 3); } @@ -425,9 +434,10 @@ mod tests { engine.process(GameEvent::TiltDetected); engine.process(GameEvent::TiltDetected); let evs = engine.process(GameEvent::TiltDetected); - assert!(evs - .iter() - .any(|e| e.event_type == ScreenEventType::CheatingDetected)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::CheatingDetected) + ); } // LifeUp @@ -444,7 +454,10 @@ mod tests { fn life_up_emits_life_update() { let mut engine = started("enforcer"); let evs = engine.process(GameEvent::LifeUp); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::LifeUpdate)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::LifeUpdate) + ); } // MultiballTriggered / MultiballWin @@ -455,7 +468,10 @@ mod tests { let before = engine.state.score; let evs = engine.process(GameEvent::MultiballTriggered); assert!(engine.state.score > before); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::MultiballWin)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::MultiballWin) + ); } #[test] @@ -469,8 +485,14 @@ mod tests { fn multiball_win_emits_score_delta_and_update() { let mut engine = started("enforcer"); let evs = engine.process(GameEvent::MultiballWin); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::ScoreDelta)); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::ScoreUpdate)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::ScoreDelta) + ); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::ScoreUpdate) + ); } // PortalUsed @@ -481,8 +503,14 @@ mod tests { let before = engine.state.score; let evs = engine.process(GameEvent::PortalUsed { ball_id: None }); assert!(engine.state.score > before); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::ScoreDelta)); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::ScoreUpdate)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::ScoreDelta) + ); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::ScoreUpdate) + ); } #[test] @@ -512,7 +540,10 @@ mod tests { fn ball_saver_ready_emits_ball_saver_event() { let mut engine = started("enforcer"); let evs = engine.process(GameEvent::BallSaverReady); - assert!(evs.iter().any(|e| e.event_type == ScreenEventType::BallSaverReady)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::BallSaverReady) + ); } #[test] @@ -532,8 +563,14 @@ mod tests { e.process(event); e.state.score - before }; - let regular = score_for(GameEvent::BumperHit { pts: 100, ball_id: None }); - let triangle = score_for(GameEvent::BumperTriangleHit { pts: 150, ball_id: None }); + let regular = score_for(GameEvent::BumperHit { + pts: 100, + ball_id: None, + }); + let triangle = score_for(GameEvent::BumperTriangleHit { + pts: 150, + ball_id: None, + }); assert!(triangle > regular); } @@ -567,9 +604,10 @@ mod tests { fn score_multiplier_activated_emits_multiplier_update() { let mut engine = started("enforcer"); let evs = engine.process(GameEvent::ScoreMultiplierActivated); - assert!(evs - .iter() - .any(|e| e.event_type == ScreenEventType::MultiplierUpdate)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::MultiplierUpdate) + ); } // UltimateActivated (legacy no-op) @@ -607,9 +645,10 @@ mod tests { bonus_pts: 500, sequence: vec![], })); - assert!(evs - .iter() - .any(|e| e.event_type == ScreenEventType::ComboActivated)); + assert!( + evs.iter() + .any(|e| e.event_type == ScreenEventType::ComboActivated) + ); } #[test]