Compare commits

...

8 Commits

Author SHA1 Message Date
funman300 4d6f8bccb7 chore(pkg): simplify PKGBUILDs for local private builds
Remove GitHub source tarball and b2sums. Both PKGBUILDs now build
directly from the local workspace via _srcdir="$startdir/../..".
Run makepkg from pkg/solitaire-quest/ or pkg/solitaire-quest-server/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:45:52 +00:00
funman300 800dfb50ce chore(pkg): add Arch Linux PKGBUILDs for game client and sync server
- pkg/solitaire-quest/PKGBUILD: builds solitaire_app binary, depends on
  alsa-lib, libxkbcommon, systemd-libs (Bevy Linux requirements); check()
  runs only non-Bevy crates (solitaire_core, solitaire_sync) since Bevy
  integration tests require a GPU/display unavailable in chroot
- pkg/solitaire-quest-server/PKGBUILD: builds solitaire_server binary,
  installs systemd service unit and hardened environment file template
- pkg/solitaire-quest-server/solitaire-quest-server.service: systemd unit
  with ProtectSystem=strict, NoNewPrivileges, dedicated service user
- pkg/solitaire-quest-server/server.env: documented env template installed
  to /etc/solitaire-quest-server/server.env (mode 0640, listed in backup=)
- LICENSE: add MIT license
- Cargo.toml: add license = "MIT" to [workspace.package]
- All member crates: add license.workspace = true

Both PKGBUILDs follow the Arch Rust package guidelines:
  prepare() uses --locked + cargo fetch
  build() uses --frozen --release -p <crate>
  RUSTUP_TOOLCHAIN=stable and CARGO_TARGET_DIR=target set in each stage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:44:44 +00:00
funman300 735d8766a2 docs(engine): add missing doc comments on layout, ProgressPlugin; fix audio format in ARCHITECTURE.md
- Add field-level doc comments to Layout::card_size and Layout::pile_positions
- Add struct-level doc comment to ProgressPlugin
- Fix ARCHITECTURE.md Section 14: .ogg → .wav throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:37:07 +00:00
funman300 ccfeb055e5 fix(server): load JWT_SECRET at startup, add auth logging, fix challenge race
- Introduce AppState { pool, jwt_secret } so JWT_SECRET is loaded once in
  main() and any missing value is a fatal startup error rather than a 500
  on the first request.  All four env::var("JWT_SECRET") call sites in
  auth.rs and middleware.rs are replaced with state.jwt_secret.
- build_test_router embeds the fixed test secret so integration tests do
  not need to set JWT_SECRET in the environment.
- Add tracing::warn! in login (invalid password) and register (username
  taken) to surface brute-force attempts in production logs.
- Fix daily-challenge race condition: after INSERT OR IGNORE, re-SELECT
  the persisted row so concurrent requests both return the winner's data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:35:46 +00:00
funman300 8f957d919f test(core,sync,server): add EmptySource, ConflictReport, and roundtrip coverage
- core/game_state.rs: move_from_empty_pile_returns_empty_source covers the
  EmptySource error path in move_cards() that had no test
- sync/merge.rs: four new tests verifying ConflictReport field/value content
  for win_streak_current and daily_challenge_streak divergence, plus negative
  cases asserting no report is generated when values are equal
- server/tests: register_login_push_pull_full_roundtrip drives the full
  register → login → push → pull sequence through the test router, confirming
  that a login-derived JWT can push stats and retrieve them unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:34:57 +00:00
funman300 2407686e13 fix(engine,gpgs,core,server): export CardFaceRevealedEvent, explicit gpgs stub, enum/constant docs
- engine/lib.rs: re-export CardFaceRevealedEvent so external crates can consume flip-midpoint audio events
- gpgs/stub.rs: add explicit impls for all six defaulted SyncProvider methods; future trait changes now cause a compile error in the stub rather than silently picking up wrong defaults
- core/game_state.rs: add /// doc comments to DrawMode and GameMode variants
- server/auth.rs: replace terse BCRYPT_COST comment with full /// doc comment matching ARCHITECTURE.md §19
- server/leaderboard.rs: add /// doc comment to DISPLAY_NAME_MAX; fix misplaced comment that was prepended to the opt_in handler instead of the constant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:30:22 +00:00
funman300 1ec2593137 fix(engine): resolve input coordination bugs in selection/pause/keyboard
- SelectionPlugin: add clear_selection_on_state_change system so undo/move/reject
  never leave a stale selection pointing at the wrong card
- SelectionPlugin: expose SelectionKeySet system set for cross-plugin ordering
- PausePlugin: skip Escape→pause when a card is keyboard-selected; toggle_pause
  now runs before SelectionKeySet so it reads SelectionState before it is cleared
- InputPlugin: guard Space→DrawRequestEvent when SelectionState has an active pile
  so Space executes a card move instead of also drawing from stock
