Compare commits

...

15 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
funman300 71c0c273a1 chore(deps): migrate kira 0.9 → 0.12
- Import paths simplified: manager/tween modules re-exported from kira root
  (AudioManager, AudioManagerSettings, DefaultBackend, Tween all via kira::*)
- Volume::Amplitude removed; replaced with Value<Decibels> using a new
  amplitude_to_decibels() helper (20*log10 conversion, clamps to SILENCE)
- output_destination field removed from StaticSoundSettings; sounds routed
  to sub-tracks by calling TrackHandle::play() directly instead of
  AudioManager::play()
- set_volume() now accepts f32 (Decibels) not f64
- start_ambient_loop signature updated to take &mut Option<TrackHandle>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:54:01 -07:00
funman300 21d0c289b5 chore(deps): migrate to Bevy 0.18
- BorderRadius is no longer a Component; moved into Node.border_radius
  field at all 15 spawn sites across 6 plugin files
- Events<T> renamed to Messages<T> in test code (12 files)
- KeyboardEvents SystemParam renamed to KeyboardMessages to match the
  MessageWriter rename done in the 0.17 hop
- WindowResolution::from((f32,f32)) removed; use (u32,u32) tuple in main.rs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:48:41 -07:00
funman300 648cd44387 chore(deps): migrate to Bevy 0.17
- Event/EventReader/EventWriter renamed to Message/MessageReader/MessageWriter
- add_event → add_message for all 67 call sites
- ScrollPosition changed to tuple struct ScrollPosition(Vec2)
- CursorIcon import moved from bevy::winit::cursor to bevy::window
- WindowResolution::from((f32,f32)) removed — use (u32,u32) tuple
- World::send_event → World::write_message in test code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:04:44 -07:00
funman300 c8553dc8c5 chore(deps): migrate to Bevy 0.16, axum 0.8, and other package updates
- Bump bevy 0.15 → 0.16; fixes all breaking API changes:
  ChildBuilder → ChildSpawnerCommands, Parent → ChildOf,
  despawn_descendants → despawn_related::<Children>(),
  despawn_recursive → despawn (now recursive by default),
  EventWriter::send → write, Query::{get_single,get_single_mut}
  → {single,single_mut}, ChildOf::get → parent()
- Bump axum 0.7 → 0.8; remove axum::async_trait from FromRequestParts
- Bump tower_governor 0.4 → 0.8; fix GovernorLayer::new() API
- Bump jsonwebtoken 9 → 10 with rust_crypto feature only
- Bump thiserror 1 → 2, dirs 5 → 6, bcrypt 0.15 → 0.19,
  reqwest 0.12 → 0.13 (rustls feature rename)
- Regenerate .sqlx offline cache for sqlx compile-time query checks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:31:12 -07:00
funman300 eedddb979e feat(engine): add curve-based card animation module
Introduces solitaire_engine::card_animation — a drop-in upgrade over the
existing linear CardAnim. Supports MotionCurve easing, parabolic z-lift,
scale interpolation, delay, retargeting mid-flight, and per-card timing
variation. Coexists with the legacy AnimationPlugin during migration.

Also adds .claude/ to .gitignore so Claude Code local tooling is never
committed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 18:06:58 +00:00
funman300 59a023ed5e chore(workspace): fix all clippy warnings in test code
Resolves 15 violations found by `cargo clippy --workspace --tests -D warnings`:
- Remove unused imports (Card, Rank) in cursor_plugin tests
- Replace absurd i32::MAX comparison with a meaningful >= 0 check
- Use range .contains() instead of manual >= && <= (manual_range_contains)
- Move impl FromRequestParts before test module in middleware.rs (items_after_test_module)
- Move _VEC3_REFERENCED const before test module in input_plugin.rs
- Convert runtime assert on constant to const { assert!(...) }
- Use .contains() instead of .iter().any() for slice membership
- Replace .get(...).is_none() with !.contains_key(...) in HashMap checks
- Collapse Default::default() + field assignment into struct literal initializers
  across solitaire_sync, solitaire_data, and solitaire_engine test helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 18:02:27 +00:00
