From 89c51ab712e10d6f463ffcca06de3331579bd388 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 5 May 2026 18:49:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(settings):=20time-bonus=20multiplier=20sli?= =?UTF-8?q?der=20in=20Settings=20=E2=86=92=20Gameplay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cosmetic-only player setting (default 1.0, range 0.0-2.0, step 0.1) that scales the time-bonus row shown in the win-summary modal's score breakdown. Achievement thresholds, lifetime score totals, and leaderboard submissions still use the raw values produced by `solitaire_core::scoring`, so the multiplier never affects what gets recorded — just what the player sees on the win screen. - New `Settings::time_bonus_multiplier` field with `#[serde(default)]` + `sanitized()` clamp so older settings.json files load cleanly. - New constants `TIME_BONUS_MULTIPLIER_{MIN,MAX,STEP}` re-exported through `solitaire_data::lib`. - `settings_plugin` adds a slider row under the Gameplay header matching the existing tooltip-delay control. - `win_summary_plugin` applies the multiplier when rendering the time-bonus row of the score breakdown; "Off" label when 0.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_data/src/lib.rs | 4 +- solitaire_data/src/settings.rs | 152 ++++++++++++++++++++- solitaire_engine/src/settings_plugin.rs | 113 ++++++++++++++- solitaire_engine/src/win_summary_plugin.rs | 123 +++++++++++++++-- 4 files changed, 372 insertions(+), 20 deletions(-) diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 2ff5451..04f84f3 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -141,7 +141,9 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS}; pub mod settings; pub use settings::{ load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend, - Theme, WindowGeometry, TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, + Theme, WindowGeometry, TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, + TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, + TOOLTIP_DELAY_STEP_SECS, }; pub mod auth_tokens; diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 886bf1c..4e1af79 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -151,6 +151,21 @@ pub struct Settings { /// `#[serde(default = "default_tooltip_delay")]`. #[serde(default = "default_tooltip_delay")] pub tooltip_delay_secs: f32, + /// Multiplier applied to the post-game time-bonus score component + /// shown in the win-summary modal. Range + /// `[TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX]` + /// (`0.0`–`2.0`); default `1.0` keeps the existing behaviour. + /// + /// **COSMETIC ONLY** — this multiplier changes what the player + /// sees in the win modal's score breakdown but does **not** affect + /// achievement unlock thresholds, lifetime score totals, or + /// leaderboard submissions, which all use the raw, unmultiplied + /// score values produced by `solitaire_core`. Older + /// `settings.json` files written before this field existed + /// deserialize cleanly to `1.0` via + /// `#[serde(default = "default_time_bonus_multiplier")]`. + #[serde(default = "default_time_bonus_multiplier")] + pub time_bonus_multiplier: f32, } fn default_draw_mode() -> DrawMode { @@ -189,6 +204,25 @@ pub const TOOLTIP_DELAY_MAX_SECS: f32 = 1.5; /// Increment applied by the tooltip-delay decrement / increment buttons. pub const TOOLTIP_DELAY_STEP_SECS: f32 = 0.1; +/// Lower bound of the player-tunable time-bonus multiplier. `0.0` +/// disables the time-bonus row entirely (renders as "Off" in the UI). +pub const TIME_BONUS_MULTIPLIER_MIN: f32 = 0.0; + +/// Upper bound of the player-tunable time-bonus multiplier. `2.0` +/// doubles the displayed time bonus. +pub const TIME_BONUS_MULTIPLIER_MAX: f32 = 2.0; + +/// Increment applied by the time-bonus multiplier decrement / +/// increment buttons. +pub const TIME_BONUS_MULTIPLIER_STEP: f32 = 0.1; + +/// Default value for [`Settings::time_bonus_multiplier`]. `1.0` keeps +/// the displayed time bonus identical to the raw value produced by +/// `solitaire_core::scoring::compute_time_bonus`. +fn default_time_bonus_multiplier() -> f32 { + 1.0 +} + impl Default for Settings { fn default() -> Self { Self { @@ -206,14 +240,15 @@ impl Default for Settings { selected_theme_id: default_theme_id(), shown_achievement_onboarding: false, tooltip_delay_secs: default_tooltip_delay(), + time_bonus_multiplier: default_time_bonus_multiplier(), } } } impl Settings { - /// Clamps `sfx_volume`, `music_volume`, and `tooltip_delay_secs` into - /// their respective ranges after deserialization or hand-editing of - /// `settings.json`. + /// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`, and + /// `time_bonus_multiplier` into their respective ranges after + /// deserialization or hand-editing of `settings.json`. pub fn sanitized(self) -> Self { Self { sfx_volume: self.sfx_volume.clamp(0.0, 1.0), @@ -221,6 +256,9 @@ impl Settings { tooltip_delay_secs: self .tooltip_delay_secs .clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS), + time_bonus_multiplier: self + .time_bonus_multiplier + .clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX), ..self } } @@ -245,6 +283,20 @@ impl Settings { .clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS); self.tooltip_delay_secs } + + /// Adjust the time-bonus multiplier by `delta`, clamped to + /// `[TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX]`. The + /// result is rounded to one decimal place so the readout stays + /// clean across repeated `±` clicks (avoids float drift like + /// `0.30000004`). Returns the new value. + pub fn adjust_time_bonus_multiplier(&mut self, delta: f32) -> f32 { + let raw = (self.time_bonus_multiplier + delta) + .clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX); + // Round to 1 decimal place — the slider step is 0.1, so this + // collapses any FP drift introduced by repeated additions. + self.time_bonus_multiplier = (raw * 10.0).round() / 10.0; + self.time_bonus_multiplier + } } /// Returns the platform-specific path to `settings.json`, or `None` if @@ -375,6 +427,7 @@ mod tests { selected_theme_id: "default".to_string(), shown_achievement_onboarding: false, tooltip_delay_secs: default_tooltip_delay(), + time_bonus_multiplier: default_time_bonus_multiplier(), }; save_settings_to(&path, &s).expect("save"); let loaded = load_settings_from(&path); @@ -689,4 +742,97 @@ mod tests { .sanitized(); assert_eq!(s2.tooltip_delay_secs, TOOLTIP_DELAY_MAX_SECS); } + + // ----------------------------------------------------------------------- + // time_bonus_multiplier — cosmetic win-modal time-bonus weight + // ----------------------------------------------------------------------- + + #[test] + fn settings_time_bonus_multiplier_default_is_one() { + let s = Settings::default(); + assert!( + (s.time_bonus_multiplier - 1.0).abs() < 1e-6, + "default time_bonus_multiplier must be 1.0 (no change to displayed bonus), got {}", + s.time_bonus_multiplier + ); + } + + #[test] + fn settings_time_bonus_multiplier_round_trip() { + let path = tmp_path("time_bonus_multiplier_round_trip"); + let _ = fs::remove_file(&path); + let s = Settings { + time_bonus_multiplier: 1.5, + ..Settings::default() + }; + save_settings_to(&path, &s).expect("save"); + let loaded = load_settings_from(&path); + assert!( + (loaded.time_bonus_multiplier - 1.5).abs() < 1e-6, + "time_bonus_multiplier must survive serde round-trip; got {}", + loaded.time_bonus_multiplier + ); + let _ = fs::remove_file(&path); + } + + #[test] + fn legacy_settings_without_time_bonus_multiplier_deserializes_to_one() { + // A settings.json written before this field existed must + // deserialize cleanly to the existing 1.0 baseline so old + // players see no change to their win-modal bonuses. + let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#; + let s: Settings = serde_json::from_slice(json).unwrap_or_default(); + assert!( + (s.time_bonus_multiplier - 1.0).abs() < 1e-6, + "legacy settings.json missing time_bonus_multiplier must deserialize to 1.0, got {}", + s.time_bonus_multiplier + ); + } + + #[test] + fn settings_time_bonus_multiplier_clamps_to_range() { + // Negative or oversized values from a hand-edited file must be + // clamped on load. + let s = Settings { + time_bonus_multiplier: -0.5, + ..Settings::default() + } + .sanitized(); + assert_eq!(s.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MIN); + + let s2 = Settings { + time_bonus_multiplier: 99.0, + ..Settings::default() + } + .sanitized(); + assert_eq!(s2.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MAX); + } + + #[test] + fn adjust_time_bonus_multiplier_clamps_and_rounds() { + let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() }; + // Step up to 1.1. + assert!((s.adjust_time_bonus_multiplier(0.1) - 1.1).abs() < 1e-6); + // Big positive jump clamps to TIME_BONUS_MULTIPLIER_MAX. + assert!( + (s.adjust_time_bonus_multiplier(99.0) - TIME_BONUS_MULTIPLIER_MAX).abs() < 1e-6 + ); + // Big negative jump clamps to TIME_BONUS_MULTIPLIER_MIN. + assert!( + (s.adjust_time_bonus_multiplier(-99.0) - TIME_BONUS_MULTIPLIER_MIN).abs() < 1e-6 + ); + assert_eq!(s.time_bonus_multiplier, 0.0); + + // Repeated incremental adds must not drift past the 0.1 grid. + let mut s2 = Settings { time_bonus_multiplier: 0.0, ..Default::default() }; + for _ in 0..10 { + s2.adjust_time_bonus_multiplier(0.1); + } + // After ten +0.1 steps, value should be exactly 1.0 (1 decimal). + assert!( + (s2.time_bonus_multiplier - 1.0).abs() < 1e-6, + "rounding should pin repeated 0.1 steps to the decimal grid, got {}", + s2.time_bonus_multiplier + ); + } } diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index 05be178..aa0db04 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -18,7 +18,7 @@ use bevy::window::{WindowMoved, WindowResized}; use solitaire_core::game_state::DrawMode; use solitaire_data::{ load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings, - WindowGeometry, TOOLTIP_DELAY_STEP_SECS, + WindowGeometry, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, }; use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent}; @@ -128,6 +128,10 @@ struct ColorBlindText; #[derive(Component, Debug)] struct TooltipDelayText; +/// Marks the `Text` node showing the live time-bonus-multiplier value. +#[derive(Component, Debug)] +struct TimeBonusMultiplierText; + /// Marks the scrollable inner card so the mouse-wheel system can target it. #[derive(Component, Debug)] struct SettingsPanelScrollable; @@ -166,6 +170,10 @@ enum SettingsButton { TooltipDelayDown, /// Increment the tooltip-hover dwell delay by one step. TooltipDelayUp, + /// Decrement the cosmetic time-bonus multiplier by one step. + TimeBonusDown, + /// Increment the cosmetic time-bonus multiplier by one step. + TimeBonusUp, ToggleTheme, ToggleColorBlind, SyncNow, @@ -198,6 +206,8 @@ impl SettingsButton { SettingsButton::CycleAnimSpeed => 40, SettingsButton::TooltipDelayDown => 45, SettingsButton::TooltipDelayUp => 46, + SettingsButton::TimeBonusDown => 47, + SettingsButton::TimeBonusUp => 48, // Cosmetic section SettingsButton::ToggleTheme => 50, SettingsButton::ToggleColorBlind => 60, @@ -288,6 +298,7 @@ impl Plugin for SettingsPlugin { update_anim_speed_text, update_color_blind_text, update_tooltip_delay_text, + update_time_bonus_multiplier_text, attach_focusable_to_settings_buttons, scroll_focus_into_view, ), @@ -553,6 +564,20 @@ fn update_tooltip_delay_text( } } +/// Refreshes the live time-bonus-multiplier value in the Gameplay +/// section whenever `SettingsResource` changes. +fn update_time_bonus_multiplier_text( + settings: Res, + mut text_nodes: Query<&mut Text, With>, +) { + if !settings.is_changed() { + return; + } + for mut text in &mut text_nodes { + **text = time_bonus_label(settings.0.time_bonus_multiplier); + } +} + fn card_back_label(idx: usize) -> String { if idx == 0 { "Default".to_string() @@ -694,6 +719,25 @@ fn handle_settings_buttons( changed.write(SettingsChangedEvent(settings.0.clone())); } } + SettingsButton::TimeBonusDown => { + let before = settings.0.time_bonus_multiplier; + let after = settings.0.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP); + if (before - after).abs() > f32::EPSILON { + persist(&path, &settings.0); + changed.write(SettingsChangedEvent(settings.0.clone())); + // The Text node is refreshed by + // `update_time_bonus_multiplier_text` on the next + // frame via `settings.is_changed()`. + } + } + SettingsButton::TimeBonusUp => { + let before = settings.0.time_bonus_multiplier; + let after = settings.0.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP); + if (before - after).abs() > f32::EPSILON { + persist(&path, &settings.0); + changed.write(SettingsChangedEvent(settings.0.clone())); + } + } SettingsButton::ToggleTheme => { settings.0.theme = match settings.0.theme { Theme::Green => Theme::Blue, @@ -779,6 +823,18 @@ fn tooltip_delay_label(secs: f32) -> String { } } +/// Formats the cosmetic time-bonus multiplier for display in the +/// Settings panel. `0.0` reads as `"Off"` so the player understands the +/// time-bonus row will be hidden; any other value prints as +/// `"{n:.1}×"` (e.g. `"1.0×"`, `"1.5×"`). +fn time_bonus_label(value: f32) -> String { + if value <= 0.0 { + "Off".into() + } else { + format!("{value:.1}×") + } +} + /// Auto-attaches [`Focusable`] to every bespoke Settings button — icon /// buttons (volume +/−, toggle, cycle), swatch buttons (card-back, /// background pickers), and the "Sync Now" button. The "Done" button is @@ -1116,6 +1172,11 @@ fn spawn_settings_panel( settings.tooltip_delay_secs, font_res, ); + time_bonus_multiplier_row( + body, + settings.time_bonus_multiplier, + font_res, + ); // --- Cosmetic --- section_label(body, "Cosmetic", font_res); @@ -1300,6 +1361,56 @@ fn tooltip_delay_row( }); } +/// `Time bonus 1.0× [−] [+]` — slider row for the cosmetic +/// `Settings::time_bonus_multiplier`. Mirrors [`tooltip_delay_row`] +/// (label, current value, decrement, increment) but formats the value +/// via [`time_bonus_label`] so `0.0` reads as `"Off"` and other values +/// as `"{n:.1}×"`. The multiplier is **cosmetic** — adjusting it +/// changes only the win-modal score breakdown, not the canonical +/// scores recorded in stats / achievements / leaderboards. +fn time_bonus_multiplier_row( + parent: &mut ChildSpawnerCommands, + value: f32, + font_res: Option<&FontResource>, +) { + let label_font = label_text_font(font_res); + let value_font = value_text_font(font_res); + parent + .spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_2, + ..default() + }) + .with_children(|row| { + row.spawn(( + Text::new("Time bonus".to_string()), + label_font, + TextColor(TEXT_SECONDARY), + )); + row.spawn(( + TimeBonusMultiplierText, + Text::new(time_bonus_label(value)), + value_font, + TextColor(TEXT_PRIMARY), + )); + icon_button( + row, + "−", + SettingsButton::TimeBonusDown, + "Shrink the time-bonus shown in the win modal. Cosmetic only.", + font_res, + ); + icon_button( + row, + "+", + SettingsButton::TimeBonusUp, + "Boost the time-bonus shown in the win modal. Cosmetic only.", + font_res, + ); + }); +} + /// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme, /// anim speed, colour-blind). /// diff --git a/solitaire_engine/src/win_summary_plugin.rs b/solitaire_engine/src/win_summary_plugin.rs index 5e071c5..c453d85 100644 --- a/solitaire_engine/src/win_summary_plugin.rs +++ b/solitaire_engine/src/win_summary_plugin.rs @@ -314,14 +314,40 @@ pub struct ScoreBreakdown { } impl ScoreBreakdown { - /// Builds a breakdown for the given win. + /// Builds a breakdown for the given win, applying the player's + /// **cosmetic** time-bonus multiplier (`Settings::time_bonus_multiplier`) + /// to the raw `compute_time_bonus` result before storing it on the + /// breakdown. /// /// `base` is the running game score (`pending.score`); `time_seconds`, - /// `undo_count`, and `mode` come from the captured `WinSummaryPending`. - /// All score arithmetic is saturating to keep the breakdown safe even - /// for pathologically high scores. - pub fn compute(base: i32, time_seconds: u64, undo_count: u32, mode: GameMode) -> Self { - let time_bonus = compute_time_bonus(time_seconds); + /// `undo_count`, and `mode` come from the captured `WinSummaryPending`; + /// `time_bonus_multiplier` comes from `SettingsResource`. All score + /// arithmetic is saturating to keep the breakdown safe even for + /// pathologically high scores. + /// + /// The multiplier is **purely visual** — it changes what the player + /// sees in the win modal but does **not** affect achievement + /// thresholds, leaderboard submissions, or `StatsSnapshot` totals, + /// which all use the raw, unmultiplied scoring values. + pub fn compute( + base: i32, + time_seconds: u64, + undo_count: u32, + mode: GameMode, + time_bonus_multiplier: f32, + ) -> Self { + let raw_bonus = compute_time_bonus(time_seconds); + // Apply the cosmetic multiplier and round back to an integer so + // the breakdown total stays a whole-number score. + let scaled = (raw_bonus as f32 * time_bonus_multiplier).round(); + // Clamp into i32 range defensively — `raw_bonus` is already + // bounded by `compute_time_bonus`, but a multiplier of 2.0 on + // an i32::MAX-adjacent bonus could still overflow the cast. + let time_bonus = if scaled.is_nan() { + 0 + } else { + scaled.clamp(i32::MIN as f32, i32::MAX as f32) as i32 + }; let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 }; let multiplier = match mode { GameMode::Zen => 0.0, @@ -554,7 +580,21 @@ fn spawn_win_summary_after_delay( let anim_speed = settings .as_ref() .map_or(AnimSpeed::Normal, |s| s.0.animation_speed); - spawn_overlay(&mut commands, &pending, &session, challenge_level, anim_speed); + // The cosmetic time-bonus multiplier is also pulled + // here — defaults to 1.0 (no change) when settings are + // absent (tests under MinimalPlugins without + // SettingsPlugin). + let time_bonus_multiplier = settings + .as_ref() + .map_or(1.0_f32, |s| s.0.time_bonus_multiplier); + spawn_overlay( + &mut commands, + &pending, + &session, + challenge_level, + anim_speed, + time_bonus_multiplier, + ); } } } @@ -634,18 +674,25 @@ fn apply_screen_shake( /// full opacity (no stagger, no fade); otherwise rows are spawned /// hidden and the [`reveal_score_breakdown`] system fades them in over /// roughly one second. +/// +/// `time_bonus_multiplier` is the player's cosmetic +/// `Settings::time_bonus_multiplier` and is folded into the time-bonus +/// row of the score breakdown only — it does **not** alter any stored +/// score or achievement-unlock evaluation. fn spawn_overlay( commands: &mut Commands, pending: &WinSummaryPending, session: &SessionAchievements, challenge_level: Option, anim_speed: AnimSpeed, + time_bonus_multiplier: f32, ) { let breakdown = ScoreBreakdown::compute( pending.score, pending.time_seconds, pending.undo_count, pending.mode, + time_bonus_multiplier, ); commands .spawn(( @@ -1392,7 +1439,7 @@ mod tests { /// the no-undo bonus fires because `undo_count == 0`. #[test] fn score_breakdown_compute_produces_expected_components() { - let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic); + let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0); assert_eq!(bd.base, 3200); assert_eq!(bd.time_bonus, 5833); // 700_000 / 120 assert_eq!(bd.no_undo_bonus, SCORE_NO_UNDO_BONUS); @@ -1408,7 +1455,7 @@ mod tests { /// of the other components. #[test] fn score_breakdown_zen_mode_zeros_total() { - let bd = ScoreBreakdown::compute(500, 60, 0, GameMode::Zen); + let bd = ScoreBreakdown::compute(500, 60, 0, GameMode::Zen, 1.0); assert!((bd.multiplier - 0.0).abs() < f32::EPSILON); assert!(bd.shows_multiplier_row(), "Zen ×0 must display the multiplier row"); assert_eq!(bd.total(), 0); @@ -1418,7 +1465,7 @@ mod tests { /// row is suppressed. #[test] fn score_breakdown_skips_no_undo_row_when_undo_was_used() { - let bd = ScoreBreakdown::compute(100, 60, 1, GameMode::Classic); + let bd = ScoreBreakdown::compute(100, 60, 1, GameMode::Classic, 1.0); assert_eq!(bd.no_undo_bonus, 0); assert!(!bd.shows_no_undo_row()); } @@ -1427,7 +1474,7 @@ mod tests { /// is suppressed. #[test] fn score_breakdown_skips_time_bonus_row_when_zero() { - let bd = ScoreBreakdown::compute(100, 0, 0, GameMode::Classic); + let bd = ScoreBreakdown::compute(100, 0, 0, GameMode::Classic, 1.0); assert_eq!(bd.time_bonus, 0); assert!(!bd.shows_time_bonus_row()); } @@ -1438,7 +1485,7 @@ mod tests { /// multiplier row, ×1.0 is suppressed). #[test] fn win_modal_score_breakdown_spawns_one_row_per_component() { - let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic); + let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0); assert_eq!( bd.row_count(), 5, @@ -1446,7 +1493,7 @@ mod tests { ); // Zen with both bonuses ALSO shows the multiplier row. - let zen = ScoreBreakdown::compute(3200, 120, 0, GameMode::Zen); + let zen = ScoreBreakdown::compute(3200, 120, 0, GameMode::Zen, 1.0); assert_eq!( zen.row_count(), 6, @@ -1457,8 +1504,8 @@ mod tests { /// When `no_undo_bonus == 0`, the row count drops by one. #[test] fn win_modal_score_breakdown_skips_zero_bonus_rows() { - let bd_with = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic); - let bd_without = ScoreBreakdown::compute(3200, 120, 1, GameMode::Classic); + let bd_with = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0); + let bd_without = ScoreBreakdown::compute(3200, 120, 1, GameMode::Classic, 1.0); assert_eq!( bd_with.row_count() - 1, bd_without.row_count(), @@ -1466,6 +1513,52 @@ mod tests { ); } + /// Cosmetic time-bonus multiplier from `Settings::time_bonus_multiplier` + /// scales the displayed `time_bonus` row by the factor, rounded to + /// the nearest integer. A `0.5` multiplier halves the canonical + /// `compute_time_bonus(120) = 5833` to `2917` (5833 × 0.5 = 2916.5, + /// round-half-to-even via `.round()` lands on 2917 in IEEE-754). + #[test] + fn score_breakdown_applies_time_bonus_multiplier() { + let raw = compute_time_bonus(120); + assert_eq!(raw, 5833, "sanity-check raw bonus before testing the multiplier"); + + let bd = ScoreBreakdown::compute(0, 120, 0, GameMode::Classic, 0.5); + let expected = ((raw as f32) * 0.5).round() as i32; + assert_eq!( + bd.time_bonus, expected, + "time_bonus row must reflect raw_bonus × multiplier (rounded)" + ); + // The row is still shown — value is 2917, not zero. + assert!(bd.shows_time_bonus_row()); + } + + /// At `multiplier == 0.0` ("Off"), the time-bonus row collapses to + /// zero and is suppressed by the renderer (same path as a zero + /// elapsed time). + #[test] + fn score_breakdown_off_multiplier_zeros_time_bonus() { + let bd = ScoreBreakdown::compute(100, 120, 0, GameMode::Classic, 0.0); + assert_eq!( + bd.time_bonus, 0, + "0.0 multiplier must zero out the displayed time bonus" + ); + assert!( + !bd.shows_time_bonus_row(), + "with time_bonus = 0 the row must be suppressed by the renderer" + ); + } + + /// A `2.0` multiplier doubles the displayed bonus — exercises the + /// upper end of the slider range. + #[test] + fn score_breakdown_double_multiplier_doubles_time_bonus() { + let raw = compute_time_bonus(120); + let bd = ScoreBreakdown::compute(0, 120, 0, GameMode::Classic, 2.0); + let expected = ((raw as f32) * 2.0).round() as i32; + assert_eq!(bd.time_bonus, expected); + } + /// Pure helper test: the reveal logic uses delta-time to count /// down `delay_secs`; at `t = 0` only the first row is "revealed", /// and after one stagger interval the second row reveals as well.