- window: enforce 800×600 minimum via WindowResizeConstraints
- game_state: add precondition doc to next_auto_complete_move (waste exclusion)
- card_plugin: 12 unit tests for constants, face_colour, label_visibility, label_for
- pause_plugin: add paused_resource_default and draw_mode_label exhaustiveness tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:13:10 +00:00
funman300 ffc79447d4 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>
2026-04-28 22:02:52 +00:00
58 changed files with 2640 additions and 374 deletions
+7 -7
View File
@@ -851,16 +851,16 @@ Levels 11+: level = 10 + floor((total_xp - 5000) / 1000)
## 14. Audio System
Audio uses `bevy_kira_audio`. All sound files are `.ogg` (good compression, cross-platform, royalty-free).
Audio uses `bevy_kira_audio`. All sound files are `.wav`.
| File | Trigger |
|---|---|
| `card_deal.ogg` | New game deal animation |
| `card_flip.ogg` | Card flips face-up |
| `card_place.ogg` | Valid card placement |
| `card_invalid.ogg` | Invalid move attempt |
| `win_fanfare.ogg` | Game won |
| `ambient_loop.ogg` | Looping background music (restarts seamlessly) |
| `card_deal.wav` | New game deal animation |
| `card_flip.wav` | Card flips face-up |
| `card_place.wav` | Valid card placement |
| `card_invalid.wav` | Invalid move attempt |
| `win_fanfare.wav` | Game won |
| `ambient_loop.wav` | Looping background music (restarts seamlessly) |
Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `bevy_kira_audio` channel volumes.
+1
View File
@@ -14,6 +14,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "0.1.0"
license = "MIT"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 funman300
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+63
View File
@@ -0,0 +1,63 @@
# Maintainer: funman300 <funman300@gmail.com>
pkgname=solitaire-quest-server
pkgver=0.1.0
pkgrel=1
pkgdesc='Self-hosted sync server for Solitaire Quest (stats, achievements, leaderboards)'
url='https://github.com/funman300/solitaire-quest'
license=('MIT')
arch=('x86_64')
makedepends=('cargo' 'rust')
depends=(
'gcc-libs'
'glibc'
)
backup=('etc/solitaire-quest-server/server.env')
# Build from the local workspace (two levels above this PKGBUILD).
_srcdir="$startdir/../.."
source=(
'solitaire-quest-server.service'
'server.env'
)
b2sums=('SKIP'
'SKIP')
prepare() {
export RUSTUP_TOOLCHAIN=stable
cd "$_srcdir"
cargo fetch --locked --target "$(rustc -Vv | grep host | cut -d' ' -f2)"
}
build() {
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cd "$_srcdir"
cargo build --frozen --release -p solitaire_server
}
check() {
export RUSTUP_TOOLCHAIN=stable
cd "$_srcdir"
cargo test --frozen -p solitaire_server -p solitaire_sync
}
package() {
cd "$_srcdir"
# Binary
install -Dm0755 -t "$pkgdir/usr/bin/" "target/release/solitaire_server"
# systemd service
install -Dm0644 "$srcdir/solitaire-quest-server.service" \
"$pkgdir/usr/lib/systemd/system/solitaire-quest-server.service"
# Environment file (contains JWT_SECRET, DATABASE_URL, SERVER_PORT)
install -Dm0640 "$srcdir/server.env" \
"$pkgdir/etc/solitaire-quest-server/server.env"
# License and docs
install -Dm0644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm0644 README_SERVER.md \
"$pkgdir/usr/share/doc/$pkgname/README_SERVER.md"
}
+15
View File
@@ -0,0 +1,15 @@
# Solitaire Quest Server — environment configuration
# This file is installed to /etc/solitaire-quest-server/server.env (mode 0640).
# Edit these values before starting the service.
# Path to the SQLite database file.
# The directory must be writable by the solitaire-quest service user.
DATABASE_URL=sqlite:///var/lib/solitaire-quest-server/solitaire.db
# HS256 signing secret for JWT tokens.
# Generate a strong secret with: openssl rand -hex 32
# REQUIRED — server will refuse to start if unset.
JWT_SECRET=changeme_generate_with_openssl_rand_hex_32
# TCP port the server listens on.
SERVER_PORT=8080
@@ -0,0 +1,23 @@
[Unit]
Description=Solitaire Quest Sync Server
Documentation=https://github.com/funman300/solitaire-quest/blob/main/README_SERVER.md
After=network.target
[Service]
Type=simple
User=solitaire-quest
Group=solitaire-quest
EnvironmentFile=/etc/solitaire-quest-server/server.env
ExecStart=/usr/bin/solitaire_server
Restart=on-failure
RestartSec=5s
# Harden the service
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/solitaire-quest-server
[Install]
WantedBy=multi-user.target
+48
View File
@@ -0,0 +1,48 @@
# Maintainer: funman300 <funman300@gmail.com>
pkgname=solitaire-quest
pkgver=0.1.0
pkgrel=1
pkgdesc='Cross-platform Klondike Solitaire with progression, achievements, and optional sync'
url='https://github.com/funman300/solitaire-quest'
license=('MIT')
arch=('x86_64')
makedepends=('cargo' 'rust')
depends=(
'gcc-libs'
'glibc'
'alsa-lib'
'libxkbcommon'
'systemd-libs' # libudev.so — required by Bevy input
)
# Build from the local workspace (two levels above this PKGBUILD).
_srcdir="$startdir/../.."
source=()
b2sums=()
prepare() {
export RUSTUP_TOOLCHAIN=stable
cd "$_srcdir"
cargo fetch --locked --target "$(rustc -Vv | grep host | cut -d' ' -f2)"
}
build() {
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cd "$_srcdir"
cargo build --frozen --release -p solitaire_app
}
check() {
export RUSTUP_TOOLCHAIN=stable
cd "$_srcdir"
# Only test non-Bevy crates — Bevy integration tests require a GPU/display.
cargo test --frozen -p solitaire_core -p solitaire_sync
}
package() {
cd "$_srcdir"
install -Dm0755 -t "$pkgdir/usr/bin/" "target/release/solitaire_app"
install -Dm0644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+1
View File
@@ -1,6 +1,7 @@
[package]
name = "solitaire_app"
version.workspace = true
license.workspace = true
edition.workspace = true
[[bin]]
+13 -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() {
@@ -22,6 +22,11 @@ fn main() {
primary_window: Some(Window {
title: "Solitaire Quest".into(),
resolution: (1280u32, 800u32).into(),
resize_constraints: bevy::window::WindowResizeConstraints {
min_width: 800.0,
min_height: 600.0,
..default()
},
..default()
}),
..default()
@@ -32,8 +37,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 +59,6 @@ fn main() {
.add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin)
.run();
}
+1
View File
@@ -1,6 +1,7 @@
[package]
name = "solitaire_assetgen"
version.workspace = true
license.workspace = true
edition.workspace = true
publish = false
+1
View File
@@ -1,6 +1,7 @@
[package]
name = "solitaire_core"
version.workspace = true
license.workspace = true
edition.workspace = true
[dependencies]
+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>,
}
+60
View File
@@ -30,7 +30,9 @@ mod pile_map_serde {
/// Whether cards are drawn one at a time or three at a time from the stock.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawMode {
/// Draw one card from stock per turn.
DrawOne,
/// Draw three cards from stock per turn; only the top is playable.
DrawThree,
}
@@ -46,9 +48,13 @@ pub enum DrawMode {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum GameMode {
#[default]
/// Standard Klondike rules with score and timer.
Classic,
/// No timer, no score display, ambient audio only.
Zen,
/// Fixed hard seeds, no undo, must win to advance.
Challenge,
/// Play as many games as possible within 10 minutes.
TimeAttack,
}
@@ -64,18 +70,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 +187,7 @@ impl GameState {
stock.cards.push(card);
}
self.recycle_count = self.recycle_count.saturating_add(1);
self.move_count += 1;
return Ok(());
}
@@ -352,6 +367,15 @@ impl GameState {
/// Scans tableau piles 06 in order, returning the first top card that
/// can be placed on any foundation pile. The scan order ensures Aces are
/// resolved before higher ranks that depend on them.
///
/// # Precondition
///
/// This function is only called when `is_auto_completable` is `true`.
/// Auto-completability requires the waste pile to be empty, as enforced by
/// [`check_auto_complete`](Self::check_auto_complete) — it returns `false`
/// whenever `piles[Waste]` is non-empty. Therefore, skipping the waste pile
/// in this scan is intentional and correct: by the time this function is
/// reached, there are guaranteed to be no cards there to move.
pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> {
if !self.is_auto_completable || self.is_won {
return None;
@@ -562,6 +586,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
@@ -949,6 +991,24 @@ mod tests {
assert_eq!(g.compute_time_bonus(), 7000);
}
// --- EmptySource error path ---
#[test]
fn move_from_empty_pile_returns_empty_source() {
// Build a game state, clear a tableau pile entirely, then attempt to
// move from it. The source pile exists in `piles` (key is present) but
// contains no cards — exactly the code path that returns EmptySource.
let mut g = new_game();
// Tableau(0) starts with exactly 1 card; clear it to make the pile empty.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1);
assert_eq!(
result,
Err(MoveError::EmptySource),
"moving from an empty pile must return EmptySource"
);
}
// --- next_auto_complete_move ---
#[test]
+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
View File
@@ -1,6 +1,7 @@
[package]
name = "solitaire_data"
version.workspace = true
license.workspace = true
edition.workspace = true
[dependencies]
+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
@@ -1,6 +1,7 @@
[package]
name = "solitaire_engine"
version.workspace = true
license.workspace = true
edition.workspace = true
[dependencies]
+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);
}
}
+123
View File
@@ -1322,6 +1322,129 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// Constant sanity bounds (pure)
// -----------------------------------------------------------------------
#[test]
fn tableau_fan_frac_is_in_unit_interval() {
assert!(
TABLEAU_FAN_FRAC > 0.0 && TABLEAU_FAN_FRAC < 1.0,
"TABLEAU_FAN_FRAC must be in (0, 1), got {TABLEAU_FAN_FRAC}"
);
}
#[test]
fn flip_half_secs_is_positive() {
assert!(
FLIP_HALF_SECS > 0.0,
"FLIP_HALF_SECS must be positive, got {FLIP_HALF_SECS}"
);
}
#[test]
fn font_size_frac_is_positive_and_reasonable() {
assert!(
FONT_SIZE_FRAC > 0.0 && FONT_SIZE_FRAC <= 1.0,
"FONT_SIZE_FRAC should be in (0, 1], got {FONT_SIZE_FRAC}"
);
}
// -----------------------------------------------------------------------
// face_colour (pure) — color-blind mode
// -----------------------------------------------------------------------
#[test]
fn face_colour_normal_mode_returns_card_face_colour_for_red_suit() {
let card = Card { id: 0, suit: Suit::Hearts, rank: Rank::King, face_up: true };
assert_eq!(face_colour(&card, false), CARD_FACE_COLOUR);
}
#[test]
fn face_colour_normal_mode_returns_card_face_colour_for_black_suit() {
let card = Card { id: 0, suit: Suit::Spades, rank: Rank::King, face_up: true };
assert_eq!(face_colour(&card, false), CARD_FACE_COLOUR);
}
#[test]
fn face_colour_color_blind_mode_gives_red_suits_a_different_tint() {
let red_card = Card { id: 0, suit: Suit::Diamonds, rank: Rank::Queen, face_up: true };
let cbm_colour = face_colour(&red_card, true);
assert_ne!(
cbm_colour, CARD_FACE_COLOUR,
"color-blind mode must tint red-suit cards differently from the standard face colour"
);
}
#[test]
fn face_colour_color_blind_mode_does_not_change_black_suits() {
let black_card = Card { id: 0, suit: Suit::Clubs, rank: Rank::Jack, face_up: true };
assert_eq!(
face_colour(&black_card, true),
CARD_FACE_COLOUR,
"color-blind mode must not alter black-suit card face colour"
);
}
// -----------------------------------------------------------------------
// label_visibility (pure)
// -----------------------------------------------------------------------
#[test]
fn label_visibility_face_up_is_inherited() {
let card = Card { id: 0, suit: Suit::Clubs, rank: Rank::Ace, face_up: true };
assert_eq!(label_visibility(&card), Visibility::Inherited);
}
#[test]
fn label_visibility_face_down_is_hidden() {
let card = Card { id: 0, suit: Suit::Clubs, rank: Rank::Ace, face_up: false };
assert_eq!(label_visibility(&card), Visibility::Hidden);
}
// -----------------------------------------------------------------------
// label_for — remaining ranks not yet covered
// -----------------------------------------------------------------------
#[test]
fn label_for_all_ranks_contain_suit_letter() {
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let letters = ["C", "D", "H", "S"];
for (suit, letter) in suits.iter().zip(letters.iter()) {
let card = Card { id: 0, suit: *suit, rank: Rank::King, face_up: true };
assert!(
label_for(&card).ends_with(letter),
"label for {suit:?} must end with '{letter}'"
);
}
}
#[test]
fn label_for_face_cards_use_letter_prefix() {
let make = |rank| Card { id: 0, suit: Suit::Spades, rank, face_up: true };
assert!(label_for(&make(Rank::Jack)).starts_with('J'));
assert!(label_for(&make(Rank::Queen)).starts_with('Q'));
assert!(label_for(&make(Rank::King)).starts_with('K'));
}
#[test]
fn label_for_numeric_ranks_two_through_nine() {
let make = |rank| Card { id: 0, suit: Suit::Clubs, rank, face_up: true };
let expected = [
(Rank::Two, "2C"),
(Rank::Three, "3C"),
(Rank::Four, "4C"),
(Rank::Five, "5C"),
(Rank::Six, "6C"),
(Rank::Seven, "7C"),
(Rank::Eight, "8C"),
(Rank::Nine, "9C"),
];
for (rank, label) in expected {
assert_eq!(label_for(&make(rank)), label, "rank {rank:?}");
}
}
#[test]
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
+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
+9 -2
View File
@@ -27,9 +27,16 @@ pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
/// Computed board layout for a given window size.
#[derive(Debug, Clone)]
pub struct Layout {
/// Width/height of a single card, in world units.
/// Width and height of a single card, in world units (Bevy 2D world-space).
///
/// `x` is the card width; `y` is the card height (always `x * 1.4`).
/// All pile positions and fan offsets are derived from this value.
pub card_size: Vec2,
/// Centre position of each pile, in world coordinates.
/// Centre position of each pile, in 2D world coordinates.
///
/// World origin `(0, 0)` is the window centre; `+x` is right, `+y` is up.
/// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
/// entry. The map always contains exactly 13 entries after `compute_layout`.
pub pile_positions: HashMap<PileType, Vec2>,
}
@@ -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 {
+7 -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,
@@ -61,9 +64,10 @@ pub use card_plugin::{
};
pub use cursor_plugin::CursorPlugin;
pub use events::{
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, ForfeitEvent, GameWonEvent,
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
ForfeitEvent, GameWonEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent,
MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent,
StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
};
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
pub use help_plugin::{HelpPlugin, HelpScreen};
@@ -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 {
+38 -1
View File
@@ -23,6 +23,7 @@ use crate::events::StateChangedEvent;
use crate::game_plugin::{GameOverScreen, GameStatePath};
use crate::progress_plugin::ProgressResource;
use crate::resources::{DragState, GameStateResource};
use crate::selection_plugin::{SelectionKeySet, SelectionState};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
use crate::stats_plugin::StatsResource;
@@ -48,6 +49,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 {
@@ -57,7 +59,15 @@ impl Plugin for PausePlugin {
app.add_message::<SettingsChangedEvent>()
.add_message::<StateChangedEvent>()
.init_resource::<PausedResource>()
.add_systems(Update, (toggle_pause, handle_pause_draw_toggle));
.add_systems(
Update,
(
// toggle_pause must see SelectionState *before* handle_selection_keys
// clears it, so it can skip Escape when a card is selected.
toggle_pause.before(SelectionKeySet),
handle_pause_draw_toggle,
),
);
}
}
@@ -75,10 +85,16 @@ fn toggle_pause(
settings: Option<Res<SettingsResource>>,
mut drag: Option<ResMut<DragState>>,
mut changed: MessageWriter<StateChangedEvent>,
selection: Option<Res<SelectionState>>,
) {
if !keys.just_pressed(KeyCode::Escape) {
return;
}
// If a card is currently selected, let SelectionPlugin handle this Escape
// (it will clear the selection). Pause must not also open in the same frame.
if selection.is_some_and(|s| s.selected_pile.is_some()) {
return;
}
// If the game-over overlay is visible, let handle_game_over_input consume
// the Escape key (to start a new game). Do not open the pause overlay.
if !game_over_screens.is_empty() {
@@ -414,6 +430,16 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// PausedResource default (pure)
// -----------------------------------------------------------------------
#[test]
fn paused_resource_default_is_unpaused() {
let p = PausedResource::default();
assert!(!p.0, "game must start unpaused");
}
// -----------------------------------------------------------------------
// draw_mode_label (pure function) — Task #64
// -----------------------------------------------------------------------
@@ -428,6 +454,17 @@ mod tests {
assert_eq!(draw_mode_label(DrawMode::DrawThree), "Draw 3");
}
/// Both variants are covered so the match is exhaustive — this test would
/// fail to compile if a new DrawMode variant were added without updating
/// `draw_mode_label`.
#[test]
fn draw_mode_label_covers_all_variants() {
for mode in [DrawMode::DrawOne, DrawMode::DrawThree] {
let label = draw_mode_label(mode);
assert!(!label.is_empty(), "draw_mode_label must never return an empty string");
}
}
// -----------------------------------------------------------------------
// pause_draw_toggle_flips_draw_mode — Task #64
// -----------------------------------------------------------------------
+5
View File
@@ -37,6 +37,11 @@ pub struct LevelUpEvent {
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProgressUpdate;
/// Bevy plugin that awards XP on `GameWonEvent`, persists `PlayerProgress`,
/// and emits `LevelUpEvent` whenever a win crosses a level boundary.
///
/// Use `ProgressPlugin::default()` in the main app (reads/writes the platform
/// data directory) and `ProgressPlugin::headless()` in tests (no I/O).
pub struct ProgressPlugin {
pub storage_path: Option<PathBuf>,
}
+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;
}
}
+26 -2
View File
@@ -22,7 +22,7 @@ use solitaire_core::card::Suit;
use solitaire_core::pile::PileType;
use crate::card_plugin::CardEntity;
use crate::events::{InfoToastEvent, MoveRequestEvent};
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
use crate::layout::LayoutResource;
@@ -42,6 +42,13 @@ pub struct SelectionState {
pub selected_pile: Option<PileType>,
}
/// System set label for the key-handling system.
///
/// `PausePlugin` registers `toggle_pause` before this set so it can read
/// [`SelectionState`] before `handle_selection_keys` clears it on Escape.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SelectionKeySet;
/// Marker component placed on the outline sprite used as the keyboard-selection
/// highlight.
///
@@ -59,7 +66,10 @@ impl Plugin for SelectionPlugin {
.add_systems(
Update,
(
handle_selection_keys.before(GameMutation),
handle_selection_keys
.in_set(SelectionKeySet)
.before(GameMutation),
clear_selection_on_state_change.after(GameMutation),
update_selection_highlight.after(GameMutation),
),
);
@@ -329,6 +339,20 @@ fn try_foundation_dest(
None
}
/// Clears the selection whenever the game state changes.
///
/// Without this, an undo or a rejected move could leave `selected_pile`
/// pointing at a pile whose top card changed, causing the highlight to
/// trail a different card than the player expects.
fn clear_selection_on_state_change(
mut state_events: MessageReader<StateChangedEvent>,
mut selection: ResMut<SelectionState>,
) {
if state_events.read().next().is_some() {
selection.selected_pile = None;
}
}
/// Maintains the `SelectionHighlight` outline sprite.
///
/// When a pile is selected, a cyan sprite is placed at the selected card's
@@ -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
View File
@@ -1,6 +1,7 @@
[package]
name = "solitaire_gpgs"
version.workspace = true
license.workspace = true
edition.workspace = true
[dependencies]
+31 -1
View File
@@ -1,6 +1,6 @@
use async_trait::async_trait;
use solitaire_data::{SyncError, SyncProvider};
use solitaire_sync::{SyncPayload, SyncResponse};
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
/// Google Play Games Services sync client — desktop/iOS stub.
///
@@ -38,4 +38,34 @@ impl SyncProvider for GpgsClient {
fn is_authenticated(&self) -> bool {
false
}
/// No-op stub — returns UnsupportedPlatform on non-Android targets.
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError> {
Err(SyncError::UnsupportedPlatform)
}
/// No-op stub — returns UnsupportedPlatform on non-Android targets.
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
Err(SyncError::UnsupportedPlatform)
}
/// No-op stub — returns UnsupportedPlatform on non-Android targets.
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError> {
Err(SyncError::UnsupportedPlatform)
}
/// No-op stub — returns UnsupportedPlatform on non-Android targets.
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError> {
Err(SyncError::UnsupportedPlatform)
}
/// No-op stub — returns UnsupportedPlatform on non-Android targets.
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
Err(SyncError::UnsupportedPlatform)
}
/// No-op stub — returns UnsupportedPlatform on non-Android targets.
async fn delete_account(&self) -> Result<(), SyncError> {
Err(SyncError::UnsupportedPlatform)
}
}
+2 -5
View File
@@ -1,6 +1,7 @@
[package]
name = "solitaire_server"
version.workspace = true
license.workspace = true
edition.workspace = true
[lib]
@@ -29,8 +30,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"] }
+18 -24
View File
@@ -5,12 +5,12 @@ use bcrypt::{hash, verify};
use chrono::Utc;
use jsonwebtoken::{encode, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::{
error::AppError,
middleware::{validate_refresh_token, AuthenticatedUser, Claims},
AppState,
};
// ---------------------------------------------------------------------------
@@ -59,7 +59,7 @@ struct UserRow {
// bcrypt cost used for password hashing
// ---------------------------------------------------------------------------
/// bcrypt cost factor. Per ARCHITECTURE.md §19 this must be 12.
/// bcrypt work factor. Cost 12 ≈ 300 ms on modern hardware — balances security against registration latency.
const BCRYPT_COST: u32 = 12;
// ---------------------------------------------------------------------------
@@ -107,7 +107,7 @@ fn username_chars_ok(s: &str) -> bool {
}
pub async fn register(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
Json(body): Json<AuthRequest>,
) -> Result<Json<AuthResponse>, AppError> {
// Validate username: 332 characters, alphanumeric + underscores only.
@@ -137,11 +137,12 @@ pub async fn register(
"SELECT id FROM users WHERE username = ?",
username
)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?
.flatten();
if existing.is_some() {
tracing::warn!(username = %username, "register: username already taken");
return Err(AppError::UsernameTaken);
}
@@ -156,21 +157,18 @@ pub async fn register(
password_hash,
now
)
.execute(&pool)
.execute(&state.pool)
.await?;
let secret = std::env::var("JWT_SECRET")
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
Ok(Json(AuthResponse {
access_token: make_access_token(&user_id, &secret)?,
refresh_token: make_refresh_token(&user_id, &secret)?,
access_token: make_access_token(&user_id, &state.jwt_secret)?,
refresh_token: make_refresh_token(&user_id, &state.jwt_secret)?,
}))
}
/// `POST /api/auth/login` — verify credentials and return tokens.
pub async fn login(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
Json(body): Json<AuthRequest>,
) -> Result<Json<AuthResponse>, AppError> {
let username = body.username.trim().to_string();
@@ -179,7 +177,7 @@ pub async fn login(
"SELECT id, password_hash FROM users WHERE username = ?",
username
)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?;
let row = row.ok_or(AppError::InvalidCredentials)?;
@@ -188,29 +186,25 @@ pub async fn login(
let valid = verify(&body.password, &row_hash)?;
if !valid {
tracing::warn!(username = %username, "login: invalid password");
return Err(AppError::InvalidCredentials);
}
let secret = std::env::var("JWT_SECRET")
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
Ok(Json(AuthResponse {
access_token: make_access_token(&row_id, &secret)?,
refresh_token: make_refresh_token(&row_id, &secret)?,
access_token: make_access_token(&row_id, &state.jwt_secret)?,
refresh_token: make_refresh_token(&row_id, &state.jwt_secret)?,
}))
}
/// `POST /api/auth/refresh` — exchange a refresh token for a new access token.
pub async fn refresh(
State(state): State<AppState>,
Json(body): Json<RefreshRequest>,
) -> Result<Json<RefreshResponse>, AppError> {
let secret = std::env::var("JWT_SECRET")
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
let claims = validate_refresh_token(&body.refresh_token, &secret)?;
let claims = validate_refresh_token(&body.refresh_token, &state.jwt_secret)?;
Ok(Json(RefreshResponse {
access_token: make_access_token(&claims.sub, &secret)?,
access_token: make_access_token(&claims.sub, &state.jwt_secret)?,
}))
}
@@ -218,11 +212,11 @@ pub async fn refresh(
///
/// All related rows are removed via `ON DELETE CASCADE` in the schema.
pub async fn delete_account(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<serde_json::Value>, AppError> {
sqlx::query!("DELETE FROM users WHERE id = ?", user.user_id)
.execute(&pool)
.execute(&state.pool)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
+26 -8
View File
@@ -8,11 +8,10 @@
use axum::{extract::State, Json};
use chrono::Utc;
use sqlx::SqlitePool;
use solitaire_sync::ChallengeGoal;
use crate::error::AppError;
use crate::{error::AppError, AppState};
// ---------------------------------------------------------------------------
// Seed generation
@@ -97,18 +96,22 @@ struct ChallengeRow {
///
/// Looks up today's challenge in the database. If none exists yet, generates
/// one deterministically and stores it before returning.
///
/// The `INSERT OR IGNORE` followed by a re-SELECT ensures that concurrent
/// requests racing to create today's row all return the same persisted value
/// rather than each returning their own locally-generated copy.
pub async fn daily_challenge(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
) -> Result<Json<ChallengeGoal>, AppError> {
let today = Utc::now().format("%Y-%m-%d").to_string();
// Try to load an existing row.
// Try to load an existing row first (fast path — no generation needed).
let row = sqlx::query_as!(
ChallengeRow,
"SELECT goal_json FROM daily_challenges WHERE date = ?",
today
)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?;
if let Some(r) = row {
@@ -117,7 +120,10 @@ pub async fn daily_challenge(
return Ok(Json(goal));
}
// No row yet — generate and store.
// No row yet — generate the goal locally and attempt to store it.
// `INSERT OR IGNORE` means a concurrent request that wins the race will
// silently ignore our insert. We then re-SELECT to ensure both requests
// return the same persisted row regardless of which one won.
let seed = hash_date_to_u64(&today);
let goal = generate_goal(&today, seed);
let goal_json = serde_json::to_string(&goal)?;
@@ -129,10 +135,22 @@ pub async fn daily_challenge(
seed_i64,
goal_json
)
.execute(&pool)
.execute(&state.pool)
.await?;
Ok(Json(goal))
// Re-SELECT to return exactly what is stored — handles the race where
// another request inserted a row between our initial SELECT and INSERT.
let stored = sqlx::query_as!(
ChallengeRow,
"SELECT goal_json FROM daily_challenges WHERE date = ?",
today
)
.fetch_one(&state.pool)
.await?;
let stored_json = stored.goal_json.ok_or_else(|| AppError::Internal("missing goal_json after insert".into()))?;
let stored_goal: ChallengeGoal = serde_json::from_str(&stored_json)?;
Ok(Json(stored_goal))
}
#[cfg(test)]
+80 -12
View File
@@ -6,11 +6,10 @@
use axum::{extract::State, Json};
use chrono::Utc;
use serde::Deserialize;
use sqlx::SqlitePool;
use solitaire_sync::LeaderboardEntry;
use crate::{error::AppError, middleware::AuthenticatedUser};
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
// ---------------------------------------------------------------------------
// Request shapes
@@ -42,7 +41,7 @@ struct LeaderboardRow {
///
/// Returns entries sorted by `best_score` descending (nulls last).
pub async fn get_leaderboard(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
_user: AuthenticatedUser,
) -> Result<Json<Vec<LeaderboardEntry>>, AppError> {
let rows = sqlx::query_as!(
@@ -57,7 +56,7 @@ pub async fn get_leaderboard(
CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,
l.best_time_secs ASC"#
)
.fetch_all(&pool)
.fetch_all(&state.pool)
.await?;
let entries: Result<Vec<LeaderboardEntry>, AppError> = rows
@@ -90,28 +89,29 @@ pub async fn get_leaderboard(
/// appears in `GET /api/leaderboard`. The leaderboard row itself is kept
/// so scores are preserved if the player opts back in later.
pub async fn opt_out(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<serde_json::Value>, AppError> {
sqlx::query!(
"UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
user.user_id
)
.execute(&pool)
.execute(&state.pool)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Maximum allowed character count for a leaderboard display name (32 chars).
/// Keeps names readable in the leaderboard UI while allowing reasonable creativity.
const DISPLAY_NAME_MAX: usize = 32;
/// `POST /api/leaderboard/opt-in` — opt in and upsert the player's entry.
///
/// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the
/// leaderboard entry with the supplied display name.
/// Maximum allowed length for a leaderboard display name.
const DISPLAY_NAME_MAX: usize = 32;
pub async fn opt_in(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
user: AuthenticatedUser,
Json(body): Json<OptInRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
@@ -131,7 +131,7 @@ pub async fn opt_in(
"UPDATE users SET leaderboard_opt_in = 1 WHERE id = ?",
user.user_id
)
.execute(&pool)
.execute(&state.pool)
.await?;
let now = Utc::now().to_rfc3339();
@@ -147,8 +147,76 @@ pub async fn opt_in(
display_name,
now
)
.execute(&pool)
.execute(&state.pool)
.await?;
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");
}
}
+27 -6
View File
@@ -21,23 +21,41 @@ use sqlx::SqlitePool;
use std::sync::Arc;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
/// Shared application state injected into every Axum handler via [`axum::extract::State`].
///
/// Loaded once at startup so a missing `JWT_SECRET` causes an immediate startup
/// failure rather than a 500 error on the first request.
#[derive(Clone)]
pub struct AppState {
/// SQLite connection pool.
pub pool: SqlitePool,
/// HS256 signing secret for JWT access and refresh tokens.
pub jwt_secret: String,
}
/// Construct the full Axum [`Router`].
///
/// Separated from `main` so it can be instantiated in integration tests without
/// starting a real TCP listener.
pub fn build_router(pool: SqlitePool) -> Router {
build_router_inner(pool, true)
pub fn build_router(state: AppState) -> Router {
build_router_inner(state, true)
}
/// Construct the router without rate limiting.
///
/// Intended for integration tests only — do not use in production.
/// Uses a fixed test JWT secret (`"test_secret_32_chars_minimum_ok!"`) so
/// integration tests do not need to set `JWT_SECRET` in the environment.
#[doc(hidden)]
pub fn build_test_router(pool: SqlitePool) -> Router {
build_router_inner(pool, false)
let state = AppState {
pool,
jwt_secret: "test_secret_32_chars_minimum_ok!".to_string(),
};
build_router_inner(state, false)
}
fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
// Protected routes require a valid JWT (injected by require_auth middleware).
let protected = Router::new()
.route("/api/sync/pull", get(sync::pull))
@@ -46,7 +64,10 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
.route("/api/account", delete(auth::delete_account))
.layer(axum_middleware::from_fn(middleware::require_auth));
.layer(axum_middleware::from_fn_with_state(
state.clone(),
middleware::require_auth,
));
// Auth endpoints — rate-limited in production, unrestricted in tests.
let auth_routes = Router::new()
@@ -80,7 +101,7 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
.merge(public)
// Reject request bodies larger than 1 MB.
.layer(DefaultBodyLimit::max(1024 * 1024))
.with_state(pool)
.with_state(state)
}
/// `GET /health` — simple liveness probe, no auth required.
+5 -2
View File
@@ -16,7 +16,7 @@
//! |---------------|---------|-------------------------------|
//! | `SERVER_PORT` | `8080` | TCP port to listen on |
use solitaire_server::build_router;
use solitaire_server::{build_router, AppState};
use sqlx::SqlitePool;
use std::net::SocketAddr;
@@ -29,6 +29,8 @@ async fn main() {
tracing_subscriber::fmt::init();
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
// Load JWT_SECRET once at startup — a missing secret is a fatal configuration error.
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let port: u16 = std::env::var("SERVER_PORT")
.unwrap_or_else(|_| "8080".into())
.parse()
@@ -46,7 +48,8 @@ async fn main() {
tracing::info!("database ready at {db_url}");
let app = build_router(pool);
let state = AppState { pool, jwt_secret };
let app = build_router(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("listening on {addr}");
+7 -6
View File
@@ -5,7 +5,7 @@
//! can access it via `Extension<AuthenticatedUser>`.
use axum::{
extract::{FromRequestParts, Request},
extract::{FromRequestParts, Request, State},
http::request::Parts,
middleware::Next,
response::Response,
@@ -13,7 +13,7 @@ use axum::{
use jsonwebtoken::{decode, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::{error::AppError, AppState};
/// The claims encoded in our JWT access tokens.
#[derive(Debug, Serialize, Deserialize)]
@@ -37,18 +37,19 @@ pub struct AuthenticatedUser {
/// Axum middleware function that validates the Bearer JWT and injects
/// [`AuthenticatedUser`] into request extensions.
///
/// Reads the JWT secret from [`AppState`] rather than the environment, so a
/// missing secret causes a startup failure rather than a per-request 500.
///
/// Returns `401 Unauthorized` if the token is missing, expired, or invalid.
pub async fn require_auth(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, AppError> {
let secret = std::env::var("JWT_SECRET")
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
let token = extract_bearer_token(req.headers())
.ok_or(AppError::Unauthorized)?;
let claims = validate_access_token(&token, &secret)?;
let claims = validate_access_token(&token, &state.jwt_secret)?;
req.extensions_mut().insert(AuthenticatedUser {
user_id: claims.sub,
+124 -9
View File
@@ -11,7 +11,7 @@ use solitaire_sync::{
merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse,
};
use crate::{error::AppError, middleware::AuthenticatedUser};
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
// ---------------------------------------------------------------------------
// Database row helpers
@@ -99,10 +99,10 @@ async fn store_payload(
///
/// If the user has never pushed any data, returns a default payload.
pub async fn pull(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<SyncResponse>, AppError> {
let stored_payload = match load_sync_row(&pool, &user.user_id).await? {
let stored_payload = match load_sync_row(&state.pool, &user.user_id).await? {
Some(row) => row_to_payload(&row, &user.user_id)?,
None => {
// First pull — no server data yet; return an empty default payload.
@@ -134,7 +134,7 @@ pub async fn pull(
/// updated with the merged `best_single_score` and `fastest_win_seconds` so
/// scores stay in sync without a separate submission step.
pub async fn push(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
user: AuthenticatedUser,
Json(client_payload): Json<SyncPayload>,
) -> Result<Json<SyncResponse>, AppError> {
@@ -143,12 +143,12 @@ pub async fn push(
return Err(AppError::BadRequest("user_id mismatch".into()));
}
let server_payload = match load_sync_row(&pool, &user.user_id).await? {
let server_payload = match load_sync_row(&state.pool, &user.user_id).await? {
Some(row) => row_to_payload(&row, &user.user_id)?,
None => {
// First push — nothing to merge against; store directly.
store_payload(&pool, &user.user_id, &client_payload).await?;
update_leaderboard_if_opted_in(&pool, &user.user_id, &client_payload).await?;
store_payload(&state.pool, &user.user_id, &client_payload).await?;
update_leaderboard_if_opted_in(&state.pool, &user.user_id, &client_payload).await?;
return Ok(Json(SyncResponse {
merged: client_payload,
server_time: Utc::now(),
@@ -159,8 +159,8 @@ pub async fn push(
let (merged, conflicts) = merge(&client_payload, &server_payload);
store_payload(&pool, &user.user_id, &merged).await?;
update_leaderboard_if_opted_in(&pool, &user.user_id, &merged).await?;
store_payload(&state.pool, &user.user_id, &merged).await?;
update_leaderboard_if_opted_in(&state.pool, &user.user_id, &merged).await?;
Ok(Json(SyncResponse {
merged,
@@ -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");
}
}
+157 -46
View File
@@ -6,9 +6,10 @@
//!
//! # JWT secret
//!
//! Each test calls [`set_jwt_secret`] before touching any endpoint that reads
//! `JWT_SECRET` from the environment. This is safe because `cargo test` runs
//! integration-test binaries single-threaded by default.
//! [`build_test_router`] injects a fixed test secret into [`AppState`] so
//! tests do not need to set `JWT_SECRET` in the environment. The constant
//! [`TEST_SECRET`] must match the value used by [`build_test_router`] so that
//! test-side token decoding works correctly.
use axum::{
body::Body,
@@ -28,7 +29,9 @@ use tower::ServiceExt;
// Constants
// ---------------------------------------------------------------------------
/// The JWT secret injected into the environment for all tests.
/// JWT secret used by [`build_test_router`] and by test-side token decoding.
///
/// Must match the value hardcoded in [`solitaire_server::build_test_router`].
const TEST_SECRET: &str = "test_secret_32_chars_minimum_ok!";
// ---------------------------------------------------------------------------
@@ -53,15 +56,6 @@ async fn test_pool() -> SqlitePool {
pool
}
/// Inject `JWT_SECRET` into the process environment so all auth code can read it.
///
/// # Safety
/// Only called from test code where tests run sequentially in a single binary.
fn set_jwt_secret() {
// SAFETY: test-only; integration test binaries are single-threaded.
unsafe { std::env::set_var("JWT_SECRET", TEST_SECRET) };
}
/// Fake client IP injected by all test requests so `tower_governor`'s
/// `SmartIpKeyExtractor` can extract a key without a real peer address.
const TEST_CLIENT_IP: &str = "127.0.0.1";
@@ -202,7 +196,7 @@ fn make_payload(user_id_str: &str, games_played: u32) -> SyncPayload {
/// `POST /api/auth/register` must return 200 with both tokens.
#[tokio::test]
async fn register_creates_account_and_returns_tokens() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let resp = post_json(
@@ -227,7 +221,7 @@ async fn register_creates_account_and_returns_tokens() {
/// Registering the same username twice must return 409 Conflict on the second attempt.
#[tokio::test]
async fn register_duplicate_username_returns_conflict() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let creds = serde_json::json!({ "username": "bob", "password": "s3cr3t!!" });
@@ -247,7 +241,7 @@ async fn register_duplicate_username_returns_conflict() {
/// Short username (< 3 chars) is rejected with 400.
#[tokio::test]
async fn register_rejects_short_username() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let resp = post_json(
app,
@@ -261,7 +255,7 @@ async fn register_rejects_short_username() {
/// Username with disallowed characters is rejected with 400.
#[tokio::test]
async fn register_rejects_invalid_username_chars() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let resp = post_json(
app,
@@ -275,7 +269,7 @@ async fn register_rejects_invalid_username_chars() {
/// Password shorter than 8 characters is rejected with 400.
#[tokio::test]
async fn register_rejects_short_password() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let resp = post_json(
app,
@@ -289,7 +283,7 @@ async fn register_rejects_short_password() {
/// `POST /api/auth/login` with correct credentials returns 200 with both tokens.
#[tokio::test]
async fn login_with_correct_credentials_returns_tokens() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
// Register first.
@@ -312,7 +306,7 @@ async fn login_with_correct_credentials_returns_tokens() {
/// `POST /api/auth/login` with a wrong password must return 401.
#[tokio::test]
async fn login_with_wrong_password_returns_401() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
// Register a user.
@@ -336,7 +330,7 @@ async fn login_with_wrong_password_returns_401() {
/// `POST /api/auth/login` for a username that does not exist must return 401.
#[tokio::test]
async fn login_with_unknown_username_returns_401() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let resp = post_json(
@@ -356,7 +350,7 @@ async fn login_with_unknown_username_returns_401() {
/// `POST /api/auth/refresh` with a valid refresh token returns 200 with a new access token.
#[tokio::test]
async fn refresh_returns_new_access_token() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (_access, refresh) = register_user(app.clone(), "eve", "refresh_me").await;
@@ -380,7 +374,7 @@ async fn refresh_returns_new_access_token() {
/// the `kind` claim will be `"access"`, not `"refresh"`.
#[tokio::test]
async fn refresh_with_access_token_returns_401() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _refresh) = register_user(app.clone(), "frank", "bad_refresh").await;
@@ -407,7 +401,7 @@ async fn refresh_with_access_token_returns_401() {
/// Push a payload, then pull — the pulled data must reflect the pushed values.
#[tokio::test]
async fn push_then_pull_returns_pushed_data() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "grace", "sync_pass").await;
@@ -436,10 +430,127 @@ async fn push_then_pull_returns_pushed_data() {
assert_eq!(games_played, 7, "pulled games_played must match pushed value");
}
/// Full register → login → push → pull integration roundtrip.
///
/// This test drives every auth and sync endpoint in sequence to verify that
/// the complete happy-path flow works end-to-end with a fresh in-memory
/// database:
/// 1. Register a new user — extracts the access token from the response.
/// 2. Login with the same credentials — obtains a fresh access token from
/// the login endpoint (not reusing the registration token).
/// 3. Push a `SyncPayload` with known stats via `POST /api/sync/push`.
/// 4. Pull via `GET /api/sync/pull` and assert the pulled payload reflects
/// the pushed values.
#[tokio::test]
async fn register_login_push_pull_full_roundtrip() {
let app = build_test_router(test_pool().await);
// --- Step 1: Register ---
let reg_resp = post_json(
app.clone(),
"/api/auth/register",
serde_json::json!({ "username": "roundtrip_user", "password": "roundtrip_pass" }),
)
.await;
assert_eq!(
reg_resp.status(),
StatusCode::OK,
"registration must return 200"
);
let reg_body = body_json(reg_resp).await;
assert!(
reg_body["access_token"].is_string(),
"register must return an access_token"
);
// --- Step 2: Login (explicit — do not reuse the registration token) ---
let login_resp = post_json(
app.clone(),
"/api/auth/login",
serde_json::json!({ "username": "roundtrip_user", "password": "roundtrip_pass" }),
)
.await;
assert_eq!(
login_resp.status(),
StatusCode::OK,
"login must return 200"
);
let login_body = body_json(login_resp).await;
let access_token = login_body["access_token"]
.as_str()
.expect("login must return access_token")
.to_string();
// Decode the user UUID from the login JWT so we can construct the payload.
let user_id = decode_sub(&access_token);
// --- Step 3: Push a payload with known values ---
let payload = SyncPayload {
user_id: uuid::Uuid::parse_str(&user_id)
.expect("JWT sub must be a valid UUID"),
stats: StatsSnapshot {
games_played: 42,
games_won: 17,
best_single_score: 4_200,
fastest_win_seconds: 95,
..StatsSnapshot::default()
},
achievements: vec![],
progress: PlayerProgress::default(),
last_modified: chrono::Utc::now(),
};
let push_resp = post_authed(
app.clone(),
"/api/sync/push",
&access_token,
serde_json::to_value(&payload).expect("SyncPayload must serialise"),
)
.await;
assert_eq!(
push_resp.status(),
StatusCode::OK,
"push must return 200"
);
// --- Step 4: Pull and verify the stored data matches what was pushed ---
let pull_resp = get_authed(app, "/api/sync/pull", &access_token).await;
assert_eq!(
pull_resp.status(),
StatusCode::OK,
"pull must return 200"
);
let pull_body = body_json(pull_resp).await;
let merged = &pull_body["merged"];
assert_eq!(
merged["stats"]["games_played"].as_u64(),
Some(42),
"pulled games_played must match the pushed value"
);
assert_eq!(
merged["stats"]["games_won"].as_u64(),
Some(17),
"pulled games_won must match the pushed value"
);
assert_eq!(
merged["stats"]["best_single_score"].as_u64(),
Some(4_200),
"pulled best_single_score must match the pushed value"
);
assert_eq!(
merged["stats"]["fastest_win_seconds"].as_u64(),
Some(95),
"pulled fastest_win_seconds must match the pushed value"
);
}
/// Pushing a payload whose `user_id` does not match the JWT `sub` must return 400.
#[tokio::test]
async fn push_with_wrong_user_id_returns_400() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "heidi", "sync_pass").await;
@@ -472,7 +583,7 @@ async fn push_with_wrong_user_id_returns_400() {
/// A pull before any push returns a default empty payload (200, not 404).
#[tokio::test]
async fn pull_before_push_returns_default_payload() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "ivan", "nopush!!").await;
@@ -490,7 +601,7 @@ async fn pull_before_push_returns_default_payload() {
/// Accessing `/api/sync/pull` without a token must return 401.
#[tokio::test]
async fn pull_without_token_returns_401() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let req = Request::builder()
@@ -517,7 +628,7 @@ async fn pull_without_token_returns_401() {
/// return 200.
#[tokio::test]
async fn delete_account_succeeds_and_data_is_gone() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "judy", "delete_me").await;
@@ -570,7 +681,7 @@ async fn delete_account_succeeds_and_data_is_gone() {
#[tokio::test]
async fn health_returns_ok() {
// No JWT needed; set it anyway for consistency.
set_jwt_secret();
let app = build_test_router(test_pool().await);
let req = Request::builder()
@@ -596,7 +707,7 @@ async fn health_returns_ok() {
/// `GET /api/daily-challenge` must return 200 with today's UTC date.
#[tokio::test]
async fn daily_challenge_returns_goal_for_today() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let today = Utc::now().format("%Y-%m-%d").to_string();
@@ -625,7 +736,7 @@ async fn daily_challenge_returns_goal_for_today() {
/// Calling `GET /api/daily-challenge` twice returns the same seed (deterministic).
#[tokio::test]
async fn daily_challenge_is_deterministic() {
set_jwt_secret();
// Use the same pool so the second call hits the stored row.
let pool = test_pool().await;
@@ -668,7 +779,7 @@ async fn daily_challenge_is_deterministic() {
/// `GET /api/leaderboard` requires authentication — no token returns 401.
#[tokio::test]
async fn leaderboard_without_token_returns_401() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let req = Request::builder()
@@ -688,7 +799,7 @@ async fn leaderboard_without_token_returns_401() {
/// Opting in and then fetching the leaderboard returns the opted-in entry.
#[tokio::test]
async fn opt_in_then_leaderboard_shows_entry() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "karen", "leaderpass").await;
@@ -722,7 +833,7 @@ async fn opt_in_then_leaderboard_shows_entry() {
/// Pushing sync data after opting in updates the leaderboard best_score.
#[tokio::test]
async fn push_after_opt_in_updates_leaderboard_score() {
set_jwt_secret();
let pool = test_pool().await;
let app = build_test_router(pool);
@@ -774,7 +885,7 @@ async fn push_after_opt_in_updates_leaderboard_score() {
/// Pushing a lower score after a higher one does not overwrite the best.
#[tokio::test]
async fn push_lower_score_does_not_overwrite_leaderboard_best() {
set_jwt_secret();
let pool = test_pool().await;
let app = build_test_router(pool);
@@ -822,7 +933,7 @@ async fn push_lower_score_does_not_overwrite_leaderboard_best() {
/// Opting out hides the player from the leaderboard; opting back in restores them.
#[tokio::test]
async fn opt_out_hides_then_opt_in_restores() {
set_jwt_secret();
let pool = test_pool().await;
let app = build_test_router(pool);
@@ -877,7 +988,7 @@ async fn opt_out_hides_then_opt_in_restores() {
/// Opting in with an empty display name returns 400.
#[tokio::test]
async fn opt_in_empty_display_name_returns_400() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "empty_name", "pass1234").await;
@@ -898,7 +1009,7 @@ async fn opt_in_empty_display_name_returns_400() {
/// Opting in with a display name longer than 32 characters returns 400.
#[tokio::test]
async fn opt_in_too_long_display_name_returns_400() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "long_name", "pass1234").await;
@@ -920,7 +1031,7 @@ async fn opt_in_too_long_display_name_returns_400() {
/// Exactly 32 ASCII characters is accepted.
#[tokio::test]
async fn opt_in_exactly_32_char_display_name_succeeds() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "maxname", "pass1234").await;
@@ -943,7 +1054,7 @@ async fn opt_in_exactly_32_char_display_name_succeeds() {
/// accepted — the limit is character count, not byte count.
#[tokio::test]
async fn opt_in_32_unicode_chars_display_name_succeeds() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "unicode_name", "pass1234").await;
@@ -967,7 +1078,7 @@ async fn opt_in_32_unicode_chars_display_name_succeeds() {
/// A display name with 33 Unicode emoji is rejected.
#[tokio::test]
async fn opt_in_33_unicode_chars_display_name_returns_400() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "unicode_long", "pass1234").await;
@@ -990,7 +1101,7 @@ async fn opt_in_33_unicode_chars_display_name_returns_400() {
/// the server merges (max wins) rather than blindly replacing.
#[tokio::test]
async fn second_push_with_lower_stats_preserves_higher_stored_values() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "merge_test", "merge_pass").await;
@@ -1033,7 +1144,7 @@ async fn second_push_with_lower_stats_preserves_higher_stored_values() {
/// Login with leading/trailing whitespace in the username still succeeds.
#[tokio::test]
async fn login_trims_whitespace_from_username() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let _ = register_user(app.clone(), "trimtest", "password1!").await;
@@ -1060,7 +1171,7 @@ async fn login_trims_whitespace_from_username() {
/// `POST /api/sync/push` with a body exceeding the 1 MB limit must return 413.
#[tokio::test]
async fn push_oversized_body_returns_413() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "sizetest", "password1!").await;
@@ -1090,7 +1201,7 @@ async fn push_oversized_body_returns_413() {
/// A JWT whose `exp` is in the past must be rejected with 401 on protected routes.
#[tokio::test]
async fn expired_access_token_returns_401() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
// Craft a token that expired 2 hours ago — well past jsonwebtoken's 60 s leeway.
@@ -1123,7 +1234,7 @@ async fn expired_access_token_returns_401() {
/// A refresh token must be rejected when used as a Bearer token on protected routes.
#[tokio::test]
async fn refresh_token_rejected_on_protected_routes() {
set_jwt_secret();
let app = build_test_router(test_pool().await);
let (_, refresh) = register_user(app.clone(), "kindtest", "password1!").await;
+1
View File
@@ -1,6 +1,7 @@
[package]
name = "solitaire_sync"
version.workspace = true
license.workspace = true
edition.workspace = true
[dependencies]
+89
View File
@@ -643,6 +643,95 @@ mod tests {
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
}
// -----------------------------------------------------------------------
// ConflictReport field population
// -----------------------------------------------------------------------
#[test]
fn conflict_report_win_streak_current_contains_correct_field_and_values() {
// Verify that the ConflictReport for win_streak_current carries the exact
// field name and the string representations of the diverging values.
let mut local = default_payload();
local.stats.win_streak_current = 7;
let mut remote = default_payload();
remote.stats.win_streak_current = 2;
let (_, conflicts) = merge(&local, &remote);
let report = conflicts
.iter()
.find(|c| c.field == "win_streak_current")
.expect("ConflictReport for win_streak_current must be present");
assert_eq!(
report.local_value, "7",
"local_value in ConflictReport must be the local streak as a string"
);
assert_eq!(
report.remote_value, "2",
"remote_value in ConflictReport must be the remote streak as a string"
);
}
#[test]
fn conflict_report_daily_challenge_streak_contains_correct_field_and_values() {
// daily_challenge_streak divergence must also produce a ConflictReport with
// the correct field name and human-readable values.
let mut local = default_payload();
local.progress.daily_challenge_streak = 10;
let mut remote = default_payload();
remote.progress.daily_challenge_streak = 4;
let (merged, conflicts) = merge(&local, &remote);
let report = conflicts
.iter()
.find(|c| c.field == "daily_challenge_streak")
.expect("ConflictReport for daily_challenge_streak must be present");
assert_eq!(
report.local_value, "10",
"local_value must equal the local streak string"
);
assert_eq!(
report.remote_value, "4",
"remote_value must equal the remote streak string"
);
// Best-effort resolution: the higher value is retained.
assert_eq!(
merged.progress.daily_challenge_streak, 10,
"merged streak must take the higher value"
);
}
#[test]
fn no_conflict_report_when_win_streak_current_is_equal() {
// Identical win_streak_current must not generate any ConflictReport.
let mut local = default_payload();
local.stats.win_streak_current = 5;
let mut remote = default_payload();
remote.stats.win_streak_current = 5;
let (_, conflicts) = merge(&local, &remote);
assert!(
!conflicts.iter().any(|c| c.field == "win_streak_current"),
"equal streaks must produce no conflict"
);
}
#[test]
fn no_conflict_report_when_daily_challenge_streak_is_equal() {
// Identical daily_challenge_streak must not generate any ConflictReport.
let mut local = default_payload();
local.progress.daily_challenge_streak = 3;
let mut remote = default_payload();
remote.progress.daily_challenge_streak = 3;
let (_, conflicts) = merge(&local, &remote);
assert!(
!conflicts.iter().any(|c| c.field == "daily_challenge_streak"),
"equal daily challenge streaks must produce no conflict"
);
}
#[test]
fn fastest_win_both_max_sentinel_stays_max() {
// Both sides have u64::MAX (no wins recorded on either) — result must remain MAX,