diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index bd624c7..f5d8ee8 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -1,7 +1,6 @@ use crate::error::MoveError; use crate::klondike_adapter::{ DrawMode, KlondikeAdapter, SavedInstruction, - compute_time_bonus as scoring_time_bonus, foundation_from_slot as adapter_foundation_from_slot, skip_cards_from_count as adapter_skip_cards_from_count, tableau_from_index as adapter_tableau_from_index, @@ -1118,11 +1117,6 @@ impl GameState { }) } - /// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0). - pub fn compute_time_bonus(&self) -> i32 { - scoring_time_bonus(self.elapsed_seconds) - } - /// Read-only access to the underlying [`card_game::Session`] for this deal. /// /// Exposes `session.history()` (deterministic replay) and `session.solve()` diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index fc02b3d..d453ae1 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -124,8 +124,8 @@ pub use achievements::{ pub mod progress; pub use progress::{ - PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path, - save_progress_to, xp_for_win, + PlayerProgress, XpBreakdown, daily_seed_for, level_for_xp, load_progress_from, + progress_file_path, save_progress_to, xp_breakdown, xp_for_win, }; pub mod weekly; @@ -172,8 +172,11 @@ pub use replay::{ ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay, replay_history_path, save_replay_history_to, }; +// `latest_replay_path` is still consumed by the engine's one-shot legacy +// migration; `load_latest_replay_from`/`save_latest_replay_to` had no callers +// outside `replay.rs` and were dropped from the public surface. #[allow(deprecated)] -pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to}; +pub use replay::latest_replay_path; #[cfg(not(target_arch = "wasm32"))] pub mod matomo_client; diff --git a/solitaire_data/src/progress.rs b/solitaire_data/src/progress.rs index d1c60df..e082fd1 100644 --- a/solitaire_data/src/progress.rs +++ b/solitaire_data/src/progress.rs @@ -25,12 +25,34 @@ pub fn daily_seed_for(date: NaiveDate) -> u64 { y * 10_000 + m * 100 + d } -/// XP awarded for winning a game. +/// Component breakdown of the XP awarded for a win. +/// +/// This is the single source of truth for win-XP scoring: [`xp_for_win`] sums +/// it for the total, and UI that displays the individual lines (the win-summary +/// modal) reads the parts from here so the breakdown can never drift from the +/// total. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct XpBreakdown { + /// Flat base XP granted for any win. + pub base: u64, + /// Scaled fast-win bonus (10..=50 for sub-2-minute wins, else 0). + pub speed_bonus: u64, + /// Bonus for winning without using undo (25, else 0). + pub no_undo_bonus: u64, +} + +impl XpBreakdown { + /// Total XP awarded: `base + speed_bonus + no_undo_bonus`. + pub fn total(self) -> u64 { + self.base + self.speed_bonus + self.no_undo_bonus + } +} + +/// Component breakdown of the XP awarded for a win. /// /// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if /// the player did not use undo. -pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 { - let base: u64 = 50; +pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown { let speed_bonus: u64 = if time_seconds >= 120 { 0 } else { @@ -39,8 +61,16 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 { let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120); scaled.max(10) }; - let no_undo_bonus: u64 = if used_undo { 0 } else { 25 }; - base + speed_bonus + no_undo_bonus + XpBreakdown { + base: 50, + speed_bonus, + no_undo_bonus: if used_undo { 0 } else { 25 }, + } +} + +/// XP awarded for winning a game. See [`xp_breakdown`] for the components. +pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 { + xp_breakdown(time_seconds, used_undo).total() } /// Platform-specific default path for `progress.json`. diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index 452b398..3e38495 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -44,7 +44,8 @@ use std::hash::{Hash, Hasher}; use bevy::prelude::*; use bevy::window::RequestRedraw; use solitaire_core::card::Card; -use solitaire_core::{Foundation, KlondikePile}; +use solitaire_core::KlondikePile; +use solitaire_core::klondike_adapter::foundation_from_slot; use solitaire_data::AnimSpeed; use crate::animation_plugin::CardAnim; @@ -645,16 +646,6 @@ fn pile_cards( } } -fn foundation_from_slot(slot: u8) -> Option { - match slot { - 0 => Some(Foundation::Foundation1), - 1 => Some(Foundation::Foundation2), - 2 => Some(Foundation::Foundation3), - 3 => Some(Foundation::Foundation4), - _ => None, - } -} - // --------------------------------------------------------------------------- // Unit tests (pure functions only — no Bevy world required) // --------------------------------------------------------------------------- diff --git a/solitaire_engine/src/win_summary_plugin.rs b/solitaire_engine/src/win_summary_plugin.rs index d62ba08..ac3940a 100644 --- a/solitaire_engine/src/win_summary_plugin.rs +++ b/solitaire_engine/src/win_summary_plugin.rs @@ -90,28 +90,23 @@ pub struct WinSummaryPending { /// Builds a human-readable XP breakdown string for the win modal. /// -/// Mirrors the logic in `solitaire_data::xp_for_win` so the breakdown always -/// matches the total shown on the `XpAwardedEvent`. +/// Reads the components from `solitaire_data::xp_breakdown` — the single source +/// of truth shared with `xp_for_win` — so the breakdown can never drift from +/// the total shown on the `XpAwardedEvent`. /// /// Examples: /// - slow win, no undo → `"+50 base +25 no-undo"` /// - fast win, undo → `"+50 base +30 speed"` /// - fast win, no undo → `"+50 base +25 no-undo +30 speed"` fn build_xp_detail(time_seconds: u64, used_undo: bool) -> String { - let speed_bonus: u64 = if time_seconds >= 120 { - 0 - } else { - let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120); - scaled.max(10) - }; - let no_undo_bonus: u64 = if used_undo { 0 } else { 25 }; + let xp = solitaire_data::xp_breakdown(time_seconds, used_undo); - let mut parts = vec!["+50 base".to_string()]; - if no_undo_bonus > 0 { - parts.push("+25 no-undo".to_string()); + let mut parts = vec![format!("+{} base", xp.base)]; + if xp.no_undo_bonus > 0 { + parts.push(format!("+{} no-undo", xp.no_undo_bonus)); } - if speed_bonus > 0 { - parts.push(format!("+{speed_bonus} speed")); + if xp.speed_bonus > 0 { + parts.push(format!("+{} speed", xp.speed_bonus)); } parts.join(" ") }