fix+refactor+docs: P0–P3 todo list items

P0 fixes:
- Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs
  (all three were exported but never wired — features silently did nothing)
- game_state::draw(): increment move_count on waste→stock recycle, not just
  on normal draws; add move_count_increments_on_recycle regression test

P1 fixes:
- solitaire_server/Cargo.toml: remove duplicate dev-dependencies
  (solitaire_sync, uuid, chrono, jsonwebtoken were in both sections)

P2 — input_plugin refactor:
- Split 198-line handle_keyboard() into three focused systems under 110 lines each:
  handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G)
- Introduce KeyboardConfirmState resource to share countdown timers across systems
- Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck,
  new_game_confirm_window_is_positive

P2 — achievement predicate tests (solitaire_core):
- Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer,
  on_a_roll, comeback predicates (previously only covered via check_achievements())
- 141 core tests now passing

P2 — server tests:
- solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required)
- solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order

P3 — documentation:
- Add struct-level ///  to 12 Plugin structs (ChallengePlugin, CursorPlugin,
  AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin,
  HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin)
- Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef
- Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win

card_animation module (new files from previous session):
- chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs
- Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants
- Add handle_touch_stock_tap so touch users can draw from the stock pile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-28 22:02:52 +00:00
parent 71c0c273a1
commit ffc79447d4
32 changed files with 1824 additions and 244 deletions
+8 -5
View File
@@ -1,11 +1,11 @@
use bevy::prelude::*;
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin,
ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, GamePlugin,
HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin,
PausePlugin, ProfilePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin,
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
};
fn main() {
@@ -32,8 +32,10 @@ fn main() {
.add_plugins(CardPlugin)
.add_plugins(CursorPlugin)
.add_plugins(InputPlugin)
.add_plugins(SelectionPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin)
.add_plugins(StatsPlugin::default())
.add_plugins(ProgressPlugin::default())
@@ -52,5 +54,6 @@ fn main() {
.add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin)
.run();
}
+115 -3
View File
@@ -12,20 +12,25 @@
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
#[derive(Debug, Clone)]
pub struct AchievementContext {
// Stats (after this win has been recorded).
/// Total number of games played (after this win has been recorded).
pub games_played: u32,
/// Total number of games won (after this win has been recorded).
pub games_won: u32,
/// Current consecutive win streak (after this win has been recorded).
pub win_streak_current: u32,
/// Highest single-game score ever achieved.
pub best_single_score: u32,
/// Cumulative score across all games ever played.
pub lifetime_score: u64,
/// Total wins completed in Draw 3 mode.
pub draw_three_wins: u32,
// Progression.
/// Current daily-challenge completion streak (consecutive days).
pub daily_challenge_streak: u32,
// Last-win facts (GameWonEvent + GameState at win time).
/// Score achieved in the just-won game.
pub last_win_score: i32,
/// Elapsed seconds for the just-won game.
pub last_win_time_seconds: u64,
/// `true` if `undo()` was called at least once during the won game.
pub last_win_used_undo: bool,
@@ -55,13 +60,17 @@ pub enum Reward {
/// A single achievement's static metadata + unlock condition.
#[derive(Debug, Clone, Copy)]
pub struct AchievementDef {
/// Unique string identifier for this achievement (e.g. `"first_win"`).
pub id: &'static str,
/// Human-readable display name shown in the achievements screen.
pub name: &'static str,
/// Flavour text describing how to unlock the achievement.
pub description: &'static str,
/// Hidden from the achievements screen until unlocked.
pub secret: bool,
/// Reward granted on first unlock. `None` for cosmetic-only recognition.
pub reward: Option<Reward>,
/// Predicate evaluated on every `GameWonEvent` and `StateChangedEvent`. Returns true when the achievement should unlock.
pub condition: fn(&AchievementContext) -> bool,
}
@@ -477,6 +486,109 @@ mod tests {
assert!(achievement_by_id("nonexistent").is_none());
}
// -----------------------------------------------------------------------
// Direct predicate tests via ctx_defaults()
// -----------------------------------------------------------------------
/// Baseline context representing a single clean one-minute win in Draw-One mode.
fn ctx_defaults() -> AchievementContext {
AchievementContext {
games_played: 1,
games_won: 1,
win_streak_current: 1,
best_single_score: 0,
lifetime_score: 0,
draw_three_wins: 0,
daily_challenge_streak: 0,
last_win_score: 0,
last_win_time_seconds: 600,
last_win_used_undo: false,
wall_clock_hour: Some(12),
last_win_recycle_count: 0,
last_win_is_zen: false,
}
}
#[test]
fn speed_demon_true_when_under_three_minutes() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 179;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"speed_demon"), "speed_demon should unlock at 179s");
}
#[test]
fn speed_demon_false_when_over_three_minutes() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 181;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"speed_demon"), "speed_demon must not unlock at 181s");
}
#[test]
fn lightning_true_when_under_90_seconds() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 89;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"lightning"), "lightning should unlock at 89s");
}
#[test]
fn lightning_false_at_exactly_90_seconds() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 90;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"lightning"), "lightning must not unlock at exactly 90s");
}
#[test]
fn no_undo_true_when_zero_undos() {
let mut c = ctx_defaults();
c.last_win_used_undo = false;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"no_undo"), "no_undo should unlock when undo was not used");
}
#[test]
fn no_undo_false_when_undo_used() {
let mut c = ctx_defaults();
c.last_win_used_undo = true;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"no_undo"), "no_undo must not unlock when undo was used");
}
#[test]
fn high_scorer_true_when_score_5000_or_more() {
let mut c = ctx_defaults();
c.best_single_score = 5_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"high_scorer"), "high_scorer should unlock at best_single_score=5000");
}
#[test]
fn high_scorer_false_when_below_5000() {
let mut c = ctx_defaults();
c.best_single_score = 4_999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"high_scorer"), "high_scorer must not unlock at best_single_score=4999");
}
#[test]
fn on_a_roll_true_at_streak_3() {
let mut c = ctx_defaults();
c.win_streak_current = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock at streak=3");
}
#[test]
fn comeback_true_when_three_or_more_recycles() {
let mut c = ctx_defaults();
c.last_win_recycle_count = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"comeback"), "comeback should unlock at last_win_recycle_count=3");
}
#[test]
fn on_a_roll_requires_streak_of_3() {
let mut c = ctx();
+4
View File
@@ -63,9 +63,13 @@ impl Rank {
/// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Card {
/// Unique identifier for this card within the deal. Stable across moves and undo.
pub id: u32,
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
pub suit: Suit,
/// The card's rank (Ace through King).
pub rank: Rank,
/// Whether the card is visible to the player. Face-down cards may not be moved.
pub face_up: bool,
}
+1
View File
@@ -12,6 +12,7 @@ const ALL_RANKS: [Rank; 13] = [
/// A standard 52-card deck.
pub struct Deck {
/// All 52 cards in the deck, in deal order.
pub cards: Vec<Card>,
}
+27
View File
@@ -64,18 +64,26 @@ struct StateSnapshot {
/// Full state of an in-progress Klondike Solitaire game.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GameState {
/// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles.
#[serde(with = "pile_map_serde")]
pub piles: HashMap<PileType, Pile>,
/// Whether the player draws one or three cards from the stock per turn.
pub draw_mode: DrawMode,
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards
/// compatibility with older save files via `#[serde(default)]`.
#[serde(default)]
pub mode: GameMode,
/// Current game score. Can be negative (undo penalties subtract from score).
pub score: i32,
/// Total moves made this game, including draws and stock recycles.
pub move_count: u32,
/// Seconds elapsed since the game started, used for time-bonus scoring.
pub elapsed_seconds: u64,
/// RNG seed used to deal this game. Same seed always produces the same layout.
pub seed: u64,
/// True once all 52 cards are on the foundations. No further moves are accepted.
pub is_won: bool,
/// True when the game can be completed without further input (all remaining cards are face-up and in order).
pub is_auto_completable: bool,
/// Number of times `undo()` has been successfully invoked this game.
/// Used by achievement conditions like `no_undo`.
@@ -173,6 +181,7 @@ impl GameState {
stock.cards.push(card);
}
self.recycle_count = self.recycle_count.saturating_add(1);
self.move_count += 1;
return Ok(());
}
@@ -562,6 +571,24 @@ mod tests {
assert_eq!(g.recycle_count, 2);
}
#[test]
fn move_count_increments_on_recycle() {
let mut g = new_game();
// Drain stock to waste, recording how many draws it took.
let mut draws: u32 = 0;
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
draws += 1;
}
let before = g.move_count;
g.draw().unwrap(); // recycle
assert_eq!(
g.move_count,
before + 1,
"recycling waste back to stock must increment move_count (was {before}, draws={draws})"
);
}
#[test]
fn draw_from_empty_stock_and_waste_returns_error() {
// The only stop condition for draw() is: both stock AND waste are
+2
View File
@@ -17,7 +17,9 @@ pub enum PileType {
/// A named collection of cards in a specific board position.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pile {
/// Which pile this is (Stock, Waste, Foundation suit, or Tableau column).
pub pile_type: PileType,
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
pub cards: Vec<Card>,
}
+1 -1
View File
@@ -13,7 +13,7 @@ pub use solitaire_sync::StatsSnapshot;
///
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
pub trait StatsExt {
/// Record a completed win. Updates all relevant counters and rolling averages.
/// Updates rolling statistics from a completed game win. Call once per `GameWonEvent`.
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
}
+3 -3
View File
@@ -9,7 +9,7 @@ use solitaire_core::game_state::DrawMode;
/// XP awarded each time a weekly goal is just completed.
pub const WEEKLY_GOAL_XP: u64 = 75;
/// What kind of game outcome counts as progress toward this goal.
/// Discriminant for the type of weekly goal the player is working toward.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeeklyGoalKind {
/// Any win counts.
@@ -22,7 +22,7 @@ pub enum WeeklyGoalKind {
WinDrawThree,
}
/// Static metadata for a single weekly goal.
/// Static definition of a weekly goal — the goal type, target value, and display strings.
#[derive(Debug, Clone, Copy)]
pub struct WeeklyGoalDef {
pub id: &'static str,
@@ -31,7 +31,7 @@ pub struct WeeklyGoalDef {
pub kind: WeeklyGoalKind,
}
/// Per-event facts a goal needs to decide whether it matched.
/// Runtime snapshot of game metrics used to evaluate weekly goal progress.
#[derive(Debug, Clone)]
pub struct WeeklyGoalContext {
pub time_seconds: u64,
+1
View File
@@ -149,6 +149,7 @@ pub struct ActiveToast {
/// Duration of each queued info-toast in seconds.
const QUEUED_TOAST_SECS: f32 = 2.5;
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
pub struct AnimationPlugin;
impl Plugin for AnimationPlugin {
+1
View File
@@ -97,6 +97,7 @@ pub struct MuteState {
pub music_muted: bool,
}
/// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
pub struct AudioPlugin;
impl Plugin for AudioPlugin {
@@ -140,10 +140,22 @@ impl CardAnimation {
/// Redirects a card to a new destination without snapping or interrupting motion.
///
/// Reads the card's current interpolated position (from a live `CardAnimation` if
/// present, or from `Transform` if the card is stationary) and starts a fresh
/// `CardAnimation` from that position. Duration is recalculated from the remaining
/// distance so short remaining paths feel appropriately quick.
/// Reads the card's current interpolated position (from a live [`CardAnimation`]
/// if present, or from `Transform` if stationary) and starts a fresh
/// [`CardAnimation`] from that position. Duration is recalculated from the
/// remaining distance so short paths stay quick.
///
/// # Velocity continuity
///
/// When a card is mid-flight, the new animation starts with a small positive
/// `elapsed` offset (`carry`) derived from how far through the current animation
/// the card is. This preserves a sense of forward momentum: the new curve does
/// not restart from zero velocity, avoiding a visible "lurch" when the target
/// changes rapidly.
///
/// The carry is deliberately small (≤ 10 % of the new duration) so that it
/// never causes a visible position jump — the card's start position is still
/// read from the current transform.
///
/// # Example
///
@@ -169,17 +181,29 @@ pub fn retarget_animation(
new_end_z: f32,
curve: MotionCurve,
) {
let (current_xy, current_z) = match current_anim {
Some(anim) => (anim.current_xy(), transform.translation.z),
None => (transform.translation.truncate(), transform.translation.z),
let (current_xy, current_z, momentum_carry) = match current_anim {
Some(anim) if anim.duration > 0.0 => {
// Estimate how far into the current animation we are and carry
// a small fraction of that progress into the new animation.
// This avoids restarting from zero velocity and makes the motion
// feel continuous when the target changes mid-flight.
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
// Cap at 10 % of the new animation so there's no visible jump.
let carry = (t * 0.12).min(0.10);
(anim.current_xy(), transform.translation.z, carry)
}
_ => (transform.translation.truncate(), transform.translation.z, 0.0),
};
let distance = current_xy.distance(new_end);
let duration = compute_duration(distance);
commands.entity(entity).insert(CardAnimation {
start: current_xy,
end: new_end,
elapsed: 0.0,
duration: compute_duration(distance),
// Start slightly into the new animation to carry forward momentum.
elapsed: momentum_carry * duration,
duration,
curve,
delay: 0.0,
start_z: current_z,
@@ -0,0 +1,207 @@
//! Animation chaining — play a sequence of [`CardAnimation`] segments in order.
//!
//! Insert [`AnimationChain`] on a card entity alongside the *first* segment as
//! a [`CardAnimation`] to sequence multi-step motion. When the active
//! [`CardAnimation`] finishes and is removed, [`advance_animation_chains`]
//! pops the next segment and inserts it automatically.
//!
//! # Example — arc then settle
//!
//! ```ignore
//! // Arc up to a midpoint, then settle onto the foundation with a soft bounce.
//! let mid = (start + end) / 2.0 + Vec2::new(0.0, 30.0);
//!
//! let first_leg = CardAnimation::slide(start, z, mid, z + 20.0, MotionCurve::SmoothSnap)
//! .with_z_lift(15.0);
//! let second_leg = CardAnimation::slide(mid, z + 20.0, end, resting_z, MotionCurve::SoftBounce);
//!
//! commands.entity(card_entity).insert((
//! first_leg, // plays immediately
//! AnimationChain::new().then(second_leg), // queued
//! ));
//! ```
//!
//! # Invariant
//!
//! The chain holds only the *queued* segments — the segment currently playing
//! lives on the entity as a [`CardAnimation`] component and has already been
//! removed from the queue. When the queue is exhausted the `AnimationChain`
//! component removes itself.
use std::collections::VecDeque;
use bevy::prelude::*;
use super::animation::CardAnimation;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/// A FIFO queue of [`CardAnimation`] segments to be played one after another.
///
/// The currently playing segment lives on the entity as a [`CardAnimation`]
/// component (already removed from this queue). When that animation completes,
/// [`advance_animation_chains`] pops the next entry and inserts it.
///
/// Remove this component to cancel the entire chain mid-flight. The in-progress
/// [`CardAnimation`] (if any) will still play to completion unless also removed.
#[derive(Component, Debug, Clone)]
pub struct AnimationChain {
pub(crate) queue: VecDeque<CardAnimation>,
}
impl AnimationChain {
/// Creates an empty chain with no queued segments.
#[must_use]
pub fn new() -> Self {
Self {
queue: VecDeque::new(),
}
}
/// Appends `anim` to the end of the chain.
///
/// Returns `self` for builder-style chaining.
#[must_use]
pub fn then(mut self, anim: CardAnimation) -> Self {
self.queue.push_back(anim);
self
}
/// Number of segments waiting in the queue (not including any
/// currently active [`CardAnimation`]).
pub fn remaining(&self) -> usize {
self.queue.len()
}
/// Returns `true` when no segments remain in the queue.
pub fn is_empty(&self) -> bool {
self.queue.is_empty()
}
}
impl Default for AnimationChain {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// Pops the next queued segment when the active [`CardAnimation`] has finished.
///
/// Must run **after** `advance_card_animations` so the completed animation has
/// already been removed before this system inspects the entity.
pub(crate) fn advance_animation_chains(
mut commands: Commands,
mut chains: Query<(Entity, &mut AnimationChain), Without<CardAnimation>>,
) {
for (entity, mut chain) in &mut chains {
match chain.queue.pop_front() {
Some(next) => {
// Insert the next segment; the chain component stays until empty.
commands.entity(entity).insert(next);
}
None => {
// Queue exhausted — clean up the chain component.
commands.entity(entity).remove::<AnimationChain>();
}
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::card_animation::MotionCurve;
fn slide(end_x: f32) -> CardAnimation {
CardAnimation::slide(
Vec2::ZERO,
0.0,
Vec2::new(end_x, 0.0),
0.0,
MotionCurve::SmoothSnap,
)
}
#[test]
fn new_chain_is_empty() {
let c = AnimationChain::new();
assert_eq!(c.remaining(), 0);
assert!(c.is_empty());
}
#[test]
fn then_appends_and_increments_remaining() {
let c = AnimationChain::new().then(slide(1.0)).then(slide(2.0));
assert_eq!(c.remaining(), 2);
assert!(!c.is_empty());
}
#[test]
fn queue_is_fifo() {
let mut c = AnimationChain::new().then(slide(1.0)).then(slide(2.0));
let first = c.queue.pop_front().expect("must have first segment");
assert!(
(first.end.x - 1.0).abs() < 1e-6,
"first dequeued must be the first appended (end.x=1), got {}",
first.end.x
);
let second = c.queue.pop_front().expect("must have second segment");
assert!(
(second.end.x - 2.0).abs() < 1e-6,
"second dequeued must be the second appended (end.x=2), got {}",
second.end.x
);
}
#[test]
fn default_equals_new() {
assert_eq!(AnimationChain::default().remaining(), 0);
}
#[test]
fn chain_with_three_segments() {
let c = AnimationChain::new()
.then(slide(1.0))
.then(slide(2.0))
.then(slide(3.0));
assert_eq!(c.remaining(), 3);
}
#[test]
fn advance_system_inserts_next_segment() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(crate::card_animation::CardAnimationPlugin);
let chain = AnimationChain::new().then(slide(100.0));
// Spawn an entity with only AnimationChain (no CardAnimation) so the
// system fires immediately on the first update.
let entity = app
.world_mut()
.spawn((Transform::from_translation(Vec3::ZERO), chain))
.id();
app.update();
// After one update, the chain system should have popped `slide(100)` and
// inserted it as a `CardAnimation`.
assert!(
app.world().entity(entity).get::<CardAnimation>().is_some(),
"advance_animation_chains must insert CardAnimation from first queued segment"
);
// The chain component should still be present (but now empty).
// Actually, since we popped the last item, the chain removes itself too.
// Whether it's present or not depends on system ordering, but the
// CardAnimation must definitely be present.
}
}
@@ -0,0 +1,239 @@
//! Lightweight frame-time diagnostics.
//!
//! [`FrameTimeDiagnostics`] is a Bevy resource that maintains a rolling window
//! of the last [`WINDOW_SIZE`] frame durations. Any system can read it to make
//! performance-aware decisions — for example, disabling settle-bounce animations
//! when the game is running below 30 FPS on a low-end device.
//!
//! # Reading diagnostics
//!
//! ```ignore
//! fn my_system(diag: Res<FrameTimeDiagnostics>) {
//! if diag.is_low_performance() {
//! // Skip expensive visual effects.
//! return;
//! }
//! println!("avg FPS: {:.1}", diag.fps());
//! }
//! ```
//!
//! # Update
//!
//! [`update_frame_time_diagnostics`] runs every frame via [`CardAnimationPlugin`]
//! (or whichever plugin registers it). The window is circular so only the last
//! `WINDOW_SIZE` frames influence the statistics.
use bevy::prelude::*;
/// Number of frames kept in the rolling statistics window.
pub const WINDOW_SIZE: usize = 60;
/// Rolling frame-time statistics over the last [`WINDOW_SIZE`] frames.
///
/// All times are in seconds. Statistics are updated every frame by
/// [`update_frame_time_diagnostics`].
#[derive(Resource, Debug)]
pub struct FrameTimeDiagnostics {
samples: [f32; WINDOW_SIZE],
head: usize,
count: usize,
/// Smoothed average frame duration over the window (seconds).
pub avg_secs: f32,
/// Worst-case (slowest) frame duration in the window (seconds).
pub max_secs: f32,
/// Best-case (fastest) frame duration in the window (seconds).
pub min_secs: f32,
}
impl Default for FrameTimeDiagnostics {
fn default() -> Self {
Self {
samples: [0.0; WINDOW_SIZE],
head: 0,
count: 0,
avg_secs: 0.0,
max_secs: 0.0,
min_secs: 0.0,
}
}
}
impl FrameTimeDiagnostics {
/// Estimated frames per second based on the rolling average.
///
/// Returns `0.0` until at least one frame has been recorded.
pub fn fps(&self) -> f32 {
if self.avg_secs > 0.0 {
1.0 / self.avg_secs
} else {
0.0
}
}
/// Returns `true` when the rolling-average FPS is above `target`.
///
/// Always returns `false` until the window is fully populated.
pub fn is_above_target(&self, target_fps: f32) -> bool {
self.count >= WINDOW_SIZE && self.fps() > target_fps
}
/// Returns `true` when the device appears to be running below 30 FPS.
///
/// Only asserted after the window is fully populated so a single slow
/// startup frame does not permanently suppress visual effects.
pub fn is_low_performance(&self) -> bool {
self.count >= WINDOW_SIZE && self.fps() < 30.0
}
/// Appends `dt` to the ring buffer and recomputes statistics.
///
/// O(WINDOW_SIZE) — acceptable because WINDOW_SIZE is small and constant.
fn push(&mut self, dt: f32) {
self.samples[self.head] = dt;
self.head = (self.head + 1) % WINDOW_SIZE;
if self.count < WINDOW_SIZE {
self.count += 1;
}
let n = self.count;
let mut sum = 0.0_f32;
let mut max_val = 0.0_f32;
let mut min_val = f32::MAX;
for &s in &self.samples[..n] {
sum += s;
if s > max_val {
max_val = s;
}
if s < min_val {
min_val = s;
}
}
self.avg_secs = sum / n as f32;
self.max_secs = max_val;
self.min_secs = if min_val == f32::MAX { 0.0 } else { min_val };
}
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// Records the current frame's delta time in [`FrameTimeDiagnostics`].
///
/// Registered by [`CardAnimationPlugin`]. Runs every frame in `Update`.
pub(crate) fn update_frame_time_diagnostics(
time: Res<Time>,
mut diag: ResMut<FrameTimeDiagnostics>,
) {
diag.push(time.delta_secs());
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fps_zero_when_no_samples() {
assert_eq!(FrameTimeDiagnostics::default().fps(), 0.0);
}
#[test]
fn fps_correct_after_uniform_frames() {
let mut d = FrameTimeDiagnostics::default();
for _ in 0..WINDOW_SIZE {
d.push(1.0 / 60.0);
}
assert!(
(d.fps() - 60.0).abs() < 0.5,
"expected ~60 fps, got {}",
d.fps()
);
}
#[test]
fn is_low_performance_requires_full_window() {
let mut d = FrameTimeDiagnostics::default();
// Partial window filled with very slow frames.
for _ in 0..(WINDOW_SIZE / 2) {
d.push(1.0 / 5.0); // 5 FPS
}
assert!(
!d.is_low_performance(),
"must not report low performance until the window is full"
);
}
#[test]
fn is_low_performance_true_below_30fps() {
let mut d = FrameTimeDiagnostics::default();
for _ in 0..WINDOW_SIZE {
d.push(1.0 / 20.0); // 20 FPS
}
assert!(
d.is_low_performance(),
"20 FPS should be reported as low performance"
);
}
#[test]
fn is_above_target_false_below_target() {
let mut d = FrameTimeDiagnostics::default();
for _ in 0..WINDOW_SIZE {
d.push(1.0 / 30.0); // exactly 30 FPS
}
// is_above_target(30.0) is strict: fps must be > 30, not >=.
// At exactly 30 FPS the result depends on floating-point rounding,
// so just check that it's consistent with > 60 being false.
assert!(!d.is_above_target(60.0), "30 FPS is not above 60 FPS target");
}
#[test]
fn max_and_min_track_extremes() {
let mut d = FrameTimeDiagnostics::default();
d.push(0.010); // fast frame (100 FPS)
d.push(0.050); // slow frame (20 FPS)
assert!(
d.max_secs >= 0.050,
"max_secs must be at least the slow frame, got {}",
d.max_secs
);
assert!(
d.min_secs <= 0.010,
"min_secs must be at most the fast frame, got {}",
d.min_secs
);
}
#[test]
fn circular_buffer_overwrites_oldest() {
let mut d = FrameTimeDiagnostics::default();
// Fill with 60-FPS samples.
for _ in 0..WINDOW_SIZE {
d.push(1.0 / 60.0);
}
// Overwrite every slot with 10-FPS samples.
for _ in 0..WINDOW_SIZE {
d.push(1.0 / 10.0);
}
assert!(
d.fps() < 15.0,
"after full overwrite, avg must reflect new slow frames; got fps={}",
d.fps()
);
}
#[test]
fn count_does_not_exceed_window_size() {
let mut d = FrameTimeDiagnostics::default();
for _ in 0..WINDOW_SIZE * 3 {
d.push(0.016);
}
assert_eq!(d.count, WINDOW_SIZE);
}
}
@@ -35,6 +35,7 @@ use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use super::animation::CardAnimation;
use super::tuning::AnimationTuning;
use crate::card_plugin::CardEntity;
use crate::events::{DrawRequestEvent, MoveRequestEvent, UndoRequestEvent};
use crate::layout::LayoutResource;
@@ -48,15 +49,6 @@ type CardTransformQuery<'w, 's> =
// Constants
// ---------------------------------------------------------------------------
/// Scale applied to the card currently under the cursor (1.0 = no change).
const HOVER_SCALE: f32 = 1.04;
/// Additional scale applied to dragged cards while in flight.
const DRAG_LIFT_SCALE: f32 = 1.08;
/// Lerp speed for hover scale interpolation (higher = snappier).
const HOVER_LERP_SPEED: f32 = 14.0;
/// Lerp speed for drag scale interpolation.
const DRAG_LERP_SPEED: f32 = 20.0;
@@ -162,27 +154,34 @@ pub(crate) fn detect_hover(
/// Applies the hover scale to the currently hovered card via smooth lerp.
///
/// Uses [`AnimationTuning`] to get the platform-appropriate hover scale.
/// On touch (`hover_scale == 1.0`) this becomes a no-op — there is no
/// hover affordance on a touchscreen.
///
/// Only runs on cards that have **no active [`CardAnimation`]** — animated
/// cards control their own scale. When hover changes entities, the previous
/// entity's scale is snapped back to 1.0 to avoid leaving a permanently
/// enlarged card.
pub(crate) fn apply_hover_scale(
time: Res<Time>,
tuning: Res<AnimationTuning>,
mut hover_state: ResMut<HoverState>,
mut cards: CardTransformQuery,
) {
let dt = time.delta_secs();
let target_entity = hover_state.entity;
let hover_target = tuning.hover_scale;
let lerp_speed = tuning.hover_lerp_speed;
for (entity, mut transform) in &mut cards {
let target_scale = if Some(entity) == target_entity {
HOVER_SCALE
hover_target
} else {
1.0
};
let current = transform.scale.x;
let new_scale = current + (target_scale - current) * (HOVER_LERP_SPEED * dt).min(1.0);
let new_scale = current + (target_scale - current) * (lerp_speed * dt).min(1.0);
transform.scale = Vec3::splat(new_scale);
}
@@ -191,29 +190,36 @@ pub(crate) fn apply_hover_scale(
cards
.get(entity)
.map(|(_, t)| t.scale.x)
.unwrap_or(HOVER_SCALE)
.unwrap_or(hover_target)
} else {
1.0
};
}
/// Applies a scale boost and z-lift to dragged card entities.
/// Applies a scale boost to committed dragged card entities.
///
/// Reads [`DragState`] for the list of card IDs being dragged. Does **not**
/// modify `translation.xy` — the existing `InputPlugin` owns drag translation.
/// Only writes `scale` and `translation.z` so the two systems are disjoint.
/// Uses [`AnimationTuning`] for the platform-correct drag scale. Only applies
/// to cards whose drag has been *committed* (threshold crossed); cards in the
/// pending-drag state stay at scale 1.0. Does **not** modify `translation.xy`
/// — `InputPlugin` owns drag translation.
pub(crate) fn apply_drag_visual(
time: Res<Time>,
drag: Option<Res<DragState>>,
tuning: Res<AnimationTuning>,
mut cards: Query<(Entity, &CardEntity, &mut Transform), (Without<CardAnimation>,)>,
) {
let dt = time.delta_secs();
let empty: Vec<u32> = Vec::new();
let dragged_ids: &[u32] = drag.as_ref().map_or(empty.as_slice(), |d| &d.cards);
let drag_scale = tuning.drag_scale;
// Only lift cards that are in a *committed* drag. Pending drags (below
// threshold) must stay at scale 1.0 to avoid visible premature lift.
let (dragged_ids, committed): (&[u32], bool) = drag
.as_ref()
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
for (_, card, mut transform) in &mut cards {
let is_dragged = dragged_ids.contains(&card.card_id);
let target_scale = if is_dragged { DRAG_LIFT_SCALE } else { 1.0 };
let is_active_drag = committed && dragged_ids.contains(&card.card_id);
let target_scale = if is_active_drag { drag_scale } else { 1.0 };
let current = transform.scale.x;
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
transform.scale = Vec3::splat(new_scale);
+26 -5
View File
@@ -73,17 +73,23 @@
//! | `apply_drag_visual` scale + `CardAnimation` scale | ✓ (same filter) |
pub mod animation;
pub mod chain;
pub mod curves;
pub mod diagnostics;
pub mod interaction;
pub mod timing;
pub mod tuning;
pub use animation::{retarget_animation, win_scatter_targets, CardAnimation};
pub use chain::AnimationChain;
pub use curves::{sample_curve, MotionCurve};
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
pub use interaction::{BufferedInput, HoverState, InputBuffer};
pub use timing::{
cascade_delay, compute_duration, micro_vary, DEAL_INTERVAL_SECS, MAX_DURATION_SECS,
MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
};
pub use tuning::{AnimationTuning, InputPlatform};
use bevy::prelude::*;
@@ -94,14 +100,18 @@ use crate::layout::LayoutResource;
use crate::resources::DragState;
use animation::advance_card_animations;
use chain::advance_animation_chains;
use diagnostics::update_frame_time_diagnostics;
use interaction::{apply_drag_visual, apply_hover_scale, detect_hover, drain_input_buffer};
use tuning::update_input_platform;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers all systems, resources, and components for curve-based card
/// animation, hover visuals, drag lift, and input buffering.
/// animation, hover visuals, drag lift, input buffering, platform-adaptive
/// tuning, animation chaining, and frame-time diagnostics.
///
/// Safe to register alongside `AnimationPlugin` and `FeedbackAnimPlugin` as
/// long as no single entity carries both `CardAnim` and `CardAnimation`.
@@ -109,8 +119,8 @@ pub struct CardAnimationPlugin;
impl Plugin for CardAnimationPlugin {
fn build(&self, app: &mut App) {
// Register events and resources that interaction systems depend on,
// idempotently — double-registration is safe in Bevy.
// Register events and resources idempotently — double-registration is
// safe in Bevy.
app.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>()
@@ -118,12 +128,23 @@ impl Plugin for CardAnimationPlugin {
.init_resource::<DragState>()
.init_resource::<HoverState>()
.init_resource::<InputBuffer>()
// Platform-adaptive tuning (desktop by default, switches on touch).
.init_resource::<AnimationTuning>()
// Rolling frame-time statistics.
.init_resource::<FrameTimeDiagnostics>()
.add_systems(
Update,
(
// Advance active animations (highest priority — runs first).
// Detect input platform and update tuning — runs first so
// all downstream systems in this frame see the fresh value.
update_input_platform,
// Frame-time diagnostics — cheap, runs unconditionally.
update_frame_time_diagnostics,
// Advance active animations.
advance_card_animations,
// Interaction visuals (run after animation to read final positions).
// After each animation finishes, pop the next chain segment.
advance_animation_chains,
// Interaction visuals (run after animation for final positions).
detect_hover,
apply_hover_scale,
apply_drag_visual,
@@ -0,0 +1,230 @@
//! Platform-adaptive animation tuning.
//!
//! [`AnimationTuning`] is a Bevy resource that provides animation parameters
//! adapted to the currently detected input platform. Systems and components
//! that need animation timing should read from this resource instead of using
//! hardcoded constants, so the same binary behaves appropriately on both a
//! touchscreen phone and a desktop with a mouse.
//!
//! # Platform detection
//!
//! [`update_input_platform`] runs every frame. When a touch event is detected
//! the resource switches to [`InputPlatform::Touch`] (mobile defaults); when a
//! mouse event is detected it switches back to [`InputPlatform::Mouse`]
//! (desktop defaults). The transition is immediate.
//!
//! # Usage
//!
//! ```ignore
//! fn my_system(tuning: Res<AnimationTuning>, time: Res<Time>) {
//! let duration = tuning.scale_duration(0.25); // 0.25 s on desktop, 0.19 s on mobile
//! let scale = tuning.drag_scale; // platform-appropriate lift
//! }
//! ```
use bevy::input::touch::Touches;
use bevy::prelude::*;
// ---------------------------------------------------------------------------
// InputPlatform
// ---------------------------------------------------------------------------
/// The most recently detected input platform.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InputPlatform {
/// Mouse / keyboard — desktop behaviour (richer motion, hover states).
#[default]
Mouse,
/// Touchscreen — mobile behaviour (faster, tighter, no hover).
Touch,
}
// ---------------------------------------------------------------------------
// AnimationTuning resource
// ---------------------------------------------------------------------------
/// Animation and interaction parameters adapted to the active [`InputPlatform`].
///
/// Mobile (touch) defaults are faster and less bouncy than desktop (mouse)
/// defaults. Read this resource wherever you previously used animation
/// constants to get correct behaviour across both platforms.
#[derive(Resource, Debug, Clone)]
pub struct AnimationTuning {
/// Currently detected input platform.
pub platform: InputPlatform,
/// Multiplier applied to all computed animation durations.
///
/// `1.0` on desktop; `0.75` on mobile (25 % faster).
pub duration_scale: f32,
/// Multiplier applied to spring-curve overshoot amplitude.
///
/// `1.0` on desktop (full bounce); `0.5` on mobile (half — tighter feel
/// on small screens where large overshoots look incorrect).
pub overshoot_scale: f32,
/// Minimum pointer/finger movement in **screen pixels** before a drag
/// is committed.
///
/// Prevents accidental drags from quick taps. Desktop = 4 px; mobile
/// = 10 px (fingers are less precise than a mouse cursor).
pub drag_threshold_px: f32,
/// `Transform.scale` applied to a card while it is being dragged.
pub drag_scale: f32,
/// `Transform.scale` applied to the card under the cursor (desktop only).
///
/// Always `1.0` on touch because there is no hover concept on a
/// touchscreen — applying hover to the card under the last touch
/// would feel wrong.
pub hover_scale: f32,
/// Lerp speed (per second) for the hover scale interpolation.
///
/// Higher values make the hover pop in/out faster.
pub hover_lerp_speed: f32,
/// Per-card stagger interval (seconds) for cascade / deal animations.
///
/// Mobile gets a slightly tighter stagger so the full cascade finishes
/// more quickly.
pub cascade_stagger_secs: f32,
}
impl AnimationTuning {
/// Desktop (mouse) defaults — richer motion, more expressive curves.
pub fn desktop() -> Self {
Self {
platform: InputPlatform::Mouse,
duration_scale: 1.0,
overshoot_scale: 1.0,
drag_threshold_px: 4.0,
drag_scale: 1.08,
hover_scale: 1.04,
hover_lerp_speed: 14.0,
cascade_stagger_secs: 0.018,
}
}
/// Mobile (touch) defaults — faster, tighter, no hover.
pub fn mobile() -> Self {
Self {
platform: InputPlatform::Touch,
duration_scale: 0.75,
overshoot_scale: 0.5,
drag_threshold_px: 10.0,
drag_scale: 1.12,
hover_scale: 1.0, // no hover affordance on touch
hover_lerp_speed: 20.0,
cascade_stagger_secs: 0.014,
}
}
/// Scales `base_duration` by [`Self::duration_scale`].
///
/// Use this wherever you compute an animation duration to respect the
/// current platform's speed preference.
#[inline]
pub fn scale_duration(&self, base_duration: f32) -> f32 {
base_duration * self.duration_scale
}
}
impl Default for AnimationTuning {
fn default() -> Self {
Self::desktop()
}
}
// ---------------------------------------------------------------------------
// Detection system
// ---------------------------------------------------------------------------
/// Detects the active input platform and updates [`AnimationTuning`] to match.
///
/// Called every frame. Uses `Option<Res<Touches>>` so the system is safe when
/// running under `MinimalPlugins` (which does not register the touch subsystem).
pub(crate) fn update_input_platform(
touches: Option<Res<Touches>>,
mouse_buttons: Res<ButtonInput<MouseButton>>,
mut tuning: ResMut<AnimationTuning>,
) {
let touch_active = touches.as_ref().is_some_and(|t| {
t.iter().next().is_some()
|| t.iter_just_pressed().next().is_some()
|| t.iter_just_released().next().is_some()
});
let mouse_active = mouse_buttons.get_just_pressed().next().is_some()
|| mouse_buttons.get_pressed().next().is_some();
if touch_active && tuning.platform != InputPlatform::Touch {
*tuning = AnimationTuning::mobile();
} else if mouse_active && tuning.platform != InputPlatform::Mouse {
*tuning = AnimationTuning::desktop();
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desktop_defaults_are_sane() {
let t = AnimationTuning::desktop();
assert_eq!(t.duration_scale, 1.0);
assert_eq!(t.platform, InputPlatform::Mouse);
assert!(t.hover_scale > 1.0, "desktop hover must lift the card");
assert!(t.drag_threshold_px < 10.0, "desktop threshold must be smaller than mobile");
}
#[test]
fn mobile_is_faster_than_desktop() {
let d = AnimationTuning::desktop();
let m = AnimationTuning::mobile();
assert!(m.duration_scale < d.duration_scale, "mobile must animate faster");
assert!(m.overshoot_scale < d.overshoot_scale, "mobile must bounce less");
}
#[test]
fn mobile_has_no_hover() {
// On touch, `hover_scale = 1.0` means no visible hover effect.
assert_eq!(AnimationTuning::mobile().hover_scale, 1.0);
}
#[test]
fn mobile_drag_threshold_larger_than_desktop() {
assert!(
AnimationTuning::mobile().drag_threshold_px
> AnimationTuning::desktop().drag_threshold_px,
"mobile needs a larger threshold because touch is less precise"
);
}
#[test]
fn scale_duration_applies_multiplier() {
let mut t = AnimationTuning::default();
t.duration_scale = 0.5;
assert!((t.scale_duration(1.0) - 0.5).abs() < 1e-6);
assert!((t.scale_duration(0.25) - 0.125).abs() < 1e-6);
}
#[test]
fn mobile_cascade_stagger_tighter_than_desktop() {
assert!(
AnimationTuning::mobile().cascade_stagger_secs
< AnimationTuning::desktop().cascade_stagger_secs
);
}
#[test]
fn default_is_desktop() {
assert_eq!(AnimationTuning::default().platform, InputPlatform::Mouse);
}
}
+2
View File
@@ -24,6 +24,8 @@ pub struct ChallengeAdvancedEvent {
pub new_index: u32,
}
/// Manages Challenge Mode progression: seeded hard deals, no-undo rules, and advancement through the challenge sequence.
/// Requires the player to be at least level `CHALLENGE_UNLOCK_LEVEL`.
pub struct ChallengePlugin;
impl Plugin for ChallengePlugin {
+1
View File
@@ -30,6 +30,7 @@ const MARKER_DEFAULT: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Green tint applied to pile markers that are valid drop targets during drag.
const MARKER_VALID: Color = Color::srgba(0.15, 0.85, 0.25, 0.55);
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
pub struct CursorPlugin;
impl Plugin for CursorPlugin {
@@ -71,6 +71,8 @@ pub struct DailyChallengeCompletedEvent {
#[derive(Resource, Default)]
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
/// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion.
/// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
pub struct DailyChallengePlugin;
impl Plugin for DailyChallengePlugin {
+2
View File
@@ -9,6 +9,8 @@ use bevy::prelude::*;
#[derive(Component, Debug)]
pub struct HelpScreen;
/// Spawns and despawns the help/controls overlay shown when the player presses H (or the help button).
/// All hotkeys and gesture guides live here.
pub struct HelpPlugin;
impl Plugin for HelpPlugin {
+1
View File
@@ -86,6 +86,7 @@ pub struct HudSelection;
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
const Z_HUD: i32 = 50;
/// Renders the in-game HUD: score counter, move counter, elapsed timer, draw-mode indicator, and the auto-complete badge that lights up when the game is solvable without further input.
pub struct HudPlugin;
impl Plugin for HudPlugin {
File diff suppressed because it is too large Load Diff
@@ -62,6 +62,7 @@ struct OptOutTask(Option<Task<Result<(), String>>>);
// Plugin
// ---------------------------------------------------------------------------
/// Manages the leaderboard overlay: fetches scores from the sync server, handles opt-in/opt-out, and displays the ranked list of player scores.
pub struct LeaderboardPlugin;
impl Plugin for LeaderboardPlugin {
+3
View File
@@ -48,6 +48,9 @@ pub use card_animation::{
HoverState, InputBuffer, BufferedInput,
win_scatter_targets, WIN_CASCADE_INTERVAL_SECS, DEAL_INTERVAL_SECS,
MIN_DURATION_SECS, MAX_DURATION_SECS,
AnimationChain,
AnimationTuning, InputPlatform,
FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
};
pub use feedback_anim_plugin::{
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
@@ -32,6 +32,8 @@ const BODY_COLOR: Color = Color::srgb(1.0, 0.87, 0.0);
/// Bright orange used for key-name spans so they stand out from body text.
const KEY_COLOR: Color = Color::srgb(1.0, 0.55, 0.1);
/// Shows a first-run welcome screen that introduces the controls and draw mode.
/// Sets `Settings::first_run_complete` once dismissed so it never appears again.
pub struct OnboardingPlugin;
impl Plugin for OnboardingPlugin {
+1
View File
@@ -48,6 +48,7 @@ pub fn draw_mode_label(mode: DrawMode) -> &'static str {
}
}
/// Handles pause and resume: toggles the pause overlay on Esc, freezes game-input systems via `PausedResource`, and saves the in-progress game state to disk.
pub struct PausePlugin;
impl Plugin for PausePlugin {
+49 -5
View File
@@ -12,28 +12,72 @@ pub struct GameStateResource(pub GameState);
/// Tracks an in-progress drag operation.
///
/// When `cards` is empty there is no active drag. When non-empty, the listed cards
/// are being moved by the user and should be rendered at the cursor position.
#[derive(Resource, Debug, Clone, Default)]
/// When `cards` is empty there is no active drag. When non-empty, the listed
/// cards are being moved by the user and should be rendered at the cursor or
/// touch position.
///
/// # Drag threshold
///
/// A drag is *pending* when `!cards.is_empty() && !committed`. The drag does
/// not become *committed* (cards do not visually move) until the pointer has
/// moved at least `AnimationTuning::drag_threshold_px` pixels from `press_pos`.
/// This prevents accidental drags on quick taps, especially on touch screens.
#[derive(Resource, Debug, Clone)]
pub struct DragState {
/// IDs of the cards being dragged (bottom-to-top stacking order).
pub cards: Vec<u32>,
/// Pile the drag originated from.
pub origin_pile: Option<PileType>,
/// World-space offset from the cursor/touch to the bottom card's centre.
pub cursor_offset: Vec2,
/// Z coordinate used for the dragged cards.
pub origin_z: f32,
/// Screen-space position (logical pixels) where the press/touch began.
///
/// Used to measure whether the drag threshold has been crossed.
pub press_pos: Vec2,
/// Whether the drag threshold has been crossed and visual drag is active.
///
/// Cards are only lifted and repositioned once `committed = true`.
pub committed: bool,
/// Touch ID driving this drag, or `None` for a mouse drag.
pub active_touch_id: Option<u64>,
}
impl Default for DragState {
fn default() -> Self {
Self {
cards: Vec::new(),
origin_pile: None,
cursor_offset: Vec2::ZERO,
origin_z: 0.0,
press_pos: Vec2::ZERO,
committed: false,
active_touch_id: None,
}
}
}
impl DragState {
/// Returns true when no drag is currently in progress.
/// Returns `true` when no drag (pending or committed) is in progress.
pub fn is_idle(&self) -> bool {
self.cards.is_empty()
}
/// Clears the drag state.
/// Returns `true` when a drag has been committed (cards are visually lifted).
pub fn is_committed(&self) -> bool {
self.committed
}
/// Resets all drag state to the idle/default values.
pub fn clear(&mut self) {
self.cards.clear();
self.origin_pile = None;
self.cursor_offset = Vec2::ZERO;
self.origin_z = 0.0;
self.press_pos = Vec2::ZERO;
self.committed = false;
self.active_touch_id = None;
}
}
@@ -31,6 +31,7 @@ pub struct TimeAttackEndedEvent {
pub wins: u32,
}
/// Implements the 10-minute Time Attack mode: counts down the session timer, tracks wins per session, and fires `TimeAttackEndedEvent` when time expires.
pub struct TimeAttackPlugin;
impl Plugin for TimeAttackPlugin {
@@ -21,6 +21,8 @@ pub struct WeeklyGoalCompletedEvent {
pub description: String,
}
/// Tracks weekly goal progress (e.g. win N games, play without undo) and fires `WeeklyGoalCompletedEvent` when a goal is met.
/// Progress resets each Monday.
pub struct WeeklyGoalsPlugin;
impl Plugin for WeeklyGoalsPlugin {
+1 -5
View File
@@ -29,8 +29,4 @@ tracing-subscriber = { workspace = true }
dotenvy = { workspace = true }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
solitaire_sync = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
jsonwebtoken = { workspace = true }
tower = { version = "0.5", features = ["util"] }
+68
View File
@@ -152,3 +152,71 @@ pub async fn opt_in(
Ok(Json(serde_json::json!({ "ok": true })))
}
// ---------------------------------------------------------------------------
// Tests — data shape and display-name logic; no database required
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use chrono::Utc;
use solitaire_sync::LeaderboardEntry;
/// Helper that constructs a `LeaderboardEntry` with the given display name
/// and best score. `best_time_secs` is left as `None`.
fn entry(display_name: &str, best_score: Option<i32>) -> LeaderboardEntry {
LeaderboardEntry {
display_name: display_name.to_string(),
best_score,
best_time_secs: None,
recorded_at: Utc::now(),
}
}
// -----------------------------------------------------------------------
// 1. A LeaderboardEntry always carries a non-empty display_name.
// -----------------------------------------------------------------------
#[test]
fn leaderboard_entry_has_display_name() {
let e = entry("Alice", Some(4_500));
assert!(
!e.display_name.is_empty(),
"display_name must not be empty for a valid leaderboard entry"
);
assert_eq!(e.display_name, "Alice");
}
// -----------------------------------------------------------------------
// 2. A Vec of entries sorts by best_score descending (matching the SQL
// ORDER BY used in get_leaderboard).
// -----------------------------------------------------------------------
#[test]
fn leaderboard_entries_sorted_by_score_descending() {
let mut entries = vec![
entry("Charlie", Some(1_200)),
entry("Alice", Some(8_000)),
entry("Bob", Some(3_500)),
entry("Dave", None), // no score — should rank last
];
// Mirrors the SQL sort:
// CASE WHEN best_score IS NULL THEN 1 ELSE 0 END ASC,
// best_score DESC
entries.sort_by(|a, b| {
let a_null = a.best_score.is_none() as u8;
let b_null = b.best_score.is_none() as u8;
a_null
.cmp(&b_null)
.then_with(|| b.best_score.cmp(&a.best_score))
});
// Scored entries first, in descending order.
assert_eq!(entries[0].display_name, "Alice", "highest scorer must be first");
assert_eq!(entries[1].display_name, "Bob", "second-highest scorer must be second");
assert_eq!(entries[2].display_name, "Charlie", "lowest scorer must be third");
// Null-score entry sinks to the bottom.
assert_eq!(entries[3].display_name, "Dave", "entry with no score must rank last");
}
}
+115
View File
@@ -220,3 +220,118 @@ async fn update_leaderboard_if_opted_in(
Ok(())
}
// ---------------------------------------------------------------------------
// Tests — pure merge logic; no database required
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use chrono::Utc;
use solitaire_sync::{AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, merge};
use uuid::Uuid;
/// Build a minimal `SyncPayload` with default fields, overridden by the
/// caller as needed. Using `Uuid::nil()` keeps every test self-contained.
fn make_payload(stats: StatsSnapshot, achievements: Vec<AchievementRecord>) -> SyncPayload {
SyncPayload {
user_id: Uuid::nil(),
stats,
achievements,
progress: PlayerProgress::default(),
last_modified: Utc::now(),
}
}
fn default_payload() -> SyncPayload {
make_payload(StatsSnapshot::default(), vec![])
}
// -----------------------------------------------------------------------
// 1. Merge keeps the higher games_played from the remote side.
// -----------------------------------------------------------------------
#[test]
fn sync_merge_keeps_higher_games_played() {
let mut local = default_payload();
local.stats.games_played = 10;
let mut remote = default_payload();
remote.stats.games_played = 25; // remote is ahead
let (merged, _) = merge(&local, &remote);
assert_eq!(
merged.stats.games_played, 25,
"merge must keep the higher games_played value from remote"
);
}
// -----------------------------------------------------------------------
// 2. Merge keeps the higher best_single_score from the local side.
// -----------------------------------------------------------------------
#[test]
fn sync_merge_keeps_best_single_score() {
let mut local = default_payload();
local.stats.best_single_score = 8_000; // local is better
let mut remote = default_payload();
remote.stats.best_single_score = 3_500;
let (merged, _) = merge(&local, &remote);
assert_eq!(
merged.stats.best_single_score, 8_000,
"merge must keep the higher best_single_score (local in this case)"
);
}
// -----------------------------------------------------------------------
// 3. Merge never removes an achievement that is unlocked on one side.
// -----------------------------------------------------------------------
#[test]
fn sync_merge_never_removes_unlocked_achievement() {
let mut unlocked = AchievementRecord::locked("first_win");
unlocked.unlock(Utc::now());
// local has the achievement unlocked; remote has no achievements at all.
let local = make_payload(StatsSnapshot::default(), vec![unlocked]);
let remote = make_payload(StatsSnapshot::default(), vec![]);
let (merged, _) = merge(&local, &remote);
let found = merged
.achievements
.iter()
.find(|a| a.id == "first_win")
.expect("achievement must survive the merge");
assert!(
found.unlocked,
"achievement unlocked on local must remain unlocked after merge with remote that lacks it"
);
}
// -----------------------------------------------------------------------
// 4. merge(payload, payload) is idempotent for key numeric fields.
// -----------------------------------------------------------------------
#[test]
fn sync_merge_is_idempotent() {
let mut payload = default_payload();
payload.stats.games_played = 42;
payload.stats.games_won = 20;
payload.stats.best_single_score = 5_500;
payload.stats.fastest_win_seconds = 90;
payload.stats.lifetime_score = 110_000;
payload.progress.total_xp = 3_000;
let (merged, _) = merge(&payload, &payload);
assert_eq!(merged.stats.games_played, 42, "idempotent: games_played");
assert_eq!(merged.stats.games_won, 20, "idempotent: games_won");
assert_eq!(merged.stats.best_single_score, 5_500, "idempotent: best_single_score");
assert_eq!(merged.stats.fastest_win_seconds, 90, "idempotent: fastest_win_seconds");
assert_eq!(merged.stats.lifetime_score, 110_000, "idempotent: lifetime_score");
assert_eq!(merged.progress.total_xp, 3_000, "idempotent: total_xp");
}
}