feat(settings): time-bonus multiplier slider in Settings → Gameplay

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) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-05 18:49:07 +00:00
parent 3984231c9b
commit 89c51ab712
4 changed files with 372 additions and 20 deletions
+3 -1
View File
@@ -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;
+149 -3
View File
@@ -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
);
}
}
+112 -1
View File
@@ -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<SettingsResource>,
mut text_nodes: Query<&mut Text, With<TimeBonusMultiplierText>>,
) {
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).
///
+108 -15
View File
@@ -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<u32>,
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.