funman300 8cd28cfb29 feat(engine): right-click highlight timer and visual hint glow (#5, #6)
Task #5: Add RightClickHighlightTimer(1.5 s) so destination highlights
auto-despawn after 1.5 s. Existing clear-on-state-change and
clear-on-pause logic still fires early when a move is made or the game
is paused. Three unit tests cover timer countdown behaviour.

Task #6: Add HintVisualEvent emitted on H key. Source card gets
HintHighlight + HintHighlightTimer(2 s) for a yellow glow. Destination
PileMarker gets HintPileHighlight with a gold tint (Color::srgb(1.0,
0.85, 0.1)) that restores the original colour when the 2 s timer
expires. Five unit tests cover timer expiry and colour invariants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 17:36:23 +00:00
80 changed files with 7092 additions and 1899 deletions
+1
View File
@@ -5,3 +5,4 @@
.env
*.tmp
data/
.claude/
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "36bab3ca8f126c66a6f4865d2699702689518bd3a796c374f932aba0434a4c94"
}
+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.
Generated
+2134 -922
View File
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -14,33 +14,34 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "0.1.0"
license = "MIT"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "1"
thiserror = "2"
rand = "0.8"
async-trait = "0.1"
tokio = { version = "1", features = ["full"] }
dirs = "5"
dirs = "6"
keyring = "2"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" }
bevy = "0.15"
kira = "0.9"
bevy = "0.18"
kira = "0.12"
axum = "0.7"
axum = "0.8"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
jsonwebtoken = "9"
bcrypt = "0.15"
tower_governor = "0.4"
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
bcrypt = "0.19"
tower_governor = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
+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]]
+14 -6
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() {
@@ -21,7 +21,12 @@ fn main() {
DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Solitaire Quest".into(),
resolution: (1280.0, 800.0).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 -1
View File
@@ -91,6 +91,6 @@ mod tests {
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
// Very short elapsed time would overflow without the .min() guard.
let bonus = compute_time_bonus(1);
assert!(bonus <= i32::MAX, "time bonus must fit in i32");
assert!(bonus >= 0, "time bonus must be non-negative after u64→i32 cast");
}
}
+1
View File
@@ -1,6 +1,7 @@
[package]
name = "solitaire_data"
version.workspace = true
license.workspace = true
edition.workspace = true
[dependencies]
+1 -2
View File
@@ -148,8 +148,7 @@ mod tests {
#[test]
fn add_xp_saturates_on_overflow() {
let mut p = PlayerProgress::default();
p.total_xp = u64::MAX - 5;
let mut p = PlayerProgress { total_xp: u64::MAX - 5, ..Default::default() };
p.add_xp(100);
assert_eq!(p.total_xp, u64::MAX);
}
+4 -10
View File
@@ -207,8 +207,7 @@ mod tests {
#[test]
fn adjust_sfx_volume_clamps() {
let mut s = Settings::default();
s.sfx_volume = 0.5;
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6);
assert!((s.adjust_sfx_volume(0.5) - 1.0).abs() < 1e-6);
assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6);
@@ -217,8 +216,7 @@ mod tests {
#[test]
fn adjust_music_volume_clamps() {
let mut s = Settings::default();
s.music_volume = 0.5;
let mut s = Settings { music_volume: 0.5, ..Default::default() };
assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6);
assert!((s.adjust_music_volume(0.5) - 1.0).abs() < 1e-6);
assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6);
@@ -241,14 +239,10 @@ mod tests {
#[test]
fn sanitized_clamps_music_volume() {
let mut s = Settings::default();
s.music_volume = 2.0;
let s = s.sanitized();
let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized();
assert_eq!(s.music_volume, 1.0);
let mut s2 = Settings::default();
s2.music_volume = -0.5;
let s2 = s2.sanitized();
let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized();
assert_eq!(s2.music_volume, 0.0);
}
+2 -3
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);
}
@@ -173,8 +173,7 @@ mod tests {
#[test]
fn lifetime_score_saturates_at_u64_max() {
let mut s = StatsSnapshot::default();
s.lifetime_score = u64::MAX - 100;
let mut s = StatsSnapshot { lifetime_score: u64::MAX - 100, ..Default::default() };
s.update_on_win(200, 60, &DrawMode::DrawOne);
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
}
+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]
+23 -23
View File
@@ -70,9 +70,9 @@ impl Plugin for AchievementPlugin {
app.insert_resource(AchievementsResource(records))
.insert_resource(AchievementsStoragePath(self.storage_path.clone()))
.add_event::<AchievementUnlockedEvent>()
.add_event::<GameWonEvent>()
.add_event::<XpAwardedEvent>()
.add_message::<AchievementUnlockedEvent>()
.add_message::<GameWonEvent>()
.add_message::<XpAwardedEvent>()
// Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
// (so daily_challenge_streak is up to date for daily_devotee).
@@ -89,10 +89,10 @@ impl Plugin for AchievementPlugin {
#[allow(clippy::too_many_arguments)]
fn evaluate_on_win(
mut wins: EventReader<GameWonEvent>,
mut unlocks: EventWriter<AchievementUnlockedEvent>,
mut levelups: EventWriter<LevelUpEvent>,
mut xp_awarded: EventWriter<XpAwardedEvent>,
mut wins: MessageReader<GameWonEvent>,
mut unlocks: MessageWriter<AchievementUnlockedEvent>,
mut levelups: MessageWriter<LevelUpEvent>,
mut xp_awarded: MessageWriter<XpAwardedEvent>,
game: Res<GameStateResource>,
stats: Res<StatsResource>,
path: Res<AchievementsStoragePath>,
@@ -156,10 +156,10 @@ fn evaluate_on_win(
}
}
Reward::BonusXp(amount) => {
xp_awarded.send(XpAwardedEvent { amount });
xp_awarded.write(XpAwardedEvent { amount });
let prev_level = progress.0.add_xp(amount);
if progress.0.leveled_up_from(prev_level) {
levelups.send(LevelUpEvent {
levelups.write(LevelUpEvent {
previous_level: prev_level,
new_level: progress.0.level,
total_xp: progress.0.total_xp,
@@ -173,7 +173,7 @@ fn evaluate_on_win(
record.reward_granted = true;
}
unlocks.send(AchievementUnlockedEvent(record.clone()));
unlocks.write(AchievementUnlockedEvent(record.clone()));
}
if achievements_changed {
@@ -211,8 +211,8 @@ fn toggle_achievements_screen(
if !keys.just_pressed(KeyCode::KeyA) {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_achievements_screen(&mut commands, &achievements.0);
}
@@ -248,10 +248,10 @@ fn spawn_achievements_screen(commands: &mut Commands, records: &[AchievementReco
min_width: Val::Px(380.0),
max_height: Val::Percent(80.0),
overflow: Overflow::clip_y(),
border_radius: BorderRadius::all(Val::Px(8.0)),
..default()
},
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
BorderRadius::all(Val::Px(8.0)),
))
.with_children(|card| {
// Header
@@ -398,7 +398,7 @@ mod tests {
// StatsPlugin runs update_stats_on_win first (after GameMutation); that
// bumps games_won to 1 before evaluate_on_win reads StatsResource.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
@@ -415,7 +415,7 @@ mod tests {
assert!(unlocked_first_win);
// Verify the event was emitted.
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
assert!(fired.contains(&"first_win".to_string()));
@@ -425,7 +425,7 @@ mod tests {
fn repeated_win_does_not_refire_already_unlocked_achievement() {
let mut app = headless_app();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
@@ -433,16 +433,16 @@ mod tests {
// Clear events from first win.
app.world_mut()
.resource_mut::<Events<AchievementUnlockedEvent>>()
.resource_mut::<Messages<AchievementUnlockedEvent>>()
.clear();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
assert!(
@@ -462,13 +462,13 @@ mod tests {
let mut app = headless_app();
// "no_undo" achievement awards BonusXp(25). Trigger it by sending a
// GameWonEvent with undo_count == 0 (default) and enough stats to match.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let events = app.world().resource::<Events<XpAwardedEvent>>();
let events = app.world().resource::<Messages<XpAwardedEvent>>();
let mut cursor = events.get_cursor();
let xp_events: Vec<u64> = cursor.read(events).map(|e| e.amount).collect();
// The no_undo achievement (BonusXp 25) must have fired an XpAwardedEvent.
@@ -487,14 +487,14 @@ mod tests {
.0
.undo_count = 1;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
// "no_undo" awards BonusXp(25). If undo was used it must NOT fire.
let events = app.world().resource::<Events<XpAwardedEvent>>();
let events = app.world().resource::<Messages<XpAwardedEvent>>();
let mut cursor = events.get_cursor();
let xp_events: Vec<u64> = cursor.read(events).map(|e| e.amount).collect();
assert!(
+33 -33
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 {
@@ -156,18 +157,18 @@ impl Plugin for AnimationPlugin {
// Register the events this plugin consumes so tests that don't include
// GamePlugin can still run AnimationPlugin in isolation. Double-registration
// is idempotent in Bevy.
app.add_event::<GameWonEvent>()
.add_event::<AchievementUnlockedEvent>()
.add_event::<LevelUpEvent>()
.add_event::<DailyChallengeCompletedEvent>()
.add_event::<DailyGoalAnnouncementEvent>()
.add_event::<WeeklyGoalCompletedEvent>()
.add_event::<TimeAttackEndedEvent>()
.add_event::<ChallengeAdvancedEvent>()
.add_event::<SettingsChangedEvent>()
.add_event::<NewGameConfirmEvent>()
.add_event::<InfoToastEvent>()
.add_event::<XpAwardedEvent>()
app.add_message::<GameWonEvent>()
.add_message::<AchievementUnlockedEvent>()
.add_message::<LevelUpEvent>()
.add_message::<DailyChallengeCompletedEvent>()
.add_message::<DailyGoalAnnouncementEvent>()
.add_message::<WeeklyGoalCompletedEvent>()
.add_message::<TimeAttackEndedEvent>()
.add_message::<ChallengeAdvancedEvent>()
.add_message::<SettingsChangedEvent>()
.add_message::<NewGameConfirmEvent>()
.add_message::<InfoToastEvent>()
.add_message::<XpAwardedEvent>()
.init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>()
.init_resource::<ActiveToast>()
@@ -207,7 +208,7 @@ fn init_slide_duration(
}
fn sync_slide_duration(
mut events: EventReader<SettingsChangedEvent>,
mut events: MessageReader<SettingsChangedEvent>,
mut dur: ResMut<EffectiveSlideDuration>,
) {
for ev in events.read() {
@@ -245,7 +246,7 @@ fn advance_card_anims(
fn handle_win_cascade(
mut commands: Commands,
mut events: EventReader<GameWonEvent>,
mut events: MessageReader<GameWonEvent>,
cards: Query<(Entity, &Transform), With<CardEntity>>,
layout: Option<Res<LayoutResource>>,
settings: Option<Res<SettingsResource>>,
@@ -290,7 +291,7 @@ fn handle_win_cascade(
fn handle_achievement_toast(
mut commands: Commands,
mut events: EventReader<AchievementUnlockedEvent>,
mut events: MessageReader<AchievementUnlockedEvent>,
) {
for ev in events.read() {
spawn_toast(
@@ -301,7 +302,7 @@ fn handle_achievement_toast(
}
}
fn handle_levelup_toast(mut commands: Commands, mut events: EventReader<LevelUpEvent>) {
fn handle_levelup_toast(mut commands: Commands, mut events: MessageReader<LevelUpEvent>) {
for ev in events.read() {
spawn_toast(
&mut commands,
@@ -313,7 +314,7 @@ fn handle_levelup_toast(mut commands: Commands, mut events: EventReader<LevelUpE
fn handle_daily_goal_announcement_toast(
mut commands: Commands,
mut events: EventReader<DailyGoalAnnouncementEvent>,
mut events: MessageReader<DailyGoalAnnouncementEvent>,
) {
for ev in events.read() {
spawn_toast(&mut commands, format!("Goal: {}", ev.0), DAILY_TOAST_SECS);
@@ -322,7 +323,7 @@ fn handle_daily_goal_announcement_toast(
fn handle_daily_toast(
mut commands: Commands,
mut events: EventReader<DailyChallengeCompletedEvent>,
mut events: MessageReader<DailyChallengeCompletedEvent>,
) {
for ev in events.read() {
spawn_toast(
@@ -335,7 +336,7 @@ fn handle_daily_toast(
fn handle_weekly_toast(
mut commands: Commands,
mut events: EventReader<WeeklyGoalCompletedEvent>,
mut events: MessageReader<WeeklyGoalCompletedEvent>,
) {
for ev in events.read() {
spawn_toast(
@@ -348,7 +349,7 @@ fn handle_weekly_toast(
fn handle_time_attack_toast(
mut commands: Commands,
mut events: EventReader<TimeAttackEndedEvent>,
mut events: MessageReader<TimeAttackEndedEvent>,
) {
for ev in events.read() {
spawn_toast(
@@ -361,7 +362,7 @@ fn handle_time_attack_toast(
fn handle_challenge_toast(
mut commands: Commands,
mut events: EventReader<ChallengeAdvancedEvent>,
mut events: MessageReader<ChallengeAdvancedEvent>,
) {
for ev in events.read() {
spawn_toast(
@@ -374,7 +375,7 @@ fn handle_challenge_toast(
fn handle_settings_toast(
mut commands: Commands,
mut events: EventReader<SettingsChangedEvent>,
mut events: MessageReader<SettingsChangedEvent>,
mut last_sfx: Local<Option<f32>>,
mut last_music: Local<Option<f32>>,
) {
@@ -417,7 +418,7 @@ fn handle_auto_complete_toast(
fn handle_new_game_confirm_toast(
mut commands: Commands,
mut events: EventReader<NewGameConfirmEvent>,
mut events: MessageReader<NewGameConfirmEvent>,
) {
for _ in events.read() {
spawn_toast(&mut commands, "Press N again to start a new game".to_string(), 3.0);
@@ -430,7 +431,7 @@ fn handle_new_game_confirm_toast(
/// decouples event production from rendering so multiple simultaneous events do
/// not cause overlapping toast text on screen.
fn enqueue_toasts(
mut events: EventReader<InfoToastEvent>,
mut events: MessageReader<InfoToastEvent>,
mut queue: ResMut<ToastQueue>,
) {
for ev in events.read() {
@@ -465,7 +466,7 @@ fn drive_toast_display(
active.timer -= dt;
if active.timer <= 0.0 {
// Despawn the toast entity and clear the active slot.
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
active.entity = None;
active.timer = 0.0;
}
@@ -509,7 +510,7 @@ fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
.id()
}
fn handle_xp_awarded_toast(mut commands: Commands, mut events: EventReader<XpAwardedEvent>) {
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
for ev in events.read() {
spawn_toast(&mut commands, format!("+{} XP", ev.amount), 3.0);
}
@@ -532,7 +533,7 @@ fn tick_toasts(
for (entity, mut timer) in &mut toasts {
timer.0 -= dt;
if timer.0 <= 0.0 {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
}
}
@@ -709,7 +710,7 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.world_mut().send_event(InfoToastEvent("hello".to_string()));
app.world_mut().write_message(InfoToastEvent("hello".to_string()));
app.update();
let count = app
@@ -745,7 +746,7 @@ mod tests {
fn toast_queue_enqueues_on_event() {
let mut app = queue_app();
app.world_mut()
.send_event(InfoToastEvent("test message".to_string()));
.write_message(InfoToastEvent("test message".to_string()));
app.update();
// After one update the message should have been consumed (shown) or is
// still in the queue — either way we verify the system processed it by
@@ -775,9 +776,8 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
let mut fast_settings = Settings::default();
fast_settings.animation_speed = AnimSpeed::Fast;
app.world_mut().send_event(SettingsChangedEvent(fast_settings));
let fast_settings = Settings { animation_speed: AnimSpeed::Fast, ..Default::default() };
app.world_mut().write_message(SettingsChangedEvent(fast_settings));
app.update();
let dur = app.world().resource::<EffectiveSlideDuration>().slide_secs;
@@ -796,7 +796,7 @@ mod tests {
assert_eq!(before, 0, "no animations before win");
app.world_mut()
.send_event(GameWonEvent { score: 500, time_seconds: 60 });
.write_message(GameWonEvent { score: 500, time_seconds: 60 });
app.update();
let after = app
+72 -56
View File
@@ -23,13 +23,10 @@
use std::io::Cursor;
use bevy::prelude::*;
use kira::manager::backend::DefaultBackend;
use kira::manager::{AudioManager, AudioManagerSettings};
use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
use kira::sound::Region;
use kira::track::{TrackBuilder, TrackHandle};
use kira::tween::Tween;
use kira::Volume;
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
use crate::events::{
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
@@ -46,6 +43,16 @@ const RECYCLE_VOLUME: f64 = 0.5;
/// Volume amplitude for the ambient music loop placeholder.
const AMBIENT_VOLUME: f64 = 0.05;
/// Converts a linear amplitude (0.01.0+) to the `Decibels` type used by
/// kira 0.12. Clamps to `Decibels::SILENCE` for non-positive amplitudes.
fn amplitude_to_decibels(amplitude: f32) -> Decibels {
if amplitude <= 0.0 {
Decibels::SILENCE
} else {
Decibels(20.0 * amplitude.log10())
}
}
/// Returns `true` when a `DrawRequestEvent` will recycle the waste pile back
/// to stock rather than drawing a new card.
///
@@ -56,7 +63,7 @@ fn is_recycle(stock_len: usize) -> bool {
}
/// Pre-decoded sound effects. Cheap to clone (frames are an `Arc<[Frame]>`),
/// so we hand a fresh handle to `manager.play()` on every event.
/// so we hand a fresh handle to `track.play()` on every event.
#[derive(Resource, Clone)]
pub struct SoundLibrary {
pub deal: StaticSoundData,
@@ -90,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 {
@@ -104,7 +112,7 @@ impl Plugin for AudioPlugin {
warn!("failed to decode embedded SFX assets; SFX disabled");
}
let (sfx_track, music_track) = match manager.as_mut() {
let (sfx_track, mut music_track) = match manager.as_mut() {
Some(mgr) => {
let sfx = mgr.add_sub_track(TrackBuilder::default()).ok();
let music = mgr.add_sub_track(TrackBuilder::default()).ok();
@@ -116,7 +124,7 @@ impl Plugin for AudioPlugin {
// Start the ambient loop placeholder (card_flip.wav looped at very low
// volume through music_track).
let ambient_handle =
start_ambient_loop(manager.as_mut(), library.as_ref(), &music_track);
start_ambient_loop(manager.as_mut(), library.as_ref(), &mut music_track);
app.insert_non_send_resource(AudioState {
manager,
@@ -130,15 +138,15 @@ impl Plugin for AudioPlugin {
app.insert_resource(lib);
}
app.add_event::<DrawRequestEvent>()
.add_event::<MoveRequestEvent>()
.add_event::<MoveRejectedEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<GameWonEvent>()
.add_event::<CardFlippedEvent>()
.add_event::<CardFaceRevealedEvent>()
.add_event::<UndoRequestEvent>()
.add_event::<SettingsChangedEvent>()
app.add_message::<DrawRequestEvent>()
.add_message::<MoveRequestEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<GameWonEvent>()
.add_message::<CardFlippedEvent>()
.add_message::<CardFaceRevealedEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<SettingsChangedEvent>()
.add_systems(Startup, apply_initial_volume)
.add_systems(
Update,
@@ -190,20 +198,22 @@ fn decode(bytes: &'static [u8]) -> Option<StaticSoundData> {
fn start_ambient_loop(
manager: Option<&mut AudioManager<DefaultBackend>>,
library: Option<&SoundLibrary>,
music_track: &Option<TrackHandle>,
music_track: &mut Option<TrackHandle>,
) -> Option<StaticSoundHandle> {
let manager = manager?;
let lib = library?;
let mut data = lib.flip.clone();
// Loop the entire file from start to end.
data.settings.loop_region = Some(Region::default());
data.settings.volume = Volume::Amplitude(AMBIENT_VOLUME).into();
if let Some(track) = music_track {
data.settings.output_destination = track.id().into();
}
data.settings.volume = Value::Fixed(amplitude_to_decibels(AMBIENT_VOLUME as f32));
match manager.play(data) {
let result = if let Some(track) = music_track.as_mut() {
track.play(data)
} else {
manager.play(data)
};
match result {
Ok(handle) => Some(handle),
Err(e) => {
warn!("failed to start ambient loop: {e}");
@@ -213,16 +223,17 @@ fn start_ambient_loop(
}
fn play(audio: &mut AudioState, sound: &StaticSoundData) {
let Some(manager) = audio.manager.as_mut() else {
return;
};
let data = sound.clone();
// Route SFX through the dedicated sfx_track so its volume is independent
// of the music_track volume.
let mut data = sound.clone();
if let Some(track) = &audio.sfx_track {
data.settings.output_destination = track.id().into();
}
if let Err(e) = manager.play(data) {
let result = if let Some(track) = audio.sfx_track.as_mut() {
track.play(data)
} else if let Some(manager) = audio.manager.as_mut() {
manager.play(data)
} else {
return;
};
if let Err(e) = result {
warn!("failed to play SFX: {e}");
}
}
@@ -234,15 +245,17 @@ impl AudioState {
/// explicit volume override so callers can play sounds at a fraction of their
/// normal level. Silently does nothing when audio is unavailable.
pub fn play_sfx_at_volume(&mut self, sound: &StaticSoundData, volume: f64) {
let Some(manager) = self.manager.as_mut() else {
let mut data = sound.clone();
data.settings.volume = Value::Fixed(amplitude_to_decibels(volume as f32));
let result = if let Some(track) = self.sfx_track.as_mut() {
track.play(data)
} else if let Some(manager) = self.manager.as_mut() {
manager.play(data)
} else {
return;
};
let mut data = sound.clone();
data.settings.volume = Volume::Amplitude(volume).into();
if let Some(track) = &self.sfx_track {
data.settings.output_destination = track.id().into();
}
if let Err(e) = manager.play(data) {
if let Err(e) = result {
warn!("failed to play SFX at volume {volume}: {e}");
}
}
@@ -250,13 +263,13 @@ impl AudioState {
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
if let Some(track) = audio.sfx_track.as_mut() {
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
track.set_volume(amplitude_to_decibels(volume.clamp(0.0, 1.0)), Tween::default());
}
}
fn set_music_volume(audio: &mut AudioState, volume: f32) {
if let Some(track) = audio.music_track.as_mut() {
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
track.set_volume(amplitude_to_decibels(volume.clamp(0.0, 1.0)), Tween::default());
}
}
@@ -270,7 +283,7 @@ fn apply_initial_volume(
}
fn play_on_undo(
mut events: EventReader<UndoRequestEvent>,
mut events: MessageReader<UndoRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
@@ -281,7 +294,7 @@ fn play_on_undo(
}
fn apply_volume_on_change(
mut events: EventReader<SettingsChangedEvent>,
mut events: MessageReader<SettingsChangedEvent>,
mut audio: NonSendMut<AudioState>,
mute: Option<Res<MuteState>>,
) {
@@ -326,7 +339,7 @@ fn handle_mute_keys(
}
fn play_on_draw(
mut events: EventReader<DrawRequestEvent>,
mut events: MessageReader<DrawRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
game: Option<Res<GameStateResource>>,
@@ -345,14 +358,17 @@ fn play_on_draw(
if is_recycle(stock_len) {
let mut data = lib.flip.clone();
data.settings.volume = Volume::Amplitude(RECYCLE_VOLUME).into();
if let Some(track) = &audio.sfx_track {
data.settings.output_destination = track.id().into();
}
if let Some(manager) = audio.manager.as_mut() {
if let Err(e) = manager.play(data) {
warn!("failed to play recycle SFX: {e}");
}
data.settings.volume =
Value::Fixed(amplitude_to_decibels(RECYCLE_VOLUME as f32));
let result = if let Some(track) = audio.sfx_track.as_mut() {
track.play(data)
} else if let Some(manager) = audio.manager.as_mut() {
manager.play(data)
} else {
continue;
};
if let Err(e) = result {
warn!("failed to play recycle SFX: {e}");
}
} else {
play(&mut audio, &lib.flip);
@@ -361,7 +377,7 @@ fn play_on_draw(
}
fn play_on_move(
mut events: EventReader<MoveRequestEvent>,
mut events: MessageReader<MoveRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
@@ -374,7 +390,7 @@ fn play_on_move(
}
fn play_on_rejected(
mut events: EventReader<MoveRejectedEvent>,
mut events: MessageReader<MoveRejectedEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
@@ -387,7 +403,7 @@ fn play_on_rejected(
}
fn play_on_new_game(
mut events: EventReader<NewGameRequestEvent>,
mut events: MessageReader<NewGameRequestEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
@@ -400,7 +416,7 @@ fn play_on_new_game(
}
fn play_on_win(
mut events: EventReader<GameWonEvent>,
mut events: MessageReader<GameWonEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
@@ -418,7 +434,7 @@ fn play_on_win(
/// Driven by `CardFaceRevealedEvent`, which is fired by `tick_flip_anim` at
/// the phase transition (scale.x crosses 0), not by the move event itself.
fn play_on_face_revealed(
mut events: EventReader<CardFaceRevealedEvent>,
mut events: MessageReader<CardFaceRevealedEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
+7 -7
View File
@@ -57,7 +57,7 @@ impl Plugin for AutoCompletePlugin {
fn detect_auto_complete(
mut state: ResMut<AutoCompleteState>,
game: Res<GameStateResource>,
mut changed: EventReader<StateChangedEvent>,
mut changed: MessageReader<StateChangedEvent>,
) {
// Only re-evaluate on state changes to avoid per-frame allocations.
if changed.is_empty() && !game.is_changed() {
@@ -106,7 +106,7 @@ fn drive_auto_complete(
mut state: ResMut<AutoCompleteState>,
game: Res<GameStateResource>,
time: Res<Time>,
mut moves: EventWriter<MoveRequestEvent>,
mut moves: MessageWriter<MoveRequestEvent>,
) {
if !state.active {
return;
@@ -122,7 +122,7 @@ fn drive_auto_complete(
return;
};
moves.send(MoveRequestEvent { from, to, count: 1 });
moves.write(MoveRequestEvent { from, to, count: 1 });
state.cooldown = STEP_INTERVAL;
}
@@ -176,7 +176,7 @@ mod tests {
let mut app = headless_app();
// Install a nearly-won state and fire StateChangedEvent.
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update();
assert!(app.world().resource::<AutoCompleteState>().active);
@@ -186,11 +186,11 @@ mod tests {
fn drive_fires_move_request_when_active() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update(); // detect runs, sets active
app.update(); // drive fires the move
let events = app.world().resource::<Events<MoveRequestEvent>>();
let events = app.world().resource::<Messages<MoveRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
// At least one MoveRequestEvent should have been fired.
@@ -206,7 +206,7 @@ mod tests {
let mut gs = nearly_won_state();
gs.is_won = true;
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update();
assert!(!app.world().resource::<AutoCompleteState>().active);
@@ -0,0 +1,396 @@
//! `CardAnimation` component and the system that drives it.
//!
//! # Design
//!
//! `CardAnimation` is a **drop-in upgrade** for the existing linear `CardAnim`.
//! It targets `Transform` (the current sprite-based architecture). Swapping to
//! Bevy UI requires only changing the four write lines in `advance_card_animations`
//! to write `Style.left` / `Style.top` via a `Style` component query instead.
//!
//! # Z-lift
//!
//! During motion, `translation.z` follows a parabolic arc:
//!
//! ```text
//! z(t) = lerp(start_z, end_z, t) + z_lift × sin(t × π)
//! ```
//!
//! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the
//! card "floats up" in the middle of its travel and lands at its correct rest z.
//!
//! # Retargeting
//!
//! When a card is redirected mid-flight, call [`retarget_animation`]. It reads
//! the current interpolated position so the card never snaps.
//!
//! # Coexistence with `CardAnim`
//!
//! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but
//! **must never be on the same entity** — both write to `Transform`. When
//! migrating, replace `CardAnim` insertions with `CardAnimation` insertions and
//! register `CardAnimationPlugin` alongside `AnimationPlugin`.
use std::f32::consts::PI;
use bevy::prelude::*;
use super::curves::{sample_curve, MotionCurve};
use super::timing::compute_duration;
use crate::pause_plugin::PausedResource;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/// Curve-based card animation.
///
/// Drives `Transform` XY translation via a [`MotionCurve`], with optional
/// z-lift and scale interpolation. Removes itself when the animation completes.
#[derive(Component, Debug, Clone)]
pub struct CardAnimation {
/// 2-D start position (world space).
pub start: Vec2,
/// 2-D destination (world space).
pub end: Vec2,
/// Seconds elapsed since the delay expired.
pub elapsed: f32,
/// Total animation duration in seconds (excluding delay).
pub duration: f32,
/// Easing curve applied to the interpolation factor.
pub curve: MotionCurve,
/// Seconds to wait before starting movement.
pub delay: f32,
/// Z coordinate at animation start (used for parabolic lift calculation).
pub start_z: f32,
/// Z coordinate at animation end — the card's resting z after completion.
pub end_z: f32,
/// Extra Z added at the midpoint of motion (`z(0.5) = base_z + z_lift`).
/// Set to 0.0 to disable the depth arc.
pub z_lift: f32,
/// Transform scale at `t = 0`.
pub scale_start: f32,
/// Transform scale at `t = 1`.
pub scale_end: f32,
}
impl CardAnimation {
/// Convenience constructor: slide from `start` to `end` with auto-computed
/// duration based on pixel distance. No z-lift or scale change.
pub fn slide(start: Vec2, start_z: f32, end: Vec2, end_z: f32, curve: MotionCurve) -> Self {
Self {
start,
end,
elapsed: 0.0,
duration: compute_duration(start.distance(end)),
curve,
delay: 0.0,
start_z,
end_z,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
}
}
/// Sets the pre-animation delay in seconds.
#[must_use]
pub fn with_delay(mut self, secs: f32) -> Self {
self.delay = secs;
self
}
/// Overrides the auto-computed duration.
#[must_use]
pub fn with_duration(mut self, secs: f32) -> Self {
self.duration = secs;
self
}
/// Enables the parabolic z-lift arc with the given peak offset.
#[must_use]
pub fn with_z_lift(mut self, lift: f32) -> Self {
self.z_lift = lift;
self
}
/// Interpolates `Transform.scale` from `start` to `end` over the animation.
#[must_use]
pub fn with_scale(mut self, start: f32, end: f32) -> Self {
self.scale_start = start;
self.scale_end = end;
self
}
/// Returns the current interpolated XY position without advancing time.
///
/// Used by [`retarget_animation`] to read mid-flight position cleanly.
pub fn current_xy(&self) -> Vec2 {
if self.duration <= 0.0 {
return self.end;
}
let t = (self.elapsed / self.duration).clamp(0.0, 1.0);
let s = sample_curve(self.curve, t);
self.start.lerp(self.end, s)
}
}
// ---------------------------------------------------------------------------
// Retarget helper
// ---------------------------------------------------------------------------
/// 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 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
///
/// ```ignore
/// // Inside a system that decides to move a card to a new target:
/// let (entity, transform, anim) = cards.get(card_entity)?;
/// retarget_animation(
/// &mut commands,
/// entity,
/// anim, // Option<&CardAnimation>
/// transform,
/// Vec2::new(400.0, 200.0),
/// resting_z,
/// MotionCurve::SmoothSnap,
/// );
/// ```
pub fn retarget_animation(
commands: &mut Commands,
entity: Entity,
current_anim: Option<&CardAnimation>,
transform: &Transform,
new_end: Vec2,
new_end_z: f32,
curve: MotionCurve,
) {
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,
// Start slightly into the new animation to carry forward momentum.
elapsed: momentum_carry * duration,
duration,
curve,
delay: 0.0,
start_z: current_z,
end_z: new_end_z,
z_lift: 8.0,
scale_start: 1.0,
scale_end: 1.0,
});
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// Advances all [`CardAnimation`] components each frame.
///
/// Skipped while the game is paused. On completion the component is removed
/// and `Transform` is snapped to the exact destination to prevent floating-point
/// drift.
pub(crate) fn advance_card_animations(
mut commands: Commands,
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut q {
// Honour pre-animation delay.
if anim.delay > 0.0 {
anim.delay = (anim.delay - dt).max(0.0);
continue;
}
// Zero-duration: instant snap.
if anim.duration <= 0.0 {
transform.translation = anim.end.extend(anim.end_z);
transform.scale = Vec3::splat(anim.scale_end);
commands.entity(entity).remove::<CardAnimation>();
continue;
}
anim.elapsed += dt;
let t = (anim.elapsed / anim.duration).min(1.0);
let s = sample_curve(anim.curve, t);
// --- XY via curve ---
let xy = anim.start.lerp(anim.end, s);
transform.translation.x = xy.x;
transform.translation.y = xy.y;
// --- Z: linear base interpolation + parabolic lift arc ---
//
// The sine arch is 0 at t=0 and t=1, peaking at t=0.5.
// This keeps the card's resting Z correct at both ends.
let base_z = anim.start_z + (anim.end_z - anim.start_z) * t;
let lift = anim.z_lift * (t * PI).sin();
transform.translation.z = base_z + lift;
// --- Scale ---
let scale = anim.scale_start + (anim.scale_end - anim.scale_start) * s;
transform.scale = Vec3::splat(scale);
// --- Completion ---
if t >= 1.0 {
transform.translation = anim.end.extend(anim.end_z);
transform.scale = Vec3::splat(anim.scale_end);
commands.entity(entity).remove::<CardAnimation>();
}
}
}
// ---------------------------------------------------------------------------
// Win cascade
// ---------------------------------------------------------------------------
/// Win-cascade scatter targets — 8 points beyond the window edges.
///
/// Scaled by `radius` (pass `layout.card_size.x * 8.0` for a good result).
pub fn win_scatter_targets(radius: f32) -> [Vec2; 8] {
let r = radius;
[
Vec2::new(r, r),
Vec2::new(-r, r),
Vec2::new(r, -r),
Vec2::new(-r, -r),
Vec2::new(0.0, r),
Vec2::new(0.0, -r),
Vec2::new(r, 0.0),
Vec2::new(-r, 0.0),
]
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn make_anim(start: Vec2, end: Vec2, elapsed: f32, duration: f32) -> CardAnimation {
CardAnimation {
start,
end,
elapsed,
duration,
curve: MotionCurve::Responsive, // linear-ish for easy assertion
delay: 0.0,
start_z: 0.0,
end_z: 0.0,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
}
}
#[test]
fn current_xy_at_start() {
let anim = make_anim(Vec2::ZERO, Vec2::new(100.0, 0.0), 0.0, 1.0);
let pos = anim.current_xy();
assert!(pos.x < 5.0, "at t=0 position should be near start, got {pos:?}");
}
#[test]
fn current_xy_at_end() {
let anim = make_anim(Vec2::ZERO, Vec2::new(100.0, 0.0), 1.0, 1.0);
let pos = anim.current_xy();
assert!(
(pos.x - 100.0).abs() < 1e-3,
"at t=1 position should be at end, got {pos:?}"
);
}
#[test]
fn current_xy_zero_duration_returns_end() {
let anim = make_anim(Vec2::ZERO, Vec2::new(50.0, 0.0), 0.0, 0.0);
let pos = anim.current_xy();
assert!(
(pos.x - 50.0).abs() < 1e-3,
"zero-duration must return end immediately, got {pos:?}"
);
}
#[test]
fn slide_constructor_auto_computes_duration() {
let start = Vec2::ZERO;
let end = Vec2::new(300.0, 0.0);
let anim = CardAnimation::slide(start, 0.0, end, 0.0, MotionCurve::SmoothSnap);
let distance = 300.0_f32;
let expected = compute_duration(distance);
assert!(
(anim.duration - expected).abs() < 1e-5,
"slide() duration mismatch: got {}, expected {}",
anim.duration,
expected
);
}
#[test]
fn with_delay_sets_delay() {
let anim = CardAnimation::slide(Vec2::ZERO, 0.0, Vec2::ONE, 0.0, MotionCurve::SmoothSnap)
.with_delay(0.5);
assert!((anim.delay - 0.5).abs() < 1e-6);
}
#[test]
fn with_z_lift_sets_z_lift() {
let anim = CardAnimation::slide(Vec2::ZERO, 0.0, Vec2::ONE, 0.0, MotionCurve::SmoothSnap)
.with_z_lift(12.0);
assert!((anim.z_lift - 12.0).abs() < 1e-6);
}
#[test]
fn win_scatter_has_eight_targets() {
let targets = win_scatter_targets(800.0);
assert_eq!(targets.len(), 8);
}
#[test]
fn win_scatter_targets_are_off_center() {
for t in win_scatter_targets(400.0) {
let dist = t.length();
assert!(dist > 100.0, "scatter target should be well off-center: {t:?}");
}
}
}
@@ -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,196 @@
//! Motion curve definitions for card animations.
//!
//! All curves map `t ∈ [0, 1]` to a position ratio. Curves with overshoot
//! (`SmoothSnap`, `SoftBounce`, `Expressive`) may return values slightly
//! outside `[0, 1]` near the destination — callers should not clamp the output
//! before applying it to a lerp, as the overshoot is intentional.
//!
//! # Curve selection guide
//!
//! | Interaction | Recommended curve |
//! |----------------------|-------------------|
//! | Standard card move | `SmoothSnap` |
//! | Foundation placement | `SoftBounce` |
//! | Invalid snap-back | `Responsive` |
//! | Win cascade | `Expressive` |
use std::f32::consts::PI;
/// Motion curve variant controlling animation easing behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MotionCurve {
/// Cubic ease-out with a 1.5 % terminal overshoot.
///
/// Overshoot is a sine arch in the final 25 % of the animation that peaks
/// ~1.5 % beyond the target, settling cleanly to 1.0 at `t = 1`. Gives a
/// lively, slightly "alive" feel without feeling heavy.
#[default]
SmoothSnap,
/// Underdamped spring (ζ = 0.65, ω = 20 rad/s).
///
/// One visible overshoot of ~8 % followed by fast decay. Good for
/// satisfying "thud" feedback when placing cards on foundations or tableau.
SoftBounce,
/// Quintic ease-out — aggressive deceleration, zero overshoot.
///
/// Starts extremely fast and decelerates hard. Best for snap-back on
/// invalid drops: the card returns instantly without any bounce.
Responsive,
/// Underdamped spring (ζ = 0.45, ω = 18 rad/s).
///
/// Two visible bounces before settling. High visual energy — reserved for
/// win cascade animations where expressivity matters more than subtlety.
Expressive,
}
/// Samples `curve` at normalised time `t ∈ [0, 1]`.
///
/// The return value is the interpolation factor to pass to `Vec2::lerp` /
/// `Vec3::lerp`. Values may slightly exceed 1.0 for curves with overshoot.
#[inline]
pub fn sample_curve(curve: MotionCurve, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
match curve {
MotionCurve::SmoothSnap => smooth_snap(t),
MotionCurve::SoftBounce => soft_bounce(t),
MotionCurve::Responsive => responsive(t),
MotionCurve::Expressive => expressive(t),
}
}
/// Cubic ease-out with a sine-arch overshoot in the final 25 % of `t`.
///
/// The overshoot term is `sin(tail * π) * 0.015` where `tail` is `t` linearly
/// rescaled from `[0.75, 1.0]` to `[0, 1]`. At `t = 0.875` the card is ~1.5 %
/// past its target; at `t = 1` the card is exactly on target.
#[inline]
fn smooth_snap(t: f32) -> f32 {
let base = 1.0 - (1.0 - t).powi(3);
let tail = ((t - 0.75) / 0.25).clamp(0.0, 1.0);
let overshoot = (tail * PI).sin() * 0.015;
base + overshoot
}
/// Underdamped spring response (ζ = 0.65, ω₀ = 20 rad/s).
///
/// Derived from the exact closed-form solution:
/// `x(t) = 1 e^{−ζω₀t}[cos(ωd·t) + (ζω₀/ωd)·sin(ωd·t)]`
/// where `ωd = ω₀·√(1 ζ²)`.
#[inline]
fn soft_bounce(t: f32) -> f32 {
const OMEGA: f32 = 20.0;
const ZETA: f32 = 0.65;
let omega_d = OMEGA * (1.0 - ZETA * ZETA).sqrt();
let decay = (-ZETA * OMEGA * t).exp();
1.0 - decay * ((omega_d * t).cos() + (ZETA * OMEGA / omega_d) * (omega_d * t).sin())
}
/// Quintic ease-out: `f(t) = 1 (1 t)^5`.
///
/// Reaches ~97 % of the target by `t = 0.5`. No overshoot.
#[inline]
fn responsive(t: f32) -> f32 {
1.0 - (1.0 - t).powi(5)
}
/// Underdamped spring response (ζ = 0.45, ω₀ = 18 rad/s) — two visible bounces.
///
/// Uses the same closed-form spring formula as `soft_bounce` but with lower
/// damping, producing higher overshoot (~18 %) and two discernible oscillations
/// before settling.
#[inline]
fn expressive(t: f32) -> f32 {
const OMEGA: f32 = 18.0;
const ZETA: f32 = 0.45;
let omega_d = OMEGA * (1.0 - ZETA * ZETA).sqrt();
let decay = (-ZETA * OMEGA * t).exp();
1.0 - decay * ((omega_d * t).cos() + (ZETA * OMEGA / omega_d) * (omega_d * t).sin())
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_near(a: f32, b: f32, eps: f32, msg: &str) {
assert!((a - b).abs() < eps, "{msg}: expected ~{b}, got {a}");
}
#[test]
fn all_curves_start_at_zero() {
for curve in [
MotionCurve::SmoothSnap,
MotionCurve::SoftBounce,
MotionCurve::Responsive,
MotionCurve::Expressive,
] {
assert_near(sample_curve(curve, 0.0), 0.0, 1e-5, &format!("{curve:?} at t=0"));
}
}
#[test]
fn all_curves_end_at_one() {
for curve in [
MotionCurve::SmoothSnap,
MotionCurve::SoftBounce,
MotionCurve::Responsive,
] {
assert_near(sample_curve(curve, 1.0), 1.0, 1e-4, &format!("{curve:?} at t=1"));
}
// Spring-based curves have residual oscillation at finite t=1; allow 2 e-3.
assert_near(
sample_curve(MotionCurve::Expressive, 1.0),
1.0,
2e-3,
"Expressive at t=1",
);
}
#[test]
fn responsive_reaches_half_before_midpoint() {
// Quintic ease-out accelerates fast — >50 % by t=0.5.
let v = sample_curve(MotionCurve::Responsive, 0.5);
assert!(v > 0.96, "Responsive should be >96 % at t=0.5, got {v}");
}
#[test]
fn smooth_snap_overshoots_slightly_near_end() {
// Peak overshoot is around t = 0.875.
let peak = sample_curve(MotionCurve::SmoothSnap, 0.875);
assert!(peak > 1.0, "SmoothSnap should overshoot at t=0.875, got {peak}");
assert!(peak < 1.03, "SmoothSnap overshoot should be small (<3 %), got {peak}");
}
#[test]
fn soft_bounce_overshoots_and_returns() {
let v = sample_curve(MotionCurve::SoftBounce, 1.0);
assert_near(v, 1.0, 1e-3, "SoftBounce must settle at 1.0");
}
#[test]
fn expressive_has_more_overshoot_than_soft_bounce() {
// Compare max value in [0,1] range.
let max_soft: f32 = (0..=100)
.map(|i| sample_curve(MotionCurve::SoftBounce, i as f32 / 100.0))
.fold(f32::NEG_INFINITY, f32::max);
let max_expr: f32 = (0..=100)
.map(|i| sample_curve(MotionCurve::Expressive, i as f32 / 100.0))
.fold(f32::NEG_INFINITY, f32::max);
assert!(
max_expr > max_soft,
"Expressive should overshoot more than SoftBounce: {max_expr} vs {max_soft}"
);
}
#[test]
fn sample_curve_clamps_t_below_zero() {
assert_near(sample_curve(MotionCurve::SmoothSnap, -1.0), 0.0, 1e-5, "t<0 clamped");
}
#[test]
fn sample_curve_clamps_t_above_one() {
assert_near(sample_curve(MotionCurve::Responsive, 2.0), 1.0, 1e-5, "t>1 clamped");
}
}
@@ -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);
}
}
@@ -0,0 +1,327 @@
//! Card interaction visuals: hover scale, drag lift, and input buffering.
//!
//! # Hover
//!
//! [`HoverState`] tracks the entity currently under the cursor. A system
//! smoothly lerps `Transform.scale` toward `HOVER_SCALE` on the hovered card
//! and back to 1.0 when the cursor leaves. Scale is only written when no
//! [`CardAnimation`] is active on the entity (the animation takes priority).
//!
//! # Drag visual
//!
//! While [`DragState`] is non-idle, the dragged card entities receive a subtle
//! scale boost (`DRAG_LIFT_SCALE`) and their z-order is pushed up. The exact
//! translation is still controlled by the existing [`crate::input_plugin`] —
//! this system only applies the _visual_ enhancement without touching XY.
//!
//! # Input buffer
//!
//! [`InputBuffer`] stores move/draw/undo actions that arrived while cards are
//! still animating. Call [`InputBuffer::push`] from any system that wants
//! buffering. The drain system fires the oldest buffered action as soon as all
//! [`CardAnimation`] components have cleared, giving a responsive feel on
//! fast repeated clicks.
//!
//! # Visual priority
//!
//! Dragged cards always have the highest z. The existing [`crate::input_plugin`]
//! sets drag z; this module applies scale on top. The ordering constraint
//! `.after(crate::game_plugin::GameMutation)` ensures all game-state changes
//! settle before visual updates run.
use std::collections::VecDeque;
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;
use crate::resources::DragState;
/// Type alias to reduce complexity in hover/drag query signatures.
type CardTransformQuery<'w, 's> =
Query<'w, 's, (Entity, &'static mut Transform), (With<CardEntity>, Without<CardAnimation>)>;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Lerp speed for drag scale interpolation.
const DRAG_LERP_SPEED: f32 = 20.0;
/// Maximum number of buffered inputs retained.
const INPUT_BUFFER_CAPACITY: usize = 4;
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
/// Tracks the entity currently under the cursor and the interpolated hover scale.
#[derive(Resource, Debug, Default)]
pub struct HoverState {
/// Entity currently hovered (`None` when cursor is off all cards or dragging).
pub entity: Option<Entity>,
/// Current interpolated scale applied to the hovered card.
pub scale: f32,
}
/// Describes a user action that arrived while cards were still animating.
#[derive(Debug, Clone)]
pub enum BufferedInput {
Move { from: crate::events::MoveRequestEvent },
Draw,
Undo,
}
/// FIFO queue of inputs deferred until ongoing animations complete.
///
/// Populate via [`InputBuffer::push`] and consume via the drain system.
/// Capped at [`INPUT_BUFFER_CAPACITY`] — further pushes when full are silently
/// dropped to prevent stale action pileup.
#[derive(Resource, Debug, Default)]
pub struct InputBuffer {
pub(crate) queue: VecDeque<BufferedInput>,
}
impl InputBuffer {
/// Enqueues an input if the buffer is not full.
pub fn push(&mut self, input: BufferedInput) {
if self.queue.len() < INPUT_BUFFER_CAPACITY {
self.queue.push_back(input);
}
}
/// Returns `true` when no inputs are pending.
pub fn is_empty(&self) -> bool {
self.queue.is_empty()
}
/// Returns how many inputs are queued.
pub fn len(&self) -> usize {
self.queue.len()
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Detects which card is under the cursor and updates [`HoverState`].
///
/// Clears hover when [`DragState`] is active (dragging takes visual priority).
/// Picks the topmost card (highest `translation.z`) when multiple cards overlap.
pub(crate) fn detect_hover(
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
drag: Option<Res<DragState>>,
layout: Option<Res<LayoutResource>>,
cards: Query<(Entity, &Transform), With<CardEntity>>,
mut hover: ResMut<HoverState>,
) {
let is_dragging = drag.as_ref().is_some_and(|d| !d.is_idle());
if is_dragging {
hover.entity = None;
return;
}
let Some(layout) = layout else { return };
let Some(cursor_world) = cursor_world(&windows, &cameras) else {
hover.entity = None;
return;
};
let half_w = layout.0.card_size.x * 0.5;
let half_h = layout.0.card_size.y * 0.5;
let mut best: Option<(Entity, f32)> = None;
for (entity, transform) in &cards {
let pos = transform.translation.truncate();
if (cursor_world.x - pos.x).abs() < half_w
&& (cursor_world.y - pos.y).abs() < half_h
{
let z = transform.translation.z;
if best.is_none_or(|(_, bz)| z > bz) {
best = Some((entity, z));
}
}
}
hover.entity = best.map(|(e, _)| e);
}
/// 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_target
} else {
1.0
};
let current = transform.scale.x;
let new_scale = current + (target_scale - current) * (lerp_speed * dt).min(1.0);
transform.scale = Vec3::splat(new_scale);
}
// Update the tracked scale for external inspection.
hover_state.scale = if let Some(entity) = target_entity {
cards
.get(entity)
.map(|(_, t)| t.scale.x)
.unwrap_or(hover_target)
} else {
1.0
};
}
/// Applies a scale boost to committed dragged card entities.
///
/// 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 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_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);
}
}
/// Fires the oldest buffered input when no [`CardAnimation`] components remain.
///
/// Call this system late in the `Update` schedule so freshly-removed animations
/// are already gone before the drain runs.
pub(crate) fn drain_input_buffer(
mut buffer: ResMut<InputBuffer>,
anims: Query<&CardAnimation>,
mut move_events: MessageWriter<MoveRequestEvent>,
mut draw_events: MessageWriter<DrawRequestEvent>,
mut undo_events: MessageWriter<UndoRequestEvent>,
) {
if !anims.is_empty() {
return;
}
match buffer.queue.pop_front() {
Some(BufferedInput::Move { from }) => {
move_events.write(from);
}
Some(BufferedInput::Draw) => {
draw_events.write(DrawRequestEvent);
}
Some(BufferedInput::Undo) => {
undo_events.write(UndoRequestEvent);
}
None => {}
}
}
// ---------------------------------------------------------------------------
// Cursor helper (mirrors the pattern used by input_plugin)
// ---------------------------------------------------------------------------
/// Converts the cursor screen position to 2-D world coordinates.
///
/// Returns `None` when the cursor is outside the window or no camera is found.
fn cursor_world(
windows: &Query<&Window, With<PrimaryWindow>>,
cameras: &Query<(&Camera, &GlobalTransform)>,
) -> Option<Vec2> {
let window = windows.single().ok()?;
let cursor = window.cursor_position()?;
let (camera, camera_transform) = cameras.single().ok()?;
camera.viewport_to_world_2d(camera_transform, cursor).ok()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_buffer_capacity_is_respected() {
let mut buf = InputBuffer::default();
for _ in 0..INPUT_BUFFER_CAPACITY + 5 {
buf.push(BufferedInput::Draw);
}
assert_eq!(
buf.len(),
INPUT_BUFFER_CAPACITY,
"buffer must not exceed capacity"
);
}
#[test]
fn input_buffer_is_fifo() {
let mut buf = InputBuffer::default();
buf.push(BufferedInput::Draw);
buf.push(BufferedInput::Undo);
matches!(buf.queue.pop_front().unwrap(), BufferedInput::Draw);
matches!(buf.queue.pop_front().unwrap(), BufferedInput::Undo);
}
#[test]
fn input_buffer_empty_initially() {
let buf = InputBuffer::default();
assert!(buf.is_empty());
assert_eq!(buf.len(), 0);
}
#[test]
fn input_buffer_len_increments() {
let mut buf = InputBuffer::default();
buf.push(BufferedInput::Draw);
assert_eq!(buf.len(), 1);
buf.push(BufferedInput::Undo);
assert_eq!(buf.len(), 2);
}
#[test]
fn hover_state_default_has_no_entity() {
let state = HoverState::default();
assert!(state.entity.is_none());
assert_eq!(state.scale, 0.0);
}
}
+411
View File
@@ -0,0 +1,411 @@
//! `CardAnimationPlugin` — curve-based card animation system.
//!
//! # Quick start
//!
//! Register the plugin alongside the existing animation plugins:
//!
//! ```ignore
//! app.add_plugins((
//! AnimationPlugin, // existing: drives CardAnim (linear)
//! FeedbackAnimPlugin, // existing: shake + settle
//! CardAnimationPlugin, // new: curve-based CardAnimation
//! ));
//! ```
//!
//! Spawn a card with a `CardAnimation` component:
//!
//! ```ignore
//! use solitaire_engine::card_animation::{CardAnimation, MotionCurve};
//!
//! commands.spawn((
//! SpriteBundle { /* ... */ },
//! CardAnimation::slide(
//! Vec2::new(0.0, 0.0), // start xy
//! 0.0, // start z
//! Vec2::new(300.0, 200.0),// end xy
//! 5.0, // end z (resting)
//! MotionCurve::SmoothSnap,
//! )
//! .with_z_lift(12.0) // floats up during motion
//! .with_delay(0.03), // stagger delay
//! ));
//! ```
//!
//! Retarget a card mid-flight:
//!
//! ```ignore
//! use solitaire_engine::card_animation::retarget_animation;
//!
//! fn handle_drop(
//! mut commands: Commands,
//! q: Query<(Entity, &Transform, Option<&CardAnimation>), With<CardEntity>>,
//! ) {
//! let (entity, transform, anim) = q.get(card_entity).unwrap();
//! retarget_animation(
//! &mut commands,
//! entity,
//! anim,
//! transform,
//! new_target_xy,
//! new_target_z,
//! MotionCurve::SmoothSnap,
//! );
//! }
//! ```
//!
//! # Win cascade with `Expressive` curve
//!
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
//! (linear). To use the curve-based cascade instead, disable
//! `handle_win_cascade` in `AnimationPlugin` and register `WinCascadePlugin`
//! (declared below) which uses `CardAnimation` + `MotionCurve::Expressive`.
//!
//! They **must not both be active** — both write to `Transform` on the same
//! 52 entities and will race.
//!
//! # Coexistence rules
//!
//! | Condition | Safe? |
//! |---|---|
//! | `CardAnim` and `CardAnimation` on **different** entities | ✓ |
//! | `CardAnim` and `CardAnimation` on the **same** entity | ✗ |
//! | `HoverState` scale + `CardAnimation` scale on same entity | ✓ (CardAnimation takes priority — hover skipped via `Without<CardAnimation>` filter) |
//! | `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::*;
use crate::card_plugin::CardEntity;
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
use crate::game_plugin::GameMutation;
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, 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`.
pub struct CardAnimationPlugin;
impl Plugin for CardAnimationPlugin {
fn build(&self, app: &mut App) {
// Register events and resources idempotently — double-registration is
// safe in Bevy.
app.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<GameWonEvent>()
.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,
(
// 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,
// 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,
// Drain buffered inputs only when no animations remain.
drain_input_buffer,
)
.chain()
.after(GameMutation),
);
}
}
// ---------------------------------------------------------------------------
// Optional: win cascade with Expressive curve
// ---------------------------------------------------------------------------
/// Optional plugin that replaces the linear win cascade in `AnimationPlugin`
/// with an `Expressive`-curve cascade.
///
/// **Do not register this alongside `AnimationPlugin`'s win cascade** — they
/// will race on the same card entities. To use this plugin, prevent
/// `AnimationPlugin` from handling `GameWonEvent` (or remove it and manage
/// win toasts manually).
pub struct WinCascadePlugin;
impl Plugin for WinCascadePlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
trigger_expressive_win_cascade.after(GameMutation),
);
}
}
/// Inserts `CardAnimation` (Expressive curve) on every card when `GameWonEvent` fires.
///
/// Cards scatter to 8 off-screen positions with per-card stagger. The z-lift
/// creates a "burst" effect as cards fly outward.
fn trigger_expressive_win_cascade(
mut events: MessageReader<GameWonEvent>,
cards: Query<(Entity, &Transform), With<CardEntity>>,
layout: Option<Res<LayoutResource>>,
mut commands: Commands,
) {
if events.read().next().is_none() {
return;
}
let radius = layout
.as_ref()
.map_or(800.0, |l| l.0.card_size.x * 8.0);
let targets = win_scatter_targets(radius);
for (index, (entity, transform)) in cards.iter().enumerate() {
let start_xy = transform.translation.truncate();
let start_z = transform.translation.z;
let target = targets[index % targets.len()];
commands.entity(entity).insert(
CardAnimation::slide(start_xy, start_z, target, start_z + 60.0, MotionCurve::Expressive)
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
.with_duration(0.65)
.with_z_lift(25.0),
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::animation_plugin::AnimationPlugin;
use crate::card_plugin::CardPlugin;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
fn base_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(CardAnimationPlugin);
app.update();
app
}
#[test]
fn plugin_registers_hover_state() {
let app = base_app();
assert!(
app.world().get_resource::<HoverState>().is_some(),
"HoverState resource must be registered"
);
}
#[test]
fn plugin_registers_input_buffer() {
let app = base_app();
assert!(
app.world().get_resource::<InputBuffer>().is_some(),
"InputBuffer resource must be registered"
);
}
#[test]
fn card_animation_advances_and_removes_itself() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
let start = Vec2::new(0.0, 0.0);
let end = Vec2::new(100.0, 0.0);
let entity = app
.world_mut()
.spawn((
Transform::from_translation(start.extend(0.0)),
CardAnimation {
start,
end,
elapsed: 0.99,
duration: 1.0,
curve: MotionCurve::Responsive,
delay: 0.0,
start_z: 0.0,
end_z: 0.0,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
},
))
.id();
app.update();
// After one update at elapsed=0.99, component should still be present.
// We can't advance time reliably in MinimalPlugins, but we can check
// that the advance_card_animations system processed the component
// (pos moved closer to end).
let transform = app.world().entity(entity).get::<Transform>().unwrap();
assert!(
transform.translation.x > 50.0,
"card should have moved past midpoint by elapsed=0.99, got x={}",
transform.translation.x
);
}
#[test]
fn card_animation_instant_snaps_on_zero_duration() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
let end = Vec2::new(200.0, 100.0);
let entity = app
.world_mut()
.spawn((
Transform::from_translation(Vec3::ZERO),
CardAnimation {
start: Vec2::ZERO,
end,
elapsed: 0.0,
duration: 0.0, // zero duration → instant snap
curve: MotionCurve::SmoothSnap,
delay: 0.0,
start_z: 0.0,
end_z: 5.0,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
},
))
.id();
app.update();
assert!(
app.world().entity(entity).get::<CardAnimation>().is_none(),
"zero-duration animation must be removed after one update"
);
let transform = app.world().entity(entity).get::<Transform>().unwrap();
assert!(
(transform.translation.x - 200.0).abs() < 1e-3,
"card must snap to end.x"
);
assert!(
(transform.translation.y - 100.0).abs() < 1e-3,
"card must snap to end.y"
);
assert!(
(transform.translation.z - 5.0).abs() < 1e-3,
"card must snap to end_z"
);
}
#[test]
fn card_animation_respects_delay() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
let entity = app
.world_mut()
.spawn((
Transform::from_translation(Vec3::ZERO),
CardAnimation {
start: Vec2::ZERO,
end: Vec2::new(100.0, 0.0),
elapsed: 0.0,
duration: 0.15,
curve: MotionCurve::SmoothSnap,
delay: 100.0, // huge delay — card must not move
start_z: 0.0,
end_z: 0.0,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
},
))
.id();
app.update();
let transform = app.world().entity(entity).get::<Transform>().unwrap();
assert!(
transform.translation.x.abs() < 1e-3,
"card must not move during delay, got x={}",
transform.translation.x
);
}
#[test]
fn input_buffer_push_and_drain_ordering() {
let mut buf = InputBuffer::default();
buf.push(BufferedInput::Draw);
buf.push(BufferedInput::Undo);
// FIFO: Draw comes out first.
assert!(matches!(buf.queue.pop_front().unwrap(), BufferedInput::Draw));
assert!(matches!(buf.queue.pop_front().unwrap(), BufferedInput::Undo));
}
#[test]
fn hover_state_initialises_without_entity() {
let state = HoverState::default();
assert!(state.entity.is_none());
}
#[test]
fn win_scatter_produces_eight_distinct_points() {
let targets = win_scatter_targets(600.0);
assert_eq!(targets.len(), 8);
// All must be different.
for i in 0..8 {
for j in (i + 1)..8 {
assert_ne!(
targets[i], targets[j],
"scatter targets {i} and {j} must be distinct"
);
}
}
}
}
@@ -0,0 +1,152 @@
//! Distance-based duration calculation and stagger utilities.
//!
//! All functions are pure (no Bevy dependency) and can be tested in isolation.
/// Minimum animation duration — applied to very short or zero-distance moves.
pub const MIN_DURATION_SECS: f32 = 0.12;
/// Hard cap on animation duration regardless of distance.
pub const MAX_DURATION_SECS: f32 = 0.35;
/// Sqrt scale factor calibrated so a 600-pixel move hits `MAX_DURATION_SECS`:
/// `MIN + √600 × SCALE ≈ 0.35 s`.
const SQRT_SCALE: f32 = 0.0094;
/// Micro-variation amplitude: ±0.4 % of the computed duration.
///
/// Small enough to be imperceptible in isolation but enough to break the
/// "robotic" uniformity when many cards animate simultaneously.
const MICRO_VARY_AMPLITUDE: f32 = 0.004;
/// Computes animation duration from a pixel distance using square-root scaling.
///
/// Square-root growth keeps short moves feeling instant while preventing long
/// moves from feeling excessively slow.
///
/// | Distance | Duration |
/// |----------|-----------|
/// | 25 px | ~0.17 s |
/// | 100 px | ~0.21 s |
/// | 300 px | ~0.28 s |
/// | 600 px | ~0.35 s |
/// | 1200 px | ~0.35 s ← capped |
#[inline]
pub fn compute_duration(distance: f32) -> f32 {
(MIN_DURATION_SECS + distance.abs().sqrt() * SQRT_SCALE).min(MAX_DURATION_SECS)
}
/// Applies a deterministic ±0.4 % micro-variation to `duration`.
///
/// `entity_index` should be a stable per-entity value (e.g. `Entity::index()`).
/// The same index always produces the same variation so animations don't
/// change between frames.
#[inline]
pub fn micro_vary(duration: f32, entity_index: u32) -> f32 {
// Multiplicative Fibonacci hash — cheap, decent distribution.
let hash = entity_index.wrapping_mul(2_654_435_761);
let noise = (hash >> 16) as f32 / 65_536.0; // 0.0 ..= 1.0
let variation = (noise - 0.5) * 2.0 * MICRO_VARY_AMPLITUDE;
duration * (1.0 + variation)
}
/// Returns the pre-animation delay for card at `index` in a staggered cascade.
///
/// `delay = index × interval_secs`.
#[inline]
pub fn cascade_delay(index: usize, interval_secs: f32) -> f32 {
index as f32 * interval_secs
}
/// Recommended per-card interval for the win cascade (Normal speed).
pub const WIN_CASCADE_INTERVAL_SECS: f32 = 0.018;
/// Recommended per-card interval for deal animations (Normal speed).
pub const DEAL_INTERVAL_SECS: f32 = 0.022;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_distance_gives_minimum_duration() {
assert!(
(compute_duration(0.0) - MIN_DURATION_SECS).abs() < 1e-5,
"zero distance must yield MIN_DURATION_SECS"
);
}
#[test]
fn large_distance_is_capped() {
assert!(
(compute_duration(10_000.0) - MAX_DURATION_SECS).abs() < 1e-5,
"very large distance must be capped at MAX_DURATION_SECS"
);
}
#[test]
fn duration_increases_monotonically() {
let mut prev = 0.0f32;
for d in [10, 50, 100, 200, 400, 600] {
let dur = compute_duration(d as f32);
assert!(dur >= prev, "duration must be monotone: d={d} dur={dur} prev={prev}");
prev = dur;
}
}
#[test]
fn duration_is_within_bounds() {
for d in [0, 1, 25, 100, 300, 600, 1200] {
let dur = compute_duration(d as f32);
assert!(
(MIN_DURATION_SECS..=MAX_DURATION_SECS).contains(&dur),
"duration out of bounds for d={d}: {dur}"
);
}
}
#[test]
fn micro_vary_stays_within_tolerance() {
for i in 0..=1000u32 {
let base = 0.25;
let varied = micro_vary(base, i);
let ratio = (varied - base).abs() / base;
assert!(
ratio <= MICRO_VARY_AMPLITUDE + 1e-6,
"variation for index {i} exceeds amplitude: ratio={ratio}"
);
}
}
#[test]
fn micro_vary_is_deterministic() {
let a = micro_vary(0.2, 42);
let b = micro_vary(0.2, 42);
assert!((a - b).abs() < 1e-9, "micro_vary must be deterministic");
}
#[test]
fn micro_vary_differs_for_different_indices() {
let a = micro_vary(0.2, 1);
let b = micro_vary(0.2, 2);
// Very unlikely to be equal (would require hash collision mod 65536).
assert!((a - b).abs() > 1e-9, "micro_vary should differ for different indices");
}
#[test]
fn cascade_delay_zero_index_is_zero() {
assert_eq!(cascade_delay(0, 0.018), 0.0);
}
#[test]
fn cascade_delay_scales_linearly() {
let interval = 0.018;
for i in 0..52usize {
let expected = i as f32 * interval;
let actual = cascade_delay(i, interval);
assert!(
(actual - expected).abs() < 1e-6,
"cascade_delay({i}) = {actual}, expected {expected}"
);
}
}
}
@@ -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);
}
}
+249 -27
View File
@@ -82,11 +82,28 @@ pub struct HintHighlight {
pub remaining: f32,
}
/// Countdown (seconds) until the `HintHighlight` on a card entity is removed.
///
/// Inserted alongside `HintHighlight` by the hint-visual system. When the timer
/// reaches zero both `HintHighlight` and `HintHighlightTimer` are removed from
/// the entity and the sprite colour is restored.
#[derive(Component, Debug, Clone)]
pub struct HintHighlightTimer(pub f32);
/// Marker on a `PileMarker` entity that is highlighted because the right-clicked
/// card can legally be placed there.
#[derive(Component, Debug)]
pub struct RightClickHighlight;
/// Countdown (seconds) until this right-click destination highlight despawns.
///
/// Inserted alongside `RightClickHighlight` so that highlights auto-clear after
/// 1.5 s even if the player does not make a move or click again. The existing
/// clear-on-state-change and clear-on-pause logic still fires early when
/// appropriate.
#[derive(Component, Debug, Clone)]
pub struct RightClickHighlightTimer(pub f32);
/// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile
/// marker when the stock pile is empty.
#[derive(Component, Debug)]
@@ -140,9 +157,9 @@ impl Plugin for CardPlugin {
// `MinimalPlugins` (tests) this resource is absent by default, so we
// ensure it exists here. Under `DefaultPlugins` the call is a no-op.
app.init_resource::<ButtonInput<MouseButton>>()
.add_event::<SettingsChangedEvent>()
.add_event::<CardFlippedEvent>()
.add_event::<CardFaceRevealedEvent>()
.add_message::<SettingsChangedEvent>()
.add_message::<CardFlippedEvent>()
.add_message::<CardFaceRevealedEvent>()
.add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup))
.add_systems(
Update,
@@ -154,6 +171,7 @@ impl Plugin for CardPlugin {
update_drag_shadow,
tick_hint_highlight,
handle_right_click,
tick_right_click_highlights,
clear_right_click_highlights_on_state_change.after(GameMutation),
clear_right_click_highlights_on_pause,
update_stock_empty_indicator.after(GameMutation),
@@ -165,11 +183,11 @@ impl Plugin for CardPlugin {
/// When card-back selection changes in Settings, re-render all cards so the
/// new back colour is applied immediately (without waiting for a state change).
fn resync_cards_on_settings_change(
mut setting_events: EventReader<SettingsChangedEvent>,
mut state_events: EventWriter<StateChangedEvent>,
mut setting_events: MessageReader<SettingsChangedEvent>,
mut state_events: MessageWriter<StateChangedEvent>,
) {
if setting_events.read().next().is_some() {
state_events.send(StateChangedEvent);
state_events.write(StateChangedEvent);
}
}
@@ -195,7 +213,7 @@ fn sync_cards_startup(
}
fn sync_cards_on_change(
mut events: EventReader<StateChangedEvent>,
mut events: MessageReader<StateChangedEvent>,
commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
@@ -238,7 +256,7 @@ fn sync_cards(
// Despawn any entity whose card is no longer tracked.
for (card_id, (entity, _)) in &existing {
if !live_ids.contains(card_id) {
commands.entity(*entity).despawn_recursive();
commands.entity(*entity).despawn();
}
}
@@ -425,7 +443,7 @@ fn update_card_entity(
// Despawn the old label child and respawn a fresh one, so rank/suit/
// colour/visibility all stay in sync with the card's current state.
commands.entity(entity).despawn_descendants();
commands.entity(entity).despawn_related::<Children>();
commands.entity(entity).with_children(|b| {
b.spawn((
CardLabel,
@@ -490,7 +508,7 @@ fn label_visibility(card: &Card) -> Visibility {
///
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
fn start_flip_anim(
mut events: EventReader<CardFlippedEvent>,
mut events: MessageReader<CardFlippedEvent>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity)>,
@@ -525,7 +543,7 @@ fn tick_flip_anim(
mut commands: Commands,
time: Res<Time>,
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
mut reveal_events: EventWriter<CardFaceRevealedEvent>,
mut reveal_events: MessageWriter<CardFaceRevealedEvent>,
) {
let dt = time.delta_secs();
for (entity, card_entity, mut transform, mut anim) in &mut anims {
@@ -540,7 +558,7 @@ fn tick_flip_anim(
transform.scale.x = 0.0;
// Fire the reveal event exactly once, at the phase transition,
// so the flip sound is synchronised with the visual face reveal.
reveal_events.send(CardFaceRevealedEvent(card_entity.card_id));
reveal_events.write(CardFaceRevealedEvent(card_entity.card_id));
}
}
FlipPhase::ScalingUp => {
@@ -574,7 +592,7 @@ fn update_drag_shadow(
if drag.is_idle() {
// No drag in progress — remove shadow if it exists.
if let Some(e) = shadow.take() {
commands.entity(e).despawn_recursive();
commands.entity(e).despawn();
}
return;
}
@@ -627,7 +645,8 @@ fn update_drag_shadow(
// ---------------------------------------------------------------------------
/// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
/// removes the component and resets the card sprite to its normal face-up colour.
/// removes both `HintHighlight` and `HintHighlightTimer` (if present) and
/// resets the card sprite to its normal face-up colour.
fn tick_hint_highlight(
time: Res<Time>,
mut commands: Commands,
@@ -649,7 +668,10 @@ fn tick_hint_highlight(
} else {
card_back_colour(back_idx)
};
commands.entity(entity).remove::<HintHighlight>();
commands
.entity(entity)
.remove::<HintHighlight>()
.remove::<HintHighlightTimer>();
}
}
}
@@ -664,6 +686,37 @@ const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.2, 0.8, 0.2, 0.6);
/// Restored color for `PileMarker` sprites when the highlight is cleared.
const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Counts down `RightClickHighlightTimer` each frame and clears the highlight
/// when the timer expires.
///
/// This is a fallback expiry: highlights also clear immediately on
/// `StateChangedEvent` (move made) or when the game is paused, whichever comes
/// first. The 1.5 s timer ensures highlights always disappear even if the
/// player takes no further action.
fn tick_right_click_highlights(
mut commands: Commands,
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut highlights: Query<(Entity, &mut RightClickHighlightTimer, &mut Sprite), With<RightClickHighlight>>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
for (entity, mut timer, mut sprite) in &mut highlights {
timer.0 -= dt;
if timer.0 <= 0.0 {
// Restore the pile marker to its default colour before removing
// the highlight marker component.
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
commands
.entity(entity)
.remove::<RightClickHighlight>()
.remove::<RightClickHighlightTimer>();
}
}
}
/// Removes the `RightClickHighlight` marker from every highlighted pile and
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
///
@@ -689,7 +742,7 @@ fn clear_right_click_highlights(
///
/// This ensures stale highlights do not linger after a card is moved.
fn clear_right_click_highlights_on_state_change(
mut events: EventReader<StateChangedEvent>,
mut events: MessageReader<StateChangedEvent>,
mut commands: Commands,
highlighted: Query<Entity, With<RightClickHighlight>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
@@ -781,7 +834,10 @@ fn handle_right_click(
};
if legal {
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
commands.entity(entity).insert(RightClickHighlight);
commands
.entity(entity)
.insert(RightClickHighlight)
.insert(RightClickHighlightTimer(1.5));
}
}
}
@@ -791,9 +847,9 @@ fn cursor_world_pos(
windows: &Query<&Window, With<bevy::window::PrimaryWindow>>,
cameras: &Query<(&Camera, &GlobalTransform)>,
) -> Option<Vec2> {
let window = windows.get_single().ok()?;
let window = windows.single().ok()?;
let cursor = window.cursor_position()?;
let (camera, camera_transform) = cameras.get_single().ok()?;
let (camera, camera_transform) = cameras.single().ok()?;
camera.viewport_to_world_2d(camera_transform, cursor).ok()
}
@@ -855,7 +911,7 @@ fn apply_stock_empty_indicator(
commands: &mut Commands,
game: &GameState,
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: &Query<(Entity, &Parent), With<StockEmptyLabel>>,
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
layout: &Layout,
) {
let stock_empty = game
@@ -875,7 +931,7 @@ fn apply_stock_empty_indicator(
// Spawn the "↺" label only if one does not already exist.
let already_has_label = label_children
.iter()
.any(|(_, parent)| parent.get() == entity);
.any(|(_, parent)| parent.parent() == entity);
if !already_has_label {
let font_size = layout.card_size.x * 0.4;
commands.entity(entity).with_children(|b| {
@@ -894,8 +950,8 @@ fn apply_stock_empty_indicator(
// Despawn any existing "↺" label children.
for (label_entity, parent) in label_children.iter() {
if parent.get() == entity {
commands.entity(label_entity).despawn_recursive();
if parent.parent() == entity {
commands.entity(label_entity).despawn();
}
}
}
@@ -909,7 +965,7 @@ fn update_stock_empty_indicator_startup(
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: Query<(Entity, &Parent), With<StockEmptyLabel>>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
let Some(layout) = layout else { return };
apply_stock_empty_indicator(
@@ -924,12 +980,12 @@ fn update_stock_empty_indicator_startup(
/// Runs each `Update` tick when a `StateChangedEvent` arrives, keeping the
/// stock pile marker dim state and "↺" label in sync with the current stock.
fn update_stock_empty_indicator(
mut events: EventReader<StateChangedEvent>,
mut events: MessageReader<StateChangedEvent>,
mut commands: Commands,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
label_children: Query<(Entity, &Parent), With<StockEmptyLabel>>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
if events.read().next().is_none() {
return;
@@ -1050,7 +1106,7 @@ mod tests {
let mut app = app();
// Trigger a draw, which moves a card from stock to waste and should
// flip it face-up. Count visible labels after.
app.world_mut().send_event(crate::events::DrawRequestEvent);
app.world_mut().write_message(crate::events::DrawRequestEvent);
app.update();
// Now 1 card in waste (face-up), 23 in stock (face-down). So 24
// hidden labels total in stock, plus 21 in tableau = 44.
@@ -1223,6 +1279,172 @@ mod tests {
assert_ne!(FlipPhase::ScalingDown, FlipPhase::ScalingUp);
}
// -----------------------------------------------------------------------
// Task #5 — RightClickHighlightTimer pure-function tests
// -----------------------------------------------------------------------
/// Verify that a freshly-created timer with 1.5 s has a positive countdown
/// and has not yet expired.
#[test]
fn right_click_highlight_timer_starts_positive() {
let timer = RightClickHighlightTimer(1.5);
assert!(
timer.0 > 0.0,
"timer must start with a positive countdown, got {}",
timer.0
);
}
/// Simulate ticking the timer by a delta that exceeds its initial value and
/// verify the resulting value is ≤ 0 (expiry condition).
#[test]
fn right_click_highlight_timer_expires_after_sufficient_ticks() {
let mut remaining = 1.5_f32;
// Tick by more than the initial value to ensure expiry.
remaining -= 2.0;
assert!(
remaining <= 0.0,
"timer must be expired (≤ 0) after 2.0 s tick on a 1.5 s timer, got {}",
remaining
);
}
/// Simulate ticking by less than the initial value and verify the timer is
/// still positive (not yet expired).
#[test]
fn right_click_highlight_timer_not_expired_before_duration() {
let mut remaining = 1.5_f32;
remaining -= 0.5; // only 0.5 s elapsed
assert!(
remaining > 0.0,
"timer must still be positive after only 0.5 s on a 1.5 s timer, got {}",
remaining
);
}
// -----------------------------------------------------------------------
// 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);
+27 -25
View File
@@ -18,20 +18,22 @@ pub const CHALLENGE_UNLOCK_LEVEL: u32 = 5;
/// Fired when the player has just completed a Challenge-mode game and the
/// `challenge_index` cursor advances.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct ChallengeAdvancedEvent {
pub previous_index: u32,
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 {
fn build(&self, app: &mut App) {
app.add_event::<ChallengeAdvancedEvent>()
.add_event::<GameWonEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<InfoToastEvent>()
app.add_message::<ChallengeAdvancedEvent>()
.add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<InfoToastEvent>()
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
.add_systems(Update, advance_on_challenge_win.after(ProgressUpdate))
.add_systems(Update, handle_start_challenge_request.before(GameMutation));
@@ -39,12 +41,12 @@ impl Plugin for ChallengePlugin {
}
fn advance_on_challenge_win(
mut wins: EventReader<GameWonEvent>,
mut wins: MessageReader<GameWonEvent>,
game: Res<GameStateResource>,
mut progress: ResMut<ProgressResource>,
path: Res<ProgressStoragePath>,
mut advanced: EventWriter<ChallengeAdvancedEvent>,
mut toast: EventWriter<InfoToastEvent>,
mut advanced: MessageWriter<ChallengeAdvancedEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
for _ in wins.read() {
if game.0.mode != GameMode::Challenge {
@@ -59,8 +61,8 @@ fn advance_on_challenge_win(
}
// Human-readable level is 1-based (index 0 → "Challenge 1").
let level_number = prev.saturating_add(1);
toast.send(InfoToastEvent(format!("Challenge {level_number} complete!")));
advanced.send(ChallengeAdvancedEvent {
toast.write(InfoToastEvent(format!("Challenge {level_number} complete!")));
advanced.write(ChallengeAdvancedEvent {
previous_index: prev,
new_index: progress.0.challenge_index,
});
@@ -70,14 +72,14 @@ fn advance_on_challenge_win(
fn handle_start_challenge_request(
keys: Res<ButtonInput<KeyCode>>,
progress: Res<ProgressResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut info_toast: EventWriter<InfoToastEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
if !keys.just_pressed(KeyCode::KeyX) {
return;
}
if progress.0.level < CHALLENGE_UNLOCK_LEVEL {
info_toast.send(InfoToastEvent(format!(
info_toast.write(InfoToastEvent(format!(
"Challenge mode unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
)));
return;
@@ -86,7 +88,7 @@ fn handle_start_challenge_request(
warn!("challenge seed list is empty");
return;
};
new_game.send(NewGameRequestEvent {
new_game.write(NewGameRequestEvent {
seed: Some(seed),
mode: Some(GameMode::Challenge),
});
@@ -124,7 +126,7 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 100,
});
@@ -133,7 +135,7 @@ mod tests {
let p = &app.world().resource::<ProgressResource>().0;
assert_eq!(p.challenge_index, 1);
let events = app.world().resource::<Events<ChallengeAdvancedEvent>>();
let events = app.world().resource::<Messages<ChallengeAdvancedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -145,7 +147,7 @@ mod tests {
fn classic_win_does_not_advance_challenge_index() {
let mut app = headless_app();
// Default GameStateResource is Classic mode.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 100,
});
@@ -154,7 +156,7 @@ mod tests {
let p = &app.world().resource::<ProgressResource>().0;
assert_eq!(p.challenge_index, 0);
let events = app.world().resource::<Events<ChallengeAdvancedEvent>>();
let events = app.world().resource::<Messages<ChallengeAdvancedEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
@@ -168,7 +170,7 @@ mod tests {
.press(KeyCode::KeyX);
app.update();
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
@@ -188,7 +190,7 @@ mod tests {
.press(KeyCode::KeyX);
app.update();
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -211,13 +213,13 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 100,
});
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert_eq!(fired.len(), 1, "exactly one toast must fire on challenge win");
@@ -231,13 +233,13 @@ mod tests {
fn classic_win_does_not_fire_challenge_complete_toast() {
let mut app = headless_app();
// Default mode is Classic.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 100,
});
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
assert!(
cursor.read(events).next().is_none(),
@@ -254,7 +256,7 @@ mod tests {
.press(KeyCode::KeyX);
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert_eq!(fired.len(), 1, "must show a toast explaining the lock");
+4 -5
View File
@@ -12,8 +12,7 @@
//! The tint is cleared to default the frame the drag ends.
use bevy::prelude::*;
use bevy::window::{PrimaryWindow, SystemCursorIcon};
use bevy::winit::cursor::CursorIcon;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::card::Suit;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
@@ -31,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 {
@@ -52,7 +52,7 @@ fn update_cursor_icon(
game: Option<Res<GameStateResource>>,
mut commands: Commands,
) {
let Ok((win_entity, window)) = windows.get_single() else { return };
let Ok((win_entity, window)) = windows.single() else { return };
if !drag.is_idle() {
commands
@@ -63,7 +63,7 @@ fn update_cursor_icon(
let hovering = (|| {
let cursor = window.cursor_position()?;
let (camera, cam_xf) = cameras.get_single().ok()?;
let (camera, cam_xf) = cameras.single().ok()?;
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
let layout = layout.as_ref()?.0.clone();
let game = game.as_ref()?;
@@ -214,7 +214,6 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use solitaire_core::card::{Card, Rank};
#[test]
fn point_in_rect_center_is_inside() {
+29 -27
View File
@@ -43,7 +43,7 @@ pub struct DailyChallengeResource {
/// Fired when the player presses C to start the daily challenge.
/// Carries the current goal description so it can be displayed as a toast.
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct DailyGoalAnnouncementEvent(pub String);
impl DailyChallengeResource {
@@ -60,7 +60,7 @@ impl DailyChallengeResource {
}
/// Fired when the player has just completed today's daily challenge.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct DailyChallengeCompletedEvent {
pub date: NaiveDate,
pub streak: u32,
@@ -71,17 +71,19 @@ 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 {
fn build(&self, app: &mut App) {
app.insert_resource(DailyChallengeResource::for_today())
.init_resource::<DailyChallengeTask>()
.add_event::<DailyChallengeCompletedEvent>()
.add_event::<DailyGoalAnnouncementEvent>()
.add_event::<GameWonEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<XpAwardedEvent>()
.add_message::<DailyChallengeCompletedEvent>()
.add_message::<DailyGoalAnnouncementEvent>()
.add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<XpAwardedEvent>()
.add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge)
// record/award after the base ProgressUpdate so we don't fight
@@ -145,14 +147,14 @@ fn poll_server_challenge(
#[allow(clippy::too_many_arguments)]
fn handle_daily_completion(
mut wins: EventReader<GameWonEvent>,
mut wins: MessageReader<GameWonEvent>,
daily: Res<DailyChallengeResource>,
game: Res<GameStateResource>,
mut progress: ResMut<ProgressResource>,
path: Res<ProgressStoragePath>,
mut completed: EventWriter<DailyChallengeCompletedEvent>,
mut xp_awarded: EventWriter<XpAwardedEvent>,
mut toast: EventWriter<InfoToastEvent>,
mut completed: MessageWriter<DailyChallengeCompletedEvent>,
mut xp_awarded: MessageWriter<XpAwardedEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
for ev in wins.read() {
if game.0.seed != daily.seed {
@@ -174,28 +176,28 @@ fn handle_daily_completion(
continue;
}
progress.0.add_xp(DAILY_BONUS_XP);
xp_awarded.send(XpAwardedEvent { amount: DAILY_BONUS_XP });
xp_awarded.write(XpAwardedEvent { amount: DAILY_BONUS_XP });
if let Some(target) = &path.0 {
if let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after daily completion: {e}");
}
}
completed.send(DailyChallengeCompletedEvent {
completed.write(DailyChallengeCompletedEvent {
date: daily.date,
streak: progress.0.daily_challenge_streak,
});
toast.send(InfoToastEvent("Daily challenge complete! +100 XP".to_string()));
toast.write(InfoToastEvent("Daily challenge complete! +100 XP".to_string()));
}
}
fn handle_start_daily_request(
keys: Res<ButtonInput<KeyCode>>,
daily: Res<DailyChallengeResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut announce: EventWriter<DailyGoalAnnouncementEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut announce: MessageWriter<DailyGoalAnnouncementEvent>,
) {
if keys.just_pressed(KeyCode::KeyC) {
new_game.send(NewGameRequestEvent {
new_game.write(NewGameRequestEvent {
seed: Some(daily.seed),
mode: None,
});
@@ -203,7 +205,7 @@ fn handle_start_daily_request(
.goal_description
.clone()
.unwrap_or_else(|| "Daily Challenge".to_string());
announce.send(DailyGoalAnnouncementEvent(desc));
announce.write(DailyGoalAnnouncementEvent(desc));
}
}
@@ -244,7 +246,7 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(daily_seed, DrawMode::DrawOne);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
@@ -255,7 +257,7 @@ mod tests {
// +100 from the daily bonus
assert!(progress.total_xp >= DAILY_BONUS_XP);
let events = app.world().resource::<Events<DailyChallengeCompletedEvent>>();
let events = app.world().resource::<Messages<DailyChallengeCompletedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -270,7 +272,7 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
@@ -279,7 +281,7 @@ mod tests {
let progress = &app.world().resource::<ProgressResource>().0;
assert_eq!(progress.daily_challenge_streak, 0);
let events = app.world().resource::<Events<DailyChallengeCompletedEvent>>();
let events = app.world().resource::<Messages<DailyChallengeCompletedEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
@@ -291,13 +293,13 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(daily_seed, DrawMode::DrawOne);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
app.update();
// Re-send win.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
@@ -317,7 +319,7 @@ mod tests {
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -337,7 +339,7 @@ mod tests {
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Events<DailyGoalAnnouncementEvent>>();
let events = app.world().resource::<Messages<DailyGoalAnnouncementEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert_eq!(fired.len(), 1);
@@ -355,7 +357,7 @@ mod tests {
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Events<DailyGoalAnnouncementEvent>>();
let events = app.world().resource::<Messages<DailyGoalAnnouncementEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert_eq!(fired.len(), 1);
+29 -16
View File
@@ -1,13 +1,13 @@
//! Cross-system events used by the engine's plugins.
use bevy::prelude::Event;
use bevy::prelude::Message;
use solitaire_core::game_state::GameMode;
use solitaire_core::pile::PileType;
use solitaire_data::AchievementRecord;
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
/// consumed by `GamePlugin`.
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct MoveRequestEvent {
pub from: PileType,
pub to: PileType,
@@ -15,16 +15,16 @@ pub struct MoveRequestEvent {
}
/// Request to draw from the stock (or recycle waste when stock is empty).
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct DrawRequestEvent;
/// Request to undo the most recent state change.
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct UndoRequestEvent;
/// Request to start a new game. `seed = None` uses a system-time seed.
/// `mode = None` reuses the current game's `GameMode`.
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct NewGameRequestEvent {
pub seed: Option<u64>,
pub mode: Option<GameMode>,
@@ -32,13 +32,13 @@ pub struct NewGameRequestEvent {
/// Fired by `GamePlugin` after any successful state mutation. Rendering and
/// score-display systems listen for this to refresh.
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct StateChangedEvent;
/// Fired by input/UI systems when a player attempts to drop dragged cards
/// on a real pile but the move violates the rules. Drives the
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct MoveRejectedEvent {
pub from: PileType,
pub to: PileType,
@@ -46,14 +46,14 @@ pub struct MoveRejectedEvent {
}
/// Fired once when the active game transitions to won.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct GameWonEvent {
pub score: i32,
pub time_seconds: u64,
}
/// Fired when a card's face-up state changes during gameplay.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct CardFlippedEvent(pub u32);
/// Fired by the flip animation at its midpoint — the instant the card face
@@ -62,37 +62,37 @@ pub struct CardFlippedEvent(pub u32);
/// Audio systems should listen to this event rather than `CardFlippedEvent`
/// so the flip sound is synchronised with the visual reveal, not the move
/// that triggered the animation.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct CardFaceRevealedEvent(pub u32);
/// Achievement unlocked notification carrying the full `AchievementRecord` for
/// the newly unlocked achievement. Consumed by the toast renderer and any
/// persistence/UI systems that need unlock metadata.
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct AchievementUnlockedEvent(pub AchievementRecord);
/// Request to manually trigger a sync pull from the active backend.
///
/// Fired by the Settings panel "Sync Now" button. `SyncPlugin` responds by
/// starting a new pull task if one is not already in flight.
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ManualSyncRequestEvent;
/// Fired by `InputPlugin` when N is pressed while a game is in progress
/// but confirmation has not yet been received. The animation plugin shows
/// a "Press N again to confirm" toast. A second N press within the
/// confirmation window sends `NewGameRequestEvent`.
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct NewGameConfirmEvent;
/// Generic informational toast message. Any system can fire this to display
/// a short string to the player, e.g. "Locked — reach level 5".
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct InfoToastEvent(pub String);
/// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
/// animation layer can display a "+N XP" toast alongside the win cascade.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct XpAwardedEvent {
pub amount: u64,
}
@@ -100,5 +100,18 @@ pub struct XpAwardedEvent {
/// Fired by `InputPlugin` when the player presses G to forfeit the current
/// game. Consumed by `StatsPlugin` which records the abandoned game,
/// persists stats, and starts a fresh deal.
#[derive(Event, Debug, Clone, Copy, Default)]
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ForfeitEvent;
/// Fired when the player requests a hint (H key). Carries the source card ID
/// and destination pile for visual highlighting.
///
/// Consumed by `CardPlugin` (to apply `HintHighlight` on the card entity) and
/// `TablePlugin` (to tint the destination `PileMarker` gold for 2 s).
#[derive(Message, Debug, Clone)]
pub struct HintVisualEvent {
/// The `Card::id` of the source card to be highlighted.
pub source_card_id: u32,
/// The destination pile whose `PileMarker` should be tinted gold.
pub dest_pile: solitaire_core::pile::PileType,
}
+3 -3
View File
@@ -184,7 +184,7 @@ impl Plugin for FeedbackAnimPlugin {
/// Inserts `ShakeAnim` on all card entities belonging to the destination pile
/// when a `MoveRejectedEvent` fires.
fn start_shake_anim(
mut events: EventReader<MoveRejectedEvent>,
mut events: MessageReader<MoveRejectedEvent>,
game: Res<GameStateResource>,
card_entities: Query<(Entity, &CardEntity, &Transform)>,
mut commands: Commands,
@@ -243,7 +243,7 @@ fn tick_shake_anim(
/// Inserts `SettleAnim` on the top card of every non-empty pile when
/// `StateChangedEvent` fires.
fn start_settle_anim(
mut events: EventReader<StateChangedEvent>,
mut events: MessageReader<StateChangedEvent>,
game: Res<GameStateResource>,
card_entities: Query<(Entity, &CardEntity)>,
mut commands: Commands,
@@ -304,7 +304,7 @@ fn tick_settle_anim(
/// and fires the deal animation for every card entity currently in the world.
/// The stagger is looked up from `SettingsResource` via `deal_stagger_secs_for_speed`.
fn start_deal_anim(
mut events: EventReader<NewGameRequestEvent>,
mut events: MessageReader<NewGameRequestEvent>,
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
+78 -78
View File
@@ -71,16 +71,16 @@ impl Plugin for GamePlugin {
.insert_resource(GameStatePath(path))
.init_resource::<DragState>()
.init_resource::<SyncStatusResource>()
.add_event::<MoveRequestEvent>()
.add_event::<DrawRequestEvent>()
.add_event::<UndoRequestEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<StateChangedEvent>()
.add_event::<crate::events::MoveRejectedEvent>()
.add_event::<GameWonEvent>()
.add_event::<crate::events::CardFlippedEvent>()
.add_event::<crate::events::AchievementUnlockedEvent>()
.add_event::<InfoToastEvent>()
.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<StateChangedEvent>()
.add_message::<crate::events::MoveRejectedEvent>()
.add_message::<GameWonEvent>()
.add_message::<crate::events::CardFlippedEvent>()
.add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<InfoToastEvent>()
.add_systems(
Update,
(
@@ -152,9 +152,9 @@ fn seed_from_system_time() -> u64 {
#[allow(clippy::too_many_arguments)]
fn handle_new_game(
mut commands: Commands,
mut new_game: EventReader<NewGameRequestEvent>,
mut new_game: MessageReader<NewGameRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: EventWriter<StateChangedEvent>,
mut changed: MessageWriter<StateChangedEvent>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
path: Option<Res<GameStatePath>>,
confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>,
@@ -169,7 +169,7 @@ fn handle_new_game(
if needs_confirm && !confirm_already_open {
// Despawn any stale game-over overlay before showing confirm dialog.
for entity in &game_over_screens {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
spawn_confirm_dialog(&mut commands, *ev);
continue;
@@ -177,10 +177,10 @@ fn handle_new_game(
// Despawn confirm and game-over overlays before starting the new game.
for entity in &confirm_screens {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
for entity in &game_over_screens {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
@@ -199,7 +199,7 @@ fn handle_new_game(
warn!("game_state: failed to delete saved game: {e}");
}
}
changed.send(StateChangedEvent);
changed.write(StateChangedEvent);
}
}
@@ -238,10 +238,10 @@ fn spawn_confirm_dialog(commands: &mut Commands, original_request: NewGameReques
row_gap: Val::Px(20.0),
min_width: Val::Px(360.0),
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.12, 0.15)),
BorderRadius::all(Val::Px(12.0)),
))
.with_children(|card| {
// Heading
@@ -287,9 +287,9 @@ fn handle_confirm_input(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
screens: Query<(Entity, &OriginalNewGameRequest), With<ConfirmNewGameScreen>>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
) {
let Ok((entity, original)) = screens.get_single() else {
let Ok((entity, original)) = screens.single() else {
return;
};
let Some(keys) = keys else {
@@ -300,24 +300,24 @@ fn handle_confirm_input(
let cancelled = keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape);
if confirmed {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
// Re-send with move_count already 0 would bypass the dialog next time.
// We fire the event — handle_new_game will skip the dialog because
// the screen is despawned before the next read.
new_game.send(NewGameRequestEvent {
new_game.write(NewGameRequestEvent {
seed: original.0.seed,
mode: original.0.mode,
});
} else if cancelled {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
}
fn handle_draw(
mut draws: EventReader<DrawRequestEvent>,
mut draws: MessageReader<DrawRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: EventWriter<StateChangedEvent>,
mut flipped: EventWriter<CardFlippedEvent>,
mut changed: MessageWriter<StateChangedEvent>,
mut flipped: MessageWriter<CardFlippedEvent>,
) {
use solitaire_core::pile::PileType;
@@ -347,9 +347,9 @@ fn handle_draw(
Ok(()) => {
// Fire a flip event for each card that moved from stock to waste.
for id in drawn_ids {
flipped.send(CardFlippedEvent(id));
flipped.write(CardFlippedEvent(id));
}
changed.send(StateChangedEvent);
changed.write(StateChangedEvent);
}
Err(e) => warn!("draw rejected: {e}"),
}
@@ -357,11 +357,11 @@ fn handle_draw(
}
fn handle_move(
mut moves: EventReader<MoveRequestEvent>,
mut moves: MessageReader<MoveRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: EventWriter<StateChangedEvent>,
mut won: EventWriter<GameWonEvent>,
mut flipped: EventWriter<crate::events::CardFlippedEvent>,
mut changed: MessageWriter<StateChangedEvent>,
mut won: MessageWriter<GameWonEvent>,
mut flipped: MessageWriter<crate::events::CardFlippedEvent>,
path: Option<Res<GameStatePath>>,
) {
for ev in moves.read() {
@@ -385,12 +385,12 @@ fn handle_move(
.and_then(|p| p.cards.last())
.is_some_and(|c| c.id == fid && c.face_up)
{
flipped.send(crate::events::CardFlippedEvent(fid));
flipped.write(crate::events::CardFlippedEvent(fid));
}
}
changed.send(StateChangedEvent);
changed.write(StateChangedEvent);
if !was_won && game.0.is_won {
won.send(GameWonEvent {
won.write(GameWonEvent {
score: game.0.score,
time_seconds: game.0.elapsed_seconds,
});
@@ -408,20 +408,20 @@ fn handle_move(
}
fn handle_undo(
mut undos: EventReader<UndoRequestEvent>,
mut undos: MessageReader<UndoRequestEvent>,
mut game: ResMut<GameStateResource>,
mut changed: EventWriter<StateChangedEvent>,
mut toast: EventWriter<InfoToastEvent>,
mut changed: MessageWriter<StateChangedEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
use solitaire_core::error::MoveError;
for _ in undos.read() {
match game.0.undo() {
Ok(()) => {
changed.send(StateChangedEvent);
changed.write(StateChangedEvent);
}
Err(MoveError::UndoStackEmpty) => {
toast.send(InfoToastEvent("Nothing to undo".to_string()));
toast.write(InfoToastEvent("Nothing to undo".to_string()));
}
Err(e) => warn!("undo rejected: {e}"),
}
@@ -500,9 +500,9 @@ pub fn has_legal_moves(game: &GameState) -> bool {
/// game is won.
fn check_no_moves(
mut commands: Commands,
mut events: EventReader<StateChangedEvent>,
mut events: MessageReader<StateChangedEvent>,
game: Res<GameStateResource>,
mut toast: EventWriter<InfoToastEvent>,
mut toast: MessageWriter<InfoToastEvent>,
mut already_fired: Local<bool>,
game_over_screens: Query<Entity, With<GameOverScreen>>,
) {
@@ -523,7 +523,7 @@ fn check_no_moves(
let moves_ok = has_legal_moves(&game.0);
if moves_ok || game.0.is_won {
for entity in &game_over_screens {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
}
@@ -532,7 +532,7 @@ fn check_no_moves(
}
if !moves_ok && !*already_fired {
toast.send(InfoToastEvent(
toast.write(InfoToastEvent(
"No moves available \u{2014} press D to draw or N for a new game".to_string(),
));
*already_fired = true;
@@ -574,10 +574,10 @@ fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
row_gap: Val::Px(16.0),
min_width: Val::Px(340.0),
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.08, 0.08)),
BorderRadius::all(Val::Px(12.0)),
))
.with_children(|card| {
// Header — explains why the overlay appeared.
@@ -628,8 +628,8 @@ fn handle_game_over_input(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
screens: Query<Entity, With<GameOverScreen>>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut undo: EventWriter<UndoRequestEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut undo: MessageWriter<UndoRequestEvent>,
) {
if screens.is_empty() {
return;
@@ -639,12 +639,12 @@ fn handle_game_over_input(
};
if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) {
new_game.send(NewGameRequestEvent::default());
new_game.write(NewGameRequestEvent::default());
} else if keys.just_pressed(KeyCode::KeyU) {
for entity in &screens {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
undo.send(UndoRequestEvent);
undo.write(UndoRequestEvent);
}
}
@@ -685,7 +685,7 @@ fn auto_save_game_state(
/// `save_game_state_to` helper skips them). Blocking on exit is acceptable
/// because the game loop is already shutting down.
fn save_game_state_on_exit(
mut exit_events: EventReader<AppExit>,
mut exit_events: MessageReader<AppExit>,
game: Res<GameStateResource>,
path: Res<GameStatePath>,
) {
@@ -739,7 +739,7 @@ mod tests {
.cards
.len();
app.world_mut().send_event(DrawRequestEvent);
app.world_mut().write_message(DrawRequestEvent);
app.update();
let stock_after = app
@@ -763,9 +763,9 @@ mod tests {
#[test]
fn draw_request_fires_state_changed_event() {
let mut app = test_app(42);
app.world_mut().send_event(DrawRequestEvent);
app.world_mut().write_message(DrawRequestEvent);
app.update();
let events = app.world().resource::<Events<StateChangedEvent>>();
let events = app.world().resource::<Messages<StateChangedEvent>>();
let mut reader = events.get_cursor();
assert!(reader.read(events).next().is_some());
}
@@ -773,9 +773,9 @@ mod tests {
#[test]
fn undo_after_draw_restores_state() {
let mut app = test_app(42);
app.world_mut().send_event(DrawRequestEvent);
app.world_mut().write_message(DrawRequestEvent);
app.update();
app.world_mut().send_event(UndoRequestEvent);
app.world_mut().write_message(UndoRequestEvent);
app.update();
let g = &app.world().resource::<GameStateResource>().0;
assert_eq!(g.piles[&PileType::Stock].cards.len(), 24);
@@ -795,7 +795,7 @@ mod tests {
.map(|c| c.id)
.collect();
app.world_mut().send_event(NewGameRequestEvent { seed: Some(999), mode: None });
app.world_mut().write_message(NewGameRequestEvent { seed: Some(999), mode: None });
app.update();
let after: Vec<u32> = app
@@ -858,13 +858,13 @@ mod tests {
fn invalid_move_does_not_fire_state_changed() {
let mut app = test_app(42);
// Stock -> Waste is InvalidDestination; no state change expected.
app.world_mut().send_event(MoveRequestEvent {
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Stock,
to: PileType::Waste,
count: 1,
});
app.update();
let events = app.world().resource::<Events<StateChangedEvent>>();
let events = app.world().resource::<Messages<StateChangedEvent>>();
let mut reader = events.get_cursor();
assert!(reader.read(events).next().is_none());
}
@@ -892,7 +892,7 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(7654, DrawMode::DrawOne);
app.world_mut().send_event(AppExit::Success);
app.world_mut().write_message(AppExit::Success);
app.update();
let loaded = load_game_state_from(&path).expect("file should exist after exit");
@@ -913,7 +913,7 @@ mod tests {
let mut app = test_app(1);
app.insert_resource(GameStatePath(Some(path.clone())));
app.world_mut().send_event(NewGameRequestEvent { seed: Some(2), mode: None });
app.world_mut().write_message(NewGameRequestEvent { seed: Some(2), mode: None });
app.update();
assert!(!path.exists(), "saved file should be deleted after new game");
@@ -949,14 +949,14 @@ mod tests {
t.cards.push(Card { id: 902, suit: Suit::Clubs, rank: Rank::King, face_up: true });
}
app.world_mut().send_event(MoveRequestEvent {
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
count: 1,
});
app.update();
let events = app.world().resource::<Events<crate::events::CardFlippedEvent>>();
let events = app.world().resource::<Messages<crate::events::CardFlippedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert_eq!(fired.len(), 1, "CardFlippedEvent must fire when a face-down card is exposed");
@@ -1035,14 +1035,14 @@ mod tests {
.push(Card { id: 912, suit: Suit::Spades, rank: Rank::King, face_up: true });
}
app.world_mut().send_event(MoveRequestEvent {
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
count: 1,
});
app.update();
let events = app.world().resource::<Events<crate::events::CardFlippedEvent>>();
let events = app.world().resource::<Messages<crate::events::CardFlippedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert!(fired.is_empty(), "no flip event when exposed card was already face-up");
@@ -1125,7 +1125,7 @@ mod tests {
// Simulate an active game with moves made.
app.world_mut().resource_mut::<GameStateResource>().0.move_count = 5;
app.world_mut()
.send_event(NewGameRequestEvent { seed: None, mode: None });
.write_message(NewGameRequestEvent { seed: None, mode: None });
app.update();
let count = app
@@ -1146,7 +1146,7 @@ mod tests {
"test assumes a fresh game with no moves"
);
app.world_mut()
.send_event(NewGameRequestEvent { seed: None, mode: None });
.write_message(NewGameRequestEvent { seed: None, mode: None });
app.update();
let count = app
@@ -1165,7 +1165,7 @@ mod tests {
fn game_over_screen_absent_when_moves_available() {
// A fresh game always has moves (stock is non-empty).
let mut app = test_app_with_input(42);
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update();
let count = app
@@ -1201,7 +1201,7 @@ mod tests {
});
}
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update();
let count = app
@@ -1240,7 +1240,7 @@ mod tests {
});
}
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update();
// Collect all Text values that are children of the GameOverScreen entity tree.
@@ -1295,7 +1295,7 @@ mod tests {
face_up: true,
});
}
app.world_mut().send_event(StateChangedEvent);
app.world_mut().write_message(StateChangedEvent);
app.update();
// Confirm the overlay is present.
@@ -1309,7 +1309,7 @@ mod tests {
);
// Clear the NewGameRequestEvent queue so we start with a clean slate.
app.world_mut().resource_mut::<Events<NewGameRequestEvent>>().clear();
app.world_mut().resource_mut::<Messages<NewGameRequestEvent>>().clear();
// Simulate Escape press.
{
@@ -1320,7 +1320,7 @@ mod tests {
app.update();
// NewGameRequestEvent must have been fired.
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut reader = events.get_cursor();
assert!(
reader.read(events).next().is_some(),
@@ -1338,10 +1338,10 @@ mod tests {
fn undo_on_empty_stack_fires_info_toast() {
let mut app = test_app(42);
// Fresh game — undo stack is empty, so undo() returns UndoStackEmpty.
app.world_mut().send_event(UndoRequestEvent);
app.world_mut().write_message(UndoRequestEvent);
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut reader = events.get_cursor();
let fired: Vec<_> = reader.read(events).collect();
assert_eq!(fired.len(), 1, "exactly one InfoToastEvent must fire on empty-stack undo");
@@ -1357,15 +1357,15 @@ mod tests {
fn undo_after_draw_does_not_fire_info_toast() {
let mut app = test_app(42);
// Make a move so the undo stack is non-empty.
app.world_mut().send_event(DrawRequestEvent);
app.world_mut().write_message(DrawRequestEvent);
app.update();
// Clear events from the draw so we start with a clean slate.
app.world_mut().resource_mut::<Events<InfoToastEvent>>().clear();
app.world_mut().resource_mut::<Messages<InfoToastEvent>>().clear();
app.world_mut().send_event(UndoRequestEvent);
app.world_mut().write_message(UndoRequestEvent);
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut reader = events.get_cursor();
let fired: Vec<_> = reader.read(events).collect();
assert!(
+4 -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 {
@@ -25,8 +27,8 @@ fn toggle_help_screen(
if !keys.just_pressed(KeyCode::F1) {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_help_screen(&mut commands);
}
+3 -3
View File
@@ -31,8 +31,8 @@ fn toggle_home_screen(
if !keys.just_pressed(KeyCode::KeyM) {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_home_screen(&mut commands, &game);
}
@@ -139,7 +139,7 @@ fn spawn_home_screen(commands: &mut Commands, game: &GameStateResource) {
});
}
fn spawn_shortcut_row(parent: &mut ChildBuilder, key: &str, action: &str) {
fn spawn_shortcut_row(parent: &mut ChildSpawnerCommands, key: &str, action: &str) {
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
+14 -13
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 {
@@ -325,7 +326,7 @@ fn update_hud(
if game.is_changed() {
let g = &game.0;
let is_zen = g.mode == GameMode::Zen;
if let Ok(mut t) = score_q.get_single_mut() {
if let Ok(mut t) = score_q.single_mut() {
// Zen mode suppresses score display per spec ("No score display").
**t = if is_zen {
String::new()
@@ -333,10 +334,10 @@ fn update_hud(
format!("Score: {}", g.score)
};
}
if let Ok(mut t) = moves_q.get_single_mut() {
if let Ok(mut t) = moves_q.single_mut() {
**t = format!("Moves: {}", g.move_count);
}
if let Ok(mut t) = mode_q.get_single_mut() {
if let Ok(mut t) = mode_q.single_mut() {
**t = match g.mode {
GameMode::Classic => match g.draw_mode {
DrawMode::DrawOne => String::new(),
@@ -349,7 +350,7 @@ fn update_hud(
}
// --- Daily challenge constraint (with time-low colour warning) ---
if let Ok((mut t, mut color)) = challenge_q.get_single_mut() {
if let Ok((mut t, mut color)) = challenge_q.single_mut() {
if g.is_won {
**t = String::new();
} else if let Some(dc) = daily.as_deref() {
@@ -364,7 +365,7 @@ fn update_hud(
}
// --- Undo count ---
if let Ok((mut t, mut color)) = undos_q.get_single_mut() {
if let Ok((mut t, mut color)) = undos_q.single_mut() {
let count = g.undo_count;
if count == 0 {
**t = String::new();
@@ -377,7 +378,7 @@ fn update_hud(
}
// --- Recycle counter (both modes, hidden until first recycle) ---
if let Ok(mut t) = recycles_q.get_single_mut() {
if let Ok(mut t) = recycles_q.single_mut() {
**t = if g.recycle_count > 0 {
format!("Recycles: {}", g.recycle_count)
} else {
@@ -386,7 +387,7 @@ fn update_hud(
}
// --- Draw-cycle indicator (Draw-Three mode only) ---
if let Ok(mut t) = draw_cycle_q.get_single_mut() {
if let Ok(mut t) = draw_cycle_q.single_mut() {
**t = if g.is_won || g.draw_mode != DrawMode::DrawThree {
// Hide when not in Draw-Three or after the game is won.
String::new()
@@ -405,7 +406,7 @@ fn update_hud(
let is_zen = game.0.mode == GameMode::Zen;
let update_time = (ta_active || game.is_changed()) && !is_zen;
if update_time {
if let Ok(mut t) = time_q.get_single_mut() {
if let Ok(mut t) = time_q.single_mut() {
if let Some(ta) = time_attack.as_ref().filter(|ta| ta.active) {
let remaining = ta.remaining_secs.max(0.0) as u64;
let m = remaining / 60;
@@ -422,7 +423,7 @@ fn update_hud(
// Clear the time display immediately whenever Zen mode is active —
// do not guard on game.is_changed() so it clears on the same frame
// the player presses Z, before any move is made.
if let Ok(mut t) = time_q.get_single_mut() {
if let Ok(mut t) = time_q.single_mut() {
**t = String::new();
}
}
@@ -432,7 +433,7 @@ fn update_hud(
let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
if ac_changed || game.is_changed() {
if let Ok(mut t) = auto_q.get_single_mut() {
if let Ok(mut t) = auto_q.single_mut() {
**t = if ac_active {
"AUTO".to_string()
} else {
@@ -451,7 +452,7 @@ fn update_selection_hud(
selection: Option<Res<SelectionState>>,
mut q: Query<&mut Text, With<HudSelection>>,
) {
let Ok(mut t) = q.get_single_mut() else { return };
let Ok(mut t) = q.single_mut() else { return };
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
None => String::new(),
Some(PileType::Waste) => "▶ Waste".to_string(),
@@ -475,12 +476,12 @@ fn update_selection_hud(
/// to debounce so the toast only appears on the leading edge.
fn announce_auto_complete(
auto_complete: Option<Res<AutoCompleteState>>,
mut toast: EventWriter<InfoToastEvent>,
mut toast: MessageWriter<InfoToastEvent>,
mut was_active: Local<bool>,
) {
let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
if now_active && !*was_active {
toast.send(InfoToastEvent("Auto-completing...".to_string()));
toast.write(InfoToastEvent("Auto-completing...".to_string()));
}
*was_active = now_active;
}
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>,
}
+15 -14
View File
@@ -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 {
@@ -112,8 +113,8 @@ fn toggle_leaderboard_screen(
if !keys.just_pressed(KeyCode::KeyL) {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
closed_flag.0 = true;
return;
}
@@ -174,7 +175,7 @@ fn update_leaderboard_panel(
return;
}
for entity in &screens {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
spawn_leaderboard_screen(&mut commands, data.0.as_deref());
}
}
@@ -218,18 +219,18 @@ fn handle_opt_in_button(
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure.
fn poll_opt_in_task(
mut task_res: ResMut<OptInTask>,
mut toast: EventWriter<InfoToastEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
task_res.0 = None;
match result {
Ok(()) => {
toast.send(InfoToastEvent("Opted in to leaderboard".to_string()));
toast.write(InfoToastEvent("Opted in to leaderboard".to_string()));
}
Err(e) => {
warn!("leaderboard opt-in failed: {e}");
toast.send(InfoToastEvent("Leaderboard update failed".to_string()));
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
}
}
}
@@ -258,18 +259,18 @@ fn handle_opt_out_button(
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure.
fn poll_opt_out_task(
mut task_res: ResMut<OptOutTask>,
mut toast: EventWriter<InfoToastEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
task_res.0 = None;
match result {
Ok(()) => {
toast.send(InfoToastEvent("Opted out of leaderboard".to_string()));
toast.write(InfoToastEvent("Opted out of leaderboard".to_string()));
}
Err(e) => {
warn!("leaderboard opt-out failed: {e}");
toast.send(InfoToastEvent("Leaderboard update failed".to_string()));
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
}
}
}
@@ -305,10 +306,10 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
min_width: Val::Px(420.0),
max_height: Val::Percent(80.0),
overflow: Overflow::clip_y(),
border_radius: BorderRadius::all(Val::Px(8.0)),
..default()
},
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
BorderRadius::all(Val::Px(8.0)),
))
.with_children(|card| {
// Header
@@ -347,10 +348,10 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
Node {
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.18, 0.35, 0.50)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -366,10 +367,10 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
Node {
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.42, 0.15, 0.15)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -454,7 +455,7 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
});
}
fn header_cell(parent: &mut ChildBuilder, text: &str, width: f32) {
fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32) {
parent.spawn((
Text::new(text.to_string()),
TextFont { font_size: 13.0, ..default() },
@@ -463,7 +464,7 @@ fn header_cell(parent: &mut ChildBuilder, text: &str, width: f32) {
));
}
fn data_cell(parent: &mut ChildBuilder, text: &str, width: f32, color: Color) {
fn data_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, color: Color) {
parent.spawn((
Text::new(text.to_string()),
TextFont { font_size: 15.0, ..default() },
+20 -5
View File
@@ -1,5 +1,6 @@
//! Bevy integration layer for Solitaire Quest.
pub mod card_animation;
pub mod achievement_plugin;
pub mod animation_plugin;
pub mod auto_complete_plugin;
@@ -41,18 +42,32 @@ pub use daily_challenge_plugin::{
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
pub use card_animation::{
CardAnimation, CardAnimationPlugin, MotionCurve, WinCascadePlugin,
retarget_animation, sample_curve, compute_duration, cascade_delay, micro_vary,
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,
FeedbackAnimPlugin, SettleAnim, ShakeAnim,
};
pub use auto_complete_plugin::AutoCompletePlugin;
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use card_plugin::{CardEntity, CardLabel, CardPlugin, HintHighlight, RightClickHighlight};
pub use card_plugin::{
CardEntity, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer, RightClickHighlight,
RightClickHighlightTimer,
};
pub use cursor_plugin::CursorPlugin;
pub use events::{
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, ForfeitEvent, GameWonEvent,
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};
@@ -71,7 +86,7 @@ pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScroll
pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
pub use table_plugin::{PileMarker, TableBackground, TablePlugin};
pub use table_plugin::{HintPileHighlight, PileMarker, TableBackground, TablePlugin};
pub use time_attack_plugin::{
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
};
+4 -2
View File
@@ -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 {
@@ -59,7 +61,7 @@ fn dismiss_on_any_input(
path: Option<Res<SettingsStoragePath>>,
screens: Query<Entity, With<OnboardingScreen>>,
) {
let Ok(entity) = screens.get_single() else {
let Ok(entity) = screens.single() else {
return;
};
let pressed = keys.get_just_pressed().next().is_some()
@@ -67,7 +69,7 @@ fn dismiss_on_any_input(
if !pressed {
return;
}
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
settings.0.first_run_complete = true;
persist(path.as_deref().map(|p| &p.0), &settings.0);
}
+48 -11
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,16 +49,25 @@ 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 {
fn build(&self, app: &mut App) {
// Both add_event calls are idempotent — other plugins may register these
// events first, but calling add_event again is always safe.
app.add_event::<SettingsChangedEvent>()
.add_event::<StateChangedEvent>()
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,
),
);
}
}
@@ -74,11 +84,17 @@ fn toggle_pause(
stats: Option<Res<StatsResource>>,
settings: Option<Res<SettingsResource>>,
mut drag: Option<ResMut<DragState>>,
mut changed: EventWriter<StateChangedEvent>,
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() {
@@ -90,12 +106,12 @@ fn toggle_pause(
if let Some(ref mut d) = drag {
if !d.is_idle() {
d.clear();
changed.send(StateChangedEvent);
changed.write(StateChangedEvent);
return;
}
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
paused.0 = false;
} else {
// Snapshot current level and streak at pause time.
@@ -125,7 +141,7 @@ fn handle_pause_draw_toggle(
paused: Res<PausedResource>,
settings: Option<ResMut<SettingsResource>>,
path: Option<Res<SettingsStoragePath>>,
mut changed: EventWriter<SettingsChangedEvent>,
mut changed: MessageWriter<SettingsChangedEvent>,
) {
if !paused.0 {
return;
@@ -146,7 +162,7 @@ fn handle_pause_draw_toggle(
}
}
}
changed.send(SettingsChangedEvent(settings.0.clone()));
changed.write(SettingsChangedEvent(settings.0.clone()));
}
}
@@ -224,10 +240,10 @@ fn spawn_pause_screen(
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|btn| {
btn.spawn((
@@ -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
// -----------------------------------------------------------------------
@@ -496,7 +533,7 @@ mod tests {
);
// Verify a SettingsChangedEvent was fired.
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
let count = cursor.read(events).count();
assert!(count >= 1, "SettingsChangedEvent must be fired on toggle");
+3 -3
View File
@@ -42,8 +42,8 @@ fn toggle_profile_screen(
if !keys.just_pressed(KeyCode::KeyP) {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_profile_screen(
&mut commands,
@@ -246,7 +246,7 @@ fn spawn_profile_screen(
}
/// Spawn a fixed-height vertical spacer node.
fn spawn_spacer(parent: &mut ChildBuilder, height_px: f32) {
fn spawn_spacer(parent: &mut ChildSpawnerCommands, height_px: f32) {
parent.spawn(Node {
height: Val::Px(height_px),
..default()
+26 -21
View File
@@ -25,7 +25,7 @@ pub struct ProgressResource(pub PlayerProgress);
pub struct ProgressStoragePath(pub Option<PathBuf>);
/// Fired when a win pushes the player to a new level.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
pub struct LevelUpEvent {
pub previous_level: u32,
pub new_level: u32,
@@ -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>,
}
@@ -64,9 +69,9 @@ impl Plugin for ProgressPlugin {
};
app.insert_resource(ProgressResource(loaded))
.insert_resource(ProgressStoragePath(self.storage_path.clone()))
.add_event::<LevelUpEvent>()
.add_event::<XpAwardedEvent>()
.add_event::<GameWonEvent>()
.add_message::<LevelUpEvent>()
.add_message::<XpAwardedEvent>()
.add_message::<GameWonEvent>()
.add_systems(
Update,
award_xp_on_win
@@ -77,9 +82,9 @@ impl Plugin for ProgressPlugin {
}
fn award_xp_on_win(
mut wins: EventReader<GameWonEvent>,
mut levelups: EventWriter<LevelUpEvent>,
mut xp_awarded: EventWriter<XpAwardedEvent>,
mut wins: MessageReader<GameWonEvent>,
mut levelups: MessageWriter<LevelUpEvent>,
mut xp_awarded: MessageWriter<XpAwardedEvent>,
game: Res<GameStateResource>,
path: Res<ProgressStoragePath>,
mut progress: ResMut<ProgressResource>,
@@ -88,9 +93,9 @@ fn award_xp_on_win(
let used_undo = game.0.undo_count > 0;
let amount = xp_for_win(ev.time_seconds, used_undo);
let prev_level = progress.0.add_xp(amount);
xp_awarded.send(XpAwardedEvent { amount });
xp_awarded.write(XpAwardedEvent { amount });
if progress.0.leveled_up_from(prev_level) {
levelups.send(LevelUpEvent {
levelups.write(LevelUpEvent {
previous_level: prev_level,
new_level: progress.0.level,
total_xp: progress.0.total_xp,
@@ -131,7 +136,7 @@ mod tests {
fn win_awards_base_xp() {
let mut app = headless_app();
// Game starts with undo_count = 0, so the no-undo bonus applies.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300, // no speed bonus
});
@@ -150,7 +155,7 @@ mod tests {
.0
.undo_count = 1;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300,
});
@@ -164,7 +169,7 @@ mod tests {
#[test]
fn fast_win_includes_speed_bonus() {
let mut app = headless_app();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 0,
});
@@ -181,13 +186,13 @@ mod tests {
// Pre-load 480 XP so a 75-XP win pushes us over the 500 boundary.
app.world_mut().resource_mut::<ProgressResource>().0.total_xp = 480;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300,
});
app.update();
let events = app.world().resource::<Events<LevelUpEvent>>();
let events = app.world().resource::<Messages<LevelUpEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1, "exactly one level-up");
@@ -198,13 +203,13 @@ mod tests {
#[test]
fn win_without_level_change_does_not_fire_levelup() {
let mut app = headless_app();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300,
});
app.update();
let events = app.world().resource::<Events<LevelUpEvent>>();
let events = app.world().resource::<Messages<LevelUpEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
@@ -213,13 +218,13 @@ mod tests {
fn xp_awarded_event_fired_with_correct_amount() {
let mut app = headless_app();
// Slow win, no undo → base 50 + no_undo 25 = 75
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300,
});
app.update();
let events = app.world().resource::<Events<XpAwardedEvent>>();
let events = app.world().resource::<Messages<XpAwardedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -231,14 +236,14 @@ mod tests {
let mut app = headless_app();
app.world_mut().resource_mut::<ProgressResource>().0.total_xp = 480;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300,
});
app.update();
let total_xp = app.world().resource::<ProgressResource>().0.total_xp;
let events = app.world().resource::<Events<LevelUpEvent>>();
let events = app.world().resource::<Messages<LevelUpEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -256,7 +261,7 @@ mod tests {
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 0, // Zen mode keeps score at 0
time_seconds: 300,
});
+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;
}
}
+34 -10
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),
),
);
@@ -162,8 +172,8 @@ fn handle_selection_keys(
paused: Option<Res<PausedResource>>,
game: Res<GameStateResource>,
mut selection: ResMut<SelectionState>,
mut moves: EventWriter<MoveRequestEvent>,
mut info_toast: EventWriter<InfoToastEvent>,
mut moves: MessageWriter<MoveRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
@@ -200,11 +210,11 @@ fn handle_selection_keys(
if keys.just_pressed(KeyCode::Tab) {
let next = cycle_next_pile(&available, selection.selected_pile.as_ref());
if next.is_none() {
info_toast.send(InfoToastEvent("No cards to select".to_string()));
info_toast.write(InfoToastEvent("No cards to select".to_string()));
} else if selection.selected_pile.is_some()
&& did_wrap(&available, selection.selected_pile.as_ref(), next.as_ref())
{
info_toast.send(InfoToastEvent("Back to first card".to_string()));
info_toast.write(InfoToastEvent("Back to first card".to_string()));
}
selection.selected_pile = next;
return;
@@ -236,7 +246,7 @@ fn handle_selection_keys(
// --- Priority 1: foundation move (single card) ---
let foundation_dest = try_foundation_dest(card, &game.0);
if let Some(dest) = foundation_dest {
moves.send(MoveRequestEvent {
moves.write(MoveRequestEvent {
from: pile.clone(),
to: dest,
count: 1,
@@ -260,7 +270,7 @@ fn handle_selection_keys(
if let Some((dest, count)) =
best_tableau_destination_for_stack(bottom, pile, &game.0, run_len)
{
moves.send(MoveRequestEvent {
moves.write(MoveRequestEvent {
from: pile.clone(),
to: dest,
count,
@@ -274,7 +284,7 @@ fn handle_selection_keys(
// Covers non-tableau sources (Waste, Foundation) that have no
// stack-move logic.
if let Some(dest) = best_destination(card, &game.0) {
moves.send(MoveRequestEvent {
moves.write(MoveRequestEvent {
from: pile.clone(),
to: dest,
count: 1,
@@ -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
@@ -343,7 +367,7 @@ fn update_selection_highlight(
) {
// Always despawn any existing highlight first.
for entity in &highlights {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
let Some(ref pile) = selection.selected_pile else {
+53 -53
View File
@@ -36,7 +36,7 @@ pub struct SettingsStoragePath(pub Option<PathBuf>);
pub struct SettingsScreen(pub bool);
/// Fired whenever settings change so consumers (audio, UI) can react.
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct SettingsChangedEvent(pub Settings);
/// Marker on the root Settings panel entity.
@@ -144,9 +144,9 @@ impl Plugin for SettingsPlugin {
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
.init_resource::<SettingsScreen>()
.init_resource::<SettingsScrollPos>()
.add_event::<SettingsChangedEvent>()
.add_event::<ManualSyncRequestEvent>()
.add_event::<bevy::input::mouse::MouseWheel>()
.add_message::<SettingsChangedEvent>()
.add_message::<ManualSyncRequestEvent>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_systems(Update, (handle_volume_keys, toggle_settings_screen, scroll_settings_panel));
if self.ui_enabled {
@@ -185,7 +185,7 @@ fn handle_volume_keys(
keys: Res<ButtonInput<KeyCode>>,
mut settings: ResMut<SettingsResource>,
path: Res<SettingsStoragePath>,
mut changed: EventWriter<SettingsChangedEvent>,
mut changed: MessageWriter<SettingsChangedEvent>,
) {
let mut delta = 0.0_f32;
if keys.just_pressed(KeyCode::BracketLeft) {
@@ -203,7 +203,7 @@ fn handle_volume_keys(
return;
}
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
changed.write(SettingsChangedEvent(settings.0.clone()));
}
/// Opens or closes the Settings panel when `O` is pressed.
@@ -256,11 +256,11 @@ fn sync_settings_panel_visibility(
}
} else {
// Save the current scroll offset before despawning the panel.
if let Ok(sp) = scroll_nodes.get_single() {
scroll_pos.0 = sp.offset_y;
if let Ok(sp) = scroll_nodes.single() {
scroll_pos.0 = sp.0.y;
}
for entity in &panels {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
}
}
@@ -383,8 +383,8 @@ fn handle_settings_buttons(
mut settings: ResMut<SettingsResource>,
mut screen: ResMut<SettingsScreen>,
path: Res<SettingsStoragePath>,
mut changed: EventWriter<SettingsChangedEvent>,
mut manual_sync: EventWriter<ManualSyncRequestEvent>,
mut changed: MessageWriter<SettingsChangedEvent>,
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
@@ -402,8 +402,8 @@ fn handle_settings_buttons(
let after = settings.0.adjust_sfx_volume(-SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.single_mut() {
**t = format!("{:.2}", after);
}
}
@@ -413,8 +413,8 @@ fn handle_settings_buttons(
let after = settings.0.adjust_sfx_volume(SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.single_mut() {
**t = format!("{:.2}", after);
}
}
@@ -424,8 +424,8 @@ fn handle_settings_buttons(
let after = settings.0.adjust_music_volume(-SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.single_mut() {
**t = format!("{:.2}", after);
}
}
@@ -435,8 +435,8 @@ fn handle_settings_buttons(
let after = settings.0.adjust_music_volume(SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.single_mut() {
**t = format!("{:.2}", after);
}
}
@@ -447,8 +447,8 @@ fn handle_settings_buttons(
DrawMode::DrawThree => DrawMode::DrawOne,
};
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = draw_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = draw_text.single_mut() {
**t = draw_mode_label(&settings.0.draw_mode);
}
}
@@ -459,8 +459,8 @@ fn handle_settings_buttons(
AnimSpeed::Instant => AnimSpeed::Normal,
};
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = anim_speed_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = anim_speed_text.single_mut() {
**t = anim_speed_label(&settings.0.animation_speed);
}
}
@@ -471,31 +471,31 @@ fn handle_settings_buttons(
Theme::Dark => Theme::Green,
};
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = theme_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = theme_text.single_mut() {
**t = theme_label(&settings.0.theme);
}
}
SettingsButton::ToggleColorBlind => {
settings.0.color_blind_mode = !settings.0.color_blind_mode;
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = color_blind_text.get_single_mut() {
changed.write(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = color_blind_text.single_mut() {
**t = color_blind_label(settings.0.color_blind_mode);
}
}
SettingsButton::SelectCardBack(idx) => {
settings.0.selected_card_back = *idx;
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
changed.write(SettingsChangedEvent(settings.0.clone()));
}
SettingsButton::SelectBackground(idx) => {
settings.0.selected_background = *idx;
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
changed.write(SettingsChangedEvent(settings.0.clone()));
}
SettingsButton::SyncNow => {
manual_sync.send(ManualSyncRequestEvent);
manual_sync.write(ManualSyncRequestEvent);
}
SettingsButton::Done => {
screen.0 = false;
@@ -537,7 +537,7 @@ fn color_blind_label(enabled: bool) -> String {
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
/// scrolls past the top.
fn scroll_settings_panel(
mut scroll_evr: EventReader<MouseWheel>,
mut scroll_evr: MessageReader<MouseWheel>,
screen: Res<SettingsScreen>,
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
) {
@@ -556,7 +556,7 @@ fn scroll_settings_panel(
return;
}
for mut sp in scrollables.iter_mut() {
sp.offset_y = (sp.offset_y - delta_y).max(0.0);
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
@@ -595,7 +595,7 @@ fn spawn_settings_panel(
root.spawn((
SettingsPanelScrollable,
SettingsScrollNode,
ScrollPosition { offset_y: scroll_offset, ..default() },
ScrollPosition(Vec2::new(0.0, scroll_offset)),
Node {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(28.0)),
@@ -603,10 +603,10 @@ fn spawn_settings_panel(
min_width: Val::Px(340.0),
max_height: Val::Percent(88.0),
overflow: Overflow::scroll_y(),
border_radius: BorderRadius::all(Val::Px(8.0)),
..default()
},
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
BorderRadius::all(Val::Px(8.0)),
))
.with_children(|card| {
// Title
@@ -755,10 +755,10 @@ fn spawn_settings_panel(
height: Val::Px(40.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(bg_color),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -801,10 +801,10 @@ fn spawn_settings_panel(
height: Val::Px(40.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(bg_color),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -839,10 +839,10 @@ fn spawn_settings_panel(
Node {
padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -861,10 +861,10 @@ fn spawn_settings_panel(
padding: UiRect::axes(Val::Px(20.0), Val::Px(8.0)),
justify_content: JustifyContent::Center,
margin: UiRect::top(Val::Px(6.0)),
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -880,7 +880,7 @@ fn spawn_settings_panel(
});
}
fn section_label(parent: &mut ChildBuilder, title: &str) {
fn section_label(parent: &mut ChildSpawnerCommands, title: &str) {
parent.spawn((
Text::new(title),
TextFont {
@@ -893,7 +893,7 @@ fn section_label(parent: &mut ChildBuilder, title: &str) {
/// Generic volume row: `Label 0.80 [] [+]`
fn volume_row<Marker: Component>(
parent: &mut ChildBuilder,
parent: &mut ChildSpawnerCommands,
label: &str,
value: f32,
marker: Marker,
@@ -924,7 +924,7 @@ fn volume_row<Marker: Component>(
});
}
fn icon_button(parent: &mut ChildBuilder, label: &str, action: SettingsButton) {
fn icon_button(parent: &mut ChildSpawnerCommands, label: &str, action: SettingsButton) {
parent
.spawn((
action,
@@ -934,10 +934,10 @@ fn icon_button(parent: &mut ChildBuilder, label: &str, action: SettingsButton) {
height: Val::Px(28.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
},
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
@@ -995,7 +995,7 @@ mod tests {
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(after < before);
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 1);
}
@@ -1020,7 +1020,7 @@ mod tests {
press(&mut app, KeyCode::BracketRight);
app.update();
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 0);
}
@@ -1036,7 +1036,7 @@ mod tests {
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(after >= 0.0, "volume must not go below zero");
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 0, "no event when clamped at floor");
}
@@ -1095,7 +1095,7 @@ mod tests {
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
.id();
// Send a downward scroll event while the panel is closed.
app.world_mut().send_event(MouseWheel {
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -3.0,
@@ -1108,7 +1108,7 @@ mod tests {
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.offset_y;
.0.y;
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
}
@@ -1123,11 +1123,11 @@ mod tests {
.world_mut()
.spawn((
SettingsPanelScrollable,
ScrollPosition { offset_y: 100.0, ..default() },
ScrollPosition(Vec2::new(0.0, 100.0)),
))
.id();
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
app.world_mut().send_event(MouseWheel {
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -2.0,
@@ -1139,7 +1139,7 @@ mod tests {
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.offset_y;
.0.y;
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
}
@@ -1153,11 +1153,11 @@ mod tests {
.world_mut()
.spawn((
SettingsPanelScrollable,
ScrollPosition { offset_y: 10.0, ..default() },
ScrollPosition(Vec2::new(0.0, 10.0)),
))
.id();
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
app.world_mut().send_event(MouseWheel {
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 5.0,
@@ -1169,7 +1169,7 @@ mod tests {
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.offset_y;
.0.y;
assert_eq!(offset, 0.0, "scrolling past top must clamp to 0, got {offset}");
}
}
+26 -26
View File
@@ -77,10 +77,10 @@ impl Plugin for StatsPlugin {
};
app.insert_resource(StatsResource(loaded))
.insert_resource(StatsStoragePath(self.storage_path.clone()))
.add_event::<GameWonEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<ForfeitEvent>()
.add_event::<InfoToastEvent>()
.add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<ForfeitEvent>()
.add_message::<InfoToastEvent>()
// record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game.
.add_systems(
@@ -111,7 +111,7 @@ fn persist(path: &StatsStoragePath, stats: &StatsSnapshot, context: &str) {
}
fn update_stats_on_win(
mut events: EventReader<GameWonEvent>,
mut events: MessageReader<GameWonEvent>,
game: Res<GameStateResource>,
mut stats: ResMut<StatsResource>,
path: Res<StatsStoragePath>,
@@ -125,11 +125,11 @@ fn update_stats_on_win(
}
fn update_stats_on_new_game(
mut events: EventReader<NewGameRequestEvent>,
mut events: MessageReader<NewGameRequestEvent>,
game: Res<GameStateResource>,
mut stats: ResMut<StatsResource>,
path: Res<StatsStoragePath>,
mut toast: EventWriter<InfoToastEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
for _ in events.read() {
if game.0.move_count > 0 && !game.0.is_won {
@@ -137,7 +137,7 @@ fn update_stats_on_new_game(
stats.0.record_abandoned();
persist(&path, &stats.0, "abandoned game");
if streak > 1 {
toast.send(InfoToastEvent(format!("Streak of {streak} broken!")));
toast.write(InfoToastEvent(format!("Streak of {streak} broken!")));
}
}
}
@@ -149,12 +149,12 @@ fn update_stats_on_new_game(
/// `AutoCompleteState` is reset here so the "AUTO" badge and chime do not bleed
/// into the new deal (task #41).
fn handle_forfeit(
mut events: EventReader<ForfeitEvent>,
mut events: MessageReader<ForfeitEvent>,
game: Res<GameStateResource>,
mut stats: ResMut<StatsResource>,
path: Res<StatsStoragePath>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut toast: EventWriter<InfoToastEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
mut auto_complete: Option<ResMut<AutoCompleteState>>,
) {
for _ in events.read() {
@@ -163,7 +163,7 @@ fn handle_forfeit(
stats.0.record_abandoned();
persist(&path, &stats.0, "forfeit");
if streak > 1 {
toast.send(InfoToastEvent(format!("Streak of {streak} broken!")));
toast.write(InfoToastEvent(format!("Streak of {streak} broken!")));
}
}
// Reset auto-complete so the badge and chime don't carry over to the
@@ -171,8 +171,8 @@ fn handle_forfeit(
if let Some(ref mut ac) = auto_complete {
**ac = AutoCompleteState::default();
}
toast.send(InfoToastEvent("Game forfeited".to_string()));
new_game.send(NewGameRequestEvent::default());
toast.write(InfoToastEvent("Game forfeited".to_string()));
new_game.write(NewGameRequestEvent::default());
}
}
@@ -187,8 +187,8 @@ fn toggle_stats_screen(
if !keys.just_pressed(KeyCode::KeyS) {
return;
}
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_stats_screen(
&mut commands,
@@ -349,7 +349,7 @@ fn spawn_stats_screen(
/// Spawn a single stat cell: a large value label on top and a small grey
/// descriptor below, inside a fixed-width column node with a [`StatsCell`] marker.
fn spawn_stat_cell(parent: &mut ChildBuilder, value: &str, label: &str) {
fn spawn_stat_cell(parent: &mut ChildSpawnerCommands, value: &str, label: &str) {
parent
.spawn((
StatsCell,
@@ -513,7 +513,7 @@ mod tests {
#[test]
fn win_event_increments_games_won() {
let mut app = headless_app();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 120,
});
@@ -532,7 +532,7 @@ mod tests {
.0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
@@ -553,7 +553,7 @@ mod tests {
.move_count = 3;
app.world_mut()
.send_event(NewGameRequestEvent { seed: Some(999), mode: None });
.write_message(NewGameRequestEvent { seed: Some(999), mode: None });
app.update();
let stats = &app.world().resource::<StatsResource>().0;
@@ -566,7 +566,7 @@ mod tests {
fn new_game_without_moves_does_not_record_abandoned() {
let mut app = headless_app();
app.world_mut()
.send_event(NewGameRequestEvent { seed: Some(42), mode: None });
.write_message(NewGameRequestEvent { seed: Some(42), mode: None });
app.update();
let stats = &app.world().resource::<StatsResource>().0;
@@ -781,10 +781,10 @@ mod tests {
.0
.move_count = 1;
app.world_mut().send_event(ForfeitEvent);
app.world_mut().write_message(ForfeitEvent);
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut reader = events.get_cursor();
let messages: Vec<&str> = reader
.read(events)
@@ -792,7 +792,7 @@ mod tests {
.collect();
assert!(
messages.iter().any(|m| *m == "Streak of 3 broken!"),
messages.contains(&"Streak of 3 broken!"),
"expected 'Streak of 3 broken!' in toasts, got: {messages:?}"
);
}
@@ -810,10 +810,10 @@ mod tests {
.0
.move_count = 1;
app.world_mut().send_event(ForfeitEvent);
app.world_mut().write_message(ForfeitEvent);
app.update();
let events = app.world().resource::<Events<InfoToastEvent>>();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut reader = events.get_cursor();
let messages: Vec<&str> = reader
.read(events)
+4 -5
View File
@@ -93,7 +93,7 @@ impl Plugin for SyncPlugin {
.init_resource::<SyncStatusResource>()
.init_resource::<PullTaskResult>()
.init_resource::<PullTask>()
.add_event::<ManualSyncRequestEvent>()
.add_message::<ManualSyncRequestEvent>()
.add_systems(Startup, start_pull)
.add_systems(Update, (poll_pull_result, handle_manual_sync_request))
.add_systems(Last, push_on_exit);
@@ -121,7 +121,7 @@ fn start_pull(
/// Update system: starts a new pull task when `ManualSyncRequestEvent` is
/// received, but only if no pull is already in flight.
fn handle_manual_sync_request(
mut events: EventReader<ManualSyncRequestEvent>,
mut events: MessageReader<ManualSyncRequestEvent>,
provider: Res<SyncProviderResource>,
mut task_res: ResMut<PullTask>,
mut status: ResMut<SyncStatusResource>,
@@ -217,7 +217,7 @@ fn poll_pull_result(
/// that blocking on exit is permitted because the game loop is already
/// shutting down.
fn push_on_exit(
mut exit_events: EventReader<AppExit>,
mut exit_events: MessageReader<AppExit>,
provider: Res<SyncProviderResource>,
stats: Res<StatsResource>,
achievements: Res<AchievementsResource>,
@@ -403,8 +403,7 @@ mod tests {
#[test]
fn build_payload_clones_stats() {
let mut stats = StatsSnapshot::default();
stats.games_played = 42;
let stats = StatsSnapshot { games_played: 42, ..Default::default() };
let payload = build_payload(&stats, &[], &PlayerProgress::default());
assert_eq!(payload.stats.games_played, 42);
}
+149 -5
View File
@@ -10,6 +10,7 @@ use solitaire_core::card::Suit;
use solitaire_core::pile::PileType;
use solitaire_data::settings::Theme;
use crate::events::HintVisualEvent;
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
@@ -27,6 +28,17 @@ pub struct TableBackground;
#[derive(Component, Debug, Clone)]
pub struct PileMarker(pub PileType);
/// Attached to a `PileMarker` entity when it has been temporarily tinted gold
/// as a hint destination. Stores the remaining countdown and the original sprite
/// colour so it can be restored when the timer expires.
#[derive(Component, Debug, Clone)]
pub struct HintPileHighlight {
/// Seconds remaining before the pile marker colour is restored.
pub timer: f32,
/// The sprite colour the marker had before the hint tint was applied.
pub original_color: Color,
}
/// Registers the table background and pile-marker rendering.
pub struct TablePlugin;
@@ -35,10 +47,19 @@ impl Plugin for TablePlugin {
// Register WindowResized so the plugin works under MinimalPlugins in
// tests. Under DefaultPlugins, bevy_window has already registered it
// and this call is a no-op.
app.add_event::<WindowResized>()
.add_event::<SettingsChangedEvent>()
app.add_message::<WindowResized>()
.add_message::<SettingsChangedEvent>()
.add_message::<HintVisualEvent>()
.add_systems(Startup, setup_table)
.add_systems(Update, (on_window_resized, apply_theme_on_settings_change));
.add_systems(
Update,
(
on_window_resized,
apply_theme_on_settings_change,
apply_hint_pile_highlight,
tick_hint_pile_highlights,
),
);
}
}
@@ -112,7 +133,7 @@ fn spawn_background(commands: &mut Commands, window_size: Vec2, color: Color) {
}
fn apply_theme_on_settings_change(
mut events: EventReader<SettingsChangedEvent>,
mut events: MessageReader<SettingsChangedEvent>,
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
) {
let Some(ev) = events.read().last() else {
@@ -192,7 +213,7 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
#[allow(clippy::type_complexity)]
fn on_window_resized(
mut events: EventReader<WindowResized>,
mut events: MessageReader<WindowResized>,
mut layout_res: Option<ResMut<LayoutResource>>,
mut backgrounds: Query<
(&mut Sprite, &mut Transform),
@@ -225,6 +246,59 @@ fn on_window_resized(
}
}
// ---------------------------------------------------------------------------
// Task #6 — Hint pile-marker highlight
// ---------------------------------------------------------------------------
/// Gold tint applied to a `PileMarker` sprite when it is the current hint
/// destination.
const HINT_PILE_HIGHLIGHT_COLOUR: Color = Color::srgb(1.0, 0.85, 0.1);
/// Listens for `HintVisualEvent` and tints the matching `PileMarker` entity
/// gold for 2 s, storing the original colour in `HintPileHighlight` so it can
/// be restored when the timer expires.
///
/// If the pile marker already has a `HintPileHighlight` from a previous hint
/// press, the timer is reset to 2 s without changing `original_color`.
fn apply_hint_pile_highlight(
mut events: MessageReader<HintVisualEvent>,
mut commands: Commands,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite, Option<&HintPileHighlight>)>,
) {
for ev in events.read() {
for (entity, pile_marker, mut sprite, existing) in pile_markers.iter_mut() {
if pile_marker.0 != ev.dest_pile {
continue;
}
let original_color = existing
.map(|h| h.original_color)
.unwrap_or(sprite.color);
sprite.color = HINT_PILE_HIGHLIGHT_COLOUR;
commands.entity(entity).insert(HintPileHighlight {
timer: 2.0,
original_color,
});
}
}
}
/// Counts down `HintPileHighlight::timer` each frame and restores the original
/// pile marker colour when the timer expires.
fn tick_hint_pile_highlights(
mut commands: Commands,
time: Res<Time>,
mut pile_markers: Query<(Entity, &mut Sprite, &mut HintPileHighlight)>,
) {
let dt = time.delta_secs();
for (entity, mut sprite, mut highlight) in pile_markers.iter_mut() {
highlight.timer -= dt;
if highlight.timer <= 0.0 {
sprite.color = highlight.original_color;
commands.entity(entity).remove::<HintPileHighlight>();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -342,6 +416,76 @@ mod tests {
assert_eq!(suit_symbol(&Suit::Clubs), "C");
}
// -----------------------------------------------------------------------
// Task #6 — HintPileHighlight timer and colour pure-function tests
// -----------------------------------------------------------------------
/// The HINT_PILE_HIGHLIGHT_COLOUR constant must be visibly distinct from the
/// default pile marker colour so the player can see which pile is highlighted.
#[test]
fn hint_pile_highlight_colour_is_distinct_from_default() {
let default = Color::srgba(1.0, 1.0, 1.0, 0.08); // PILE_MARKER_DEFAULT_COLOUR
assert_ne!(
HINT_PILE_HIGHLIGHT_COLOUR, default,
"HINT_PILE_HIGHLIGHT_COLOUR must differ from the default pile marker colour"
);
}
/// A freshly-created HintPileHighlight has a positive timer countdown.
#[test]
fn hint_pile_highlight_timer_starts_positive() {
let h = HintPileHighlight {
timer: 2.0,
original_color: Color::srgba(1.0, 1.0, 1.0, 0.08),
};
assert!(
h.timer > 0.0,
"HintPileHighlight timer must start positive, got {}",
h.timer
);
}
/// Ticking the timer past its initial value results in a non-positive (expired)
/// countdown.
#[test]
fn hint_pile_highlight_timer_expires_after_full_duration() {
let mut remaining = 2.0_f32;
remaining -= 2.5; // 2.5 s elapsed on a 2.0 s timer
assert!(
remaining <= 0.0,
"timer must be expired after ticking past its initial value, got {}",
remaining
);
}
/// `original_color` is preserved through the highlight lifecycle so colour
/// can be correctly restored on expiry.
#[test]
fn hint_pile_highlight_preserves_original_color() {
let original = Color::srgb(0.1, 0.3, 0.5);
let h = HintPileHighlight {
timer: 2.0,
original_color: original,
};
assert_eq!(
h.original_color, original,
"original_color must be stored without modification"
);
}
/// The gold hint colour must have a strong yellow component (r ≥ 0.9, g ≥ 0.8,
/// b ≤ 0.3) to be clearly visible as a "destination" indicator.
#[test]
fn hint_pile_highlight_colour_is_gold() {
// Extract linear components. srgb(1.0, 0.85, 0.1) is the expected gold.
// We test the channel values rather than exact equality so future tweaks
// to the shade do not break the test, as long as the colour remains golden.
let Srgba { red, green, blue, .. } = HINT_PILE_HIGHLIGHT_COLOUR.to_srgba();
assert!(red >= 0.9, "gold hint colour must have red ≥ 0.9, got {red}");
assert!(green >= 0.7, "gold hint colour must have green ≥ 0.7, got {green}");
assert!(blue <= 0.3, "gold hint colour must have blue ≤ 0.3, got {blue}");
}
#[test]
fn suit_symbol_all_four_are_distinct() {
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
+23 -22
View File
@@ -26,20 +26,21 @@ pub struct TimeAttackResource {
/// Fired when the Time Attack timer expires. The summary toast in
/// `AnimationPlugin` consumes this; UI/stats consumers can also subscribe.
#[derive(Event, Debug, Clone, Copy)]
#[derive(Message, Debug, Clone, Copy)]
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 {
fn build(&self, app: &mut App) {
app.init_resource::<TimeAttackResource>()
.add_event::<TimeAttackEndedEvent>()
.add_event::<GameWonEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<InfoToastEvent>()
.add_message::<TimeAttackEndedEvent>()
.add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<InfoToastEvent>()
.add_systems(
Update,
handle_start_time_attack_request.before(GameMutation),
@@ -53,14 +54,14 @@ fn handle_start_time_attack_request(
keys: Res<ButtonInput<KeyCode>>,
progress: Res<ProgressResource>,
mut session: ResMut<TimeAttackResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut info_toast: EventWriter<InfoToastEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
if !keys.just_pressed(KeyCode::KeyT) {
return;
}
if progress.0.level < CHALLENGE_UNLOCK_LEVEL {
info_toast.send(InfoToastEvent(format!(
info_toast.write(InfoToastEvent(format!(
"Time Attack unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
)));
return;
@@ -70,7 +71,7 @@ fn handle_start_time_attack_request(
remaining_secs: TIME_ATTACK_DURATION_SECS,
wins: 0,
};
new_game.send(NewGameRequestEvent {
new_game.write(NewGameRequestEvent {
seed: None,
mode: Some(GameMode::TimeAttack),
});
@@ -79,7 +80,7 @@ fn handle_start_time_attack_request(
fn advance_time_attack(
time: Res<Time>,
mut session: ResMut<TimeAttackResource>,
mut ended: EventWriter<TimeAttackEndedEvent>,
mut ended: MessageWriter<TimeAttackEndedEvent>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
) {
if !session.active {
@@ -93,22 +94,22 @@ fn advance_time_attack(
let wins = session.wins;
session.active = false;
session.remaining_secs = 0.0;
ended.send(TimeAttackEndedEvent { wins });
ended.write(TimeAttackEndedEvent { wins });
}
}
fn auto_deal_on_time_attack_win(
mut wins: EventReader<GameWonEvent>,
mut wins: MessageReader<GameWonEvent>,
game: Res<GameStateResource>,
mut session: ResMut<TimeAttackResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
) {
for _ in wins.read() {
if !session.active || game.0.mode != GameMode::TimeAttack {
continue;
}
session.wins = session.wins.saturating_add(1);
new_game.send(NewGameRequestEvent {
new_game.write(NewGameRequestEvent {
seed: None,
mode: Some(GameMode::TimeAttack),
});
@@ -151,7 +152,7 @@ mod tests {
let session = app.world().resource::<TimeAttackResource>();
assert!(!session.active);
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
@@ -169,7 +170,7 @@ mod tests {
assert_eq!(session.wins, 0);
assert!((session.remaining_secs - TIME_ATTACK_DURATION_SECS).abs() < 1.0);
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -193,7 +194,7 @@ mod tests {
assert!(!session.active);
assert_eq!(session.remaining_secs, 0.0);
let events = app.world().resource::<Events<TimeAttackEndedEvent>>();
let events = app.world().resource::<Messages<TimeAttackEndedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -213,7 +214,7 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
@@ -222,7 +223,7 @@ mod tests {
let session = app.world().resource::<TimeAttackResource>();
assert_eq!(session.wins, 1);
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -237,7 +238,7 @@ mod tests {
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
@@ -256,7 +257,7 @@ mod tests {
wins: 0,
};
// GameStateResource defaults to Classic mode.
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
@@ -286,7 +287,7 @@ mod tests {
assert!(session.remaining_secs < 0.0, "remaining_secs must not change while paused");
// No ended event must have been emitted.
let events = app.world().resource::<Events<TimeAttackEndedEvent>>();
let events = app.world().resource::<Messages<TimeAttackEndedEvent>>();
let mut cursor = events.get_cursor();
assert!(
cursor.read(events).next().is_none(),
+22 -20
View File
@@ -15,19 +15,21 @@ use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath
use crate::resources::GameStateResource;
/// Fired when the player has just completed a weekly goal.
#[derive(Event, Debug, Clone)]
#[derive(Message, Debug, Clone)]
pub struct WeeklyGoalCompletedEvent {
pub goal_id: String,
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 {
fn build(&self, app: &mut App) {
app.add_event::<WeeklyGoalCompletedEvent>()
.add_event::<GameWonEvent>()
.add_event::<XpAwardedEvent>()
app.add_message::<WeeklyGoalCompletedEvent>()
.add_message::<GameWonEvent>()
.add_message::<XpAwardedEvent>()
.add_systems(Startup, roll_weekly_goals_on_startup)
// Run after GameMutation (so GameWonEvent is available) and
// ProgressUpdate (so we don't fight ProgressPlugin's add_xp).
@@ -57,13 +59,13 @@ fn roll_weekly_goals_on_startup(
}
fn evaluate_weekly_goals(
mut wins: EventReader<GameWonEvent>,
mut wins: MessageReader<GameWonEvent>,
game: Res<GameStateResource>,
mut progress: ResMut<ProgressResource>,
path: Res<ProgressStoragePath>,
mut completions: EventWriter<WeeklyGoalCompletedEvent>,
mut levelups: EventWriter<LevelUpEvent>,
mut xp_awarded: EventWriter<XpAwardedEvent>,
mut completions: MessageWriter<WeeklyGoalCompletedEvent>,
mut levelups: MessageWriter<LevelUpEvent>,
mut xp_awarded: MessageWriter<XpAwardedEvent>,
) {
let mut events: Vec<&GameWonEvent> = wins.read().collect();
if events.is_empty() {
@@ -92,7 +94,7 @@ fn evaluate_weekly_goals(
any_change = true;
if just_completed {
bonus_xp = bonus_xp.saturating_add(WEEKLY_GOAL_XP);
completions.send(WeeklyGoalCompletedEvent {
completions.write(WeeklyGoalCompletedEvent {
goal_id: def.id.to_string(),
description: def.description.to_string(),
});
@@ -101,10 +103,10 @@ fn evaluate_weekly_goals(
}
if bonus_xp > 0 {
xp_awarded.send(XpAwardedEvent { amount: bonus_xp });
xp_awarded.write(XpAwardedEvent { amount: bonus_xp });
let prev_level = progress.0.add_xp(bonus_xp);
if progress.0.leveled_up_from(prev_level) {
levelups.send(LevelUpEvent {
levelups.write(LevelUpEvent {
previous_level: prev_level,
new_level: progress.0.level,
total_xp: progress.0.total_xp,
@@ -149,7 +151,7 @@ mod tests {
#[test]
fn first_win_increments_win_game_goal() {
let mut app = headless_app();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
@@ -158,13 +160,13 @@ mod tests {
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
// No-undo + slow win → no_undo goal also ticked, fast goal NOT ticked.
assert_eq!(p.weekly_goal_progress.get("weekly_3_no_undo"), Some(&1));
assert!(p.weekly_goal_progress.get("weekly_3_fast").is_none());
assert!(!p.weekly_goal_progress.contains_key("weekly_3_fast"));
}
#[test]
fn fast_win_ticks_fast_goal_too() {
let mut app = headless_app();
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
@@ -181,14 +183,14 @@ mod tests {
.0
.undo_count = 1;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 200,
});
app.update();
let p = &app.world().resource::<ProgressResource>().0;
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
assert!(p.weekly_goal_progress.get("weekly_3_no_undo").is_none());
assert!(!p.weekly_goal_progress.contains_key("weekly_3_no_undo"));
}
#[test]
@@ -214,7 +216,7 @@ mod tests {
let xp_before = app.world().resource::<ProgressResource>().0.total_xp;
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
@@ -228,7 +230,7 @@ mod tests {
let base_win_xp = solitaire_data::xp_for_win(60, false);
assert_eq!(p.total_xp - xp_before, base_win_xp + WEEKLY_GOAL_XP);
let events = app.world().resource::<Events<WeeklyGoalCompletedEvent>>();
let events = app.world().resource::<Messages<WeeklyGoalCompletedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert!(fired.iter().any(|e| e.goal_id == "weekly_3_fast"));
@@ -280,13 +282,13 @@ mod tests {
.0
.weekly_goal_week_iso = Some(key);
app.world_mut().send_event(GameWonEvent {
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
app.update();
let events = app.world().resource::<Events<LevelUpEvent>>();
let events = app.world().resource::<Messages<LevelUpEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert!(!fired.is_empty(), "LevelUpEvent must fire when weekly bonus pushes past a level threshold");
+35 -35
View File
@@ -159,11 +159,11 @@ impl Plugin for WinSummaryPlugin {
app.init_resource::<WinSummaryPending>()
.init_resource::<ScreenShakeResource>()
.init_resource::<SessionAchievements>()
.add_event::<GameWonEvent>()
.add_event::<XpAwardedEvent>()
.add_event::<NewGameRequestEvent>()
.add_event::<InfoToastEvent>()
.add_event::<AchievementUnlockedEvent>()
.add_message::<GameWonEvent>()
.add_message::<XpAwardedEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<AchievementUnlockedEvent>()
// `cache_win_data` must run BEFORE `StatsUpdate` so it can compare
// the player's old personal-best values before `StatsPlugin` overwrites them.
.add_systems(
@@ -221,13 +221,13 @@ pub fn format_win_time(seconds: u64) -> String {
/// This system is scheduled `.before(StatsUpdate)` so the comparison always
/// sees the old best values.
fn cache_win_data(
mut won: EventReader<GameWonEvent>,
mut xp: EventReader<XpAwardedEvent>,
mut won: MessageReader<GameWonEvent>,
mut xp: MessageReader<XpAwardedEvent>,
mut pending: ResMut<WinSummaryPending>,
stats: Res<StatsResource>,
game: Res<GameStateResource>,
progress: Res<ProgressResource>,
mut toast: EventWriter<InfoToastEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
for ev in won.read() {
// Compare against old personal bests BEFORE StatsPlugin updates them.
@@ -255,7 +255,7 @@ fn cache_win_data(
pending.challenge_level = challenge_level;
if is_new_record {
toast.send(InfoToastEvent("New Record!".to_string()));
toast.write(InfoToastEvent("New Record!".to_string()));
}
}
for ev in xp.read() {
@@ -274,8 +274,8 @@ fn cache_win_data(
/// reader covers every implicit game-context reset in addition to the
/// explicit N / "Play Again" new-game requests.
fn collect_session_achievements(
mut unlocks: EventReader<AchievementUnlockedEvent>,
mut new_games: EventReader<NewGameRequestEvent>,
mut unlocks: MessageReader<AchievementUnlockedEvent>,
mut new_games: MessageReader<NewGameRequestEvent>,
mut session: ResMut<SessionAchievements>,
) {
// Reset on any new-game request (including mode switches via Z/X/C/T) so
@@ -303,8 +303,8 @@ fn collect_session_achievements(
#[allow(clippy::too_many_arguments)]
fn spawn_win_summary_after_delay(
mut commands: Commands,
mut won: EventReader<GameWonEvent>,
mut xp_events: EventReader<XpAwardedEvent>,
mut won: MessageReader<GameWonEvent>,
mut xp_events: MessageReader<XpAwardedEvent>,
mut shake: ResMut<ScreenShakeResource>,
mut pending: ResMut<WinSummaryPending>,
session: Res<SessionAchievements>,
@@ -321,7 +321,7 @@ fn spawn_win_summary_after_delay(
*delay = Some(WIN_SUMMARY_DELAY_SECS);
// Clear any stale overlay from a previous win.
for entity in &overlays {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
}
@@ -352,7 +352,7 @@ fn handle_win_summary_buttons(
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
overlays: Query<Entity, With<WinSummaryOverlay>>,
mut commands: Commands,
mut new_game: EventWriter<NewGameRequestEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
@@ -362,9 +362,9 @@ fn handle_win_summary_buttons(
WinSummaryButton::PlayAgain => {
// Despawn the modal.
for entity in &overlays {
commands.entity(entity).despawn_recursive();
commands.entity(entity).despawn();
}
new_game.send(NewGameRequestEvent::default());
new_game.write(NewGameRequestEvent::default());
}
}
}
@@ -442,10 +442,10 @@ fn spawn_overlay(
row_gap: Val::Px(18.0),
min_width: Val::Px(320.0),
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.12, 0.10)),
BorderRadius::all(Val::Px(12.0)),
))
.with_children(|card| {
// Heading
@@ -518,10 +518,10 @@ fn spawn_overlay(
padding: UiRect::axes(Val::Px(28.0), Val::Px(12.0)),
justify_content: JustifyContent::Center,
margin: UiRect::top(Val::Px(8.0)),
border_radius: BorderRadius::all(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
BorderRadius::all(Val::Px(6.0)),
))
.with_children(|b| {
b.spawn((
@@ -543,7 +543,7 @@ const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
/// Shows at most [`MAX_ACHIEVEMENTS_SHOWN`] names. When more achievements were
/// unlocked than the cap, appends a "...and N more" line so the player knows
/// there are additional unlocks visible on the achievements screen.
fn spawn_achievements_section(card: &mut ChildBuilder, names: &[String]) {
fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String]) {
card.spawn((
Text::new("Achievements Unlocked"),
TextFont { font_size: 18.0, ..default() },
@@ -677,7 +677,7 @@ mod tests {
use solitaire_data::AchievementRecord;
let record = AchievementRecord::locked("first_win");
app.world_mut()
.send_event(AchievementUnlockedEvent(record));
.write_message(AchievementUnlockedEvent(record));
app.update();
let session = app.world().resource::<SessionAchievements>();
@@ -693,7 +693,7 @@ mod tests {
use solitaire_data::AchievementRecord;
let record = AchievementRecord::locked("first_win");
app.world_mut()
.send_event(AchievementUnlockedEvent(record));
.write_message(AchievementUnlockedEvent(record));
app.update();
// Confirm it was recorded.
@@ -703,7 +703,7 @@ mod tests {
);
// Fire NewGameRequestEvent — should clear the list.
app.world_mut().send_event(NewGameRequestEvent::default());
app.world_mut().write_message(NewGameRequestEvent::default());
app.update();
assert!(
@@ -727,7 +727,7 @@ mod tests {
// Simulate an achievement unlock during the current session.
let record = AchievementRecord::locked("first_win");
app.world_mut()
.send_event(AchievementUnlockedEvent(record));
.write_message(AchievementUnlockedEvent(record));
app.update();
assert_eq!(
@@ -739,7 +739,7 @@ mod tests {
// Simulate pressing Z (Zen mode switch) — fires NewGameRequestEvent
// with mode = Some(Zen). Same event shape used by X (Challenge),
// C (Daily Challenge), and T (Time Attack).
app.world_mut().send_event(NewGameRequestEvent {
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: Some(GameMode::Zen),
});
@@ -756,7 +756,7 @@ mod tests {
let mut app = make_app();
app.world_mut()
.send_event(GameWonEvent { score: 1234, time_seconds: 90 });
.write_message(GameWonEvent { score: 1234, time_seconds: 90 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -771,8 +771,8 @@ mod tests {
fn cache_win_data_sets_xp_from_xp_awarded_event() {
let mut app = make_app();
app.world_mut().send_event(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().send_event(XpAwardedEvent { amount: 75 });
app.world_mut().write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(XpAwardedEvent { amount: 75 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -784,7 +784,7 @@ mod tests {
let mut app = make_app();
app.world_mut()
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.update();
let shake = app.world().resource::<ScreenShakeResource>();
@@ -802,7 +802,7 @@ mod tests {
let mut app = make_app();
app.world_mut()
.send_event(GameWonEvent { score: 500, time_seconds: 120 });
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -820,7 +820,7 @@ mod tests {
// Score 500 beats previous best of 400.
app.world_mut()
.send_event(GameWonEvent { score: 500, time_seconds: 300 });
.write_message(GameWonEvent { score: 500, time_seconds: 300 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -838,7 +838,7 @@ mod tests {
// Score 500 does not beat 800, but time 100 < 200.
app.world_mut()
.send_event(GameWonEvent { score: 500, time_seconds: 100 });
.write_message(GameWonEvent { score: 500, time_seconds: 100 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -856,7 +856,7 @@ mod tests {
// Score 500 < 800 and time 120 > 60 — neither record broken.
app.world_mut()
.send_event(GameWonEvent { score: 500, time_seconds: 120 });
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -887,7 +887,7 @@ mod tests {
}
app.world_mut()
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -903,7 +903,7 @@ mod tests {
let mut app = make_app();
// Default game mode is Classic — challenge_level should stay None.
app.world_mut()
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
+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");
}
}
+28 -9
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()
@@ -64,9 +85,7 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
.finish()
.expect("invalid governor config"),
);
auth_routes.layer(GovernorLayer {
config: governor_conf,
})
auth_routes.layer(GovernorLayer::new(governor_conf))
} else {
auth_routes
};
@@ -82,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}");
+22 -22
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,
@@ -100,6 +101,21 @@ pub fn validate_refresh_token(token: &str, secret: &str) -> Result<Claims, AppEr
// Axum extractor — allows handlers to receive AuthenticatedUser directly
// ---------------------------------------------------------------------------
impl<S> FromRequestParts<S> for AuthenticatedUser
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<AuthenticatedUser>()
.cloned()
.ok_or(AppError::Unauthorized)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -221,19 +237,3 @@ mod tests {
assert!(result.is_err(), "expired refresh token must be rejected");
}
}
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<AuthenticatedUser>()
.cloned()
.ok_or(AppError::Unauthorized)
}
}
+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,
+3 -6
View File
@@ -201,8 +201,7 @@ mod tests {
#[test]
fn add_xp_saturates_on_overflow() {
let mut p = PlayerProgress::default();
p.total_xp = u64::MAX;
let mut p = PlayerProgress { total_xp: u64::MAX, ..Default::default() };
p.add_xp(1);
assert_eq!(p.total_xp, u64::MAX);
}
@@ -230,8 +229,7 @@ mod tests {
#[test]
fn roll_weekly_goals_clears_progress_for_new_week() {
let mut p = PlayerProgress::default();
p.weekly_goal_week_iso = Some("2026-W16".to_string());
let mut p = PlayerProgress { weekly_goal_week_iso: Some("2026-W16".to_string()), ..Default::default() };
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 3);
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
@@ -242,8 +240,7 @@ mod tests {
#[test]
fn roll_weekly_goals_is_noop_for_same_week() {
let mut p = PlayerProgress::default();
p.weekly_goal_week_iso = Some("2026-W17".to_string());
let mut p = PlayerProgress { weekly_goal_week_iso: Some("2026-W17".to_string()), ..Default::default() };
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 2);
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
+2 -5
View File
@@ -135,17 +135,14 @@ mod tests {
#[test]
fn record_abandoned_resets_win_streak() {
let mut s = StatsSnapshot::default();
s.win_streak_current = 5;
let mut s = StatsSnapshot { win_streak_current: 5, ..Default::default() };
s.record_abandoned();
assert_eq!(s.win_streak_current, 0, "abandoned game must break the win streak");
}
#[test]
fn record_abandoned_preserves_best_streak() {
let mut s = StatsSnapshot::default();
s.win_streak_best = 7;
s.win_streak_current = 7;
let mut s = StatsSnapshot { win_streak_best: 7, win_streak_current: 7, ..Default::default() };
s.record_abandoned();
assert_eq!(s.win_streak_best, 7, "best streak must not be reduced on abandon");
assert_eq!(s.win_streak_current, 0);