Compare commits
15 Commits
v0.9.0
...
4d6f8bccb7
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d6f8bccb7 | |||
| 800dfb50ce | |||
| 735d8766a2 | |||
| ccfeb055e5 | |||
| 8f957d919f | |||
| 2407686e13 | |||
| 1ec2593137 | |||
| ffc79447d4 | |||
| 71c0c273a1 | |||
| 21d0c289b5 | |||
| 648cd44387 | |||
| c8553dc8c5 | |||
| eedddb979e | |||
| 59a023ed5e | |||
| 8cd28cfb29 |
@@ -5,3 +5,4 @@
|
|||||||
.env
|
.env
|
||||||
*.tmp
|
*.tmp
|
||||||
data/
|
data/
|
||||||
|
.claude/
|
||||||
|
|||||||
+12
@@ -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
@@ -851,16 +851,16 @@ Levels 11+: level = 10 + floor((total_xp - 5000) / 1000)
|
|||||||
|
|
||||||
## 14. Audio System
|
## 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 |
|
| File | Trigger |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `card_deal.ogg` | New game deal animation |
|
| `card_deal.wav` | New game deal animation |
|
||||||
| `card_flip.ogg` | Card flips face-up |
|
| `card_flip.wav` | Card flips face-up |
|
||||||
| `card_place.ogg` | Valid card placement |
|
| `card_place.wav` | Valid card placement |
|
||||||
| `card_invalid.ogg` | Invalid move attempt |
|
| `card_invalid.wav` | Invalid move attempt |
|
||||||
| `win_fanfare.ogg` | Game won |
|
| `win_fanfare.wav` | Game won |
|
||||||
| `ambient_loop.ogg` | Looping background music (restarts seamlessly) |
|
| `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.
|
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
File diff suppressed because it is too large
Load Diff
+10
-9
@@ -14,33 +14,34 @@ resolver = "2"
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
thiserror = "1"
|
thiserror = "2"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
dirs = "5"
|
dirs = "6"
|
||||||
keyring = "2"
|
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_core = { path = "solitaire_core" }
|
||||||
solitaire_sync = { path = "solitaire_sync" }
|
solitaire_sync = { path = "solitaire_sync" }
|
||||||
solitaire_data = { path = "solitaire_data" }
|
solitaire_data = { path = "solitaire_data" }
|
||||||
solitaire_engine = { path = "solitaire_engine" }
|
solitaire_engine = { path = "solitaire_engine" }
|
||||||
|
|
||||||
bevy = "0.15"
|
bevy = "0.18"
|
||||||
kira = "0.9"
|
kira = "0.12"
|
||||||
|
|
||||||
axum = "0.7"
|
axum = "0.8"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
|
||||||
bcrypt = "0.15"
|
bcrypt = "0.19"
|
||||||
tower_governor = "0.4"
|
tower_governor = "0.8"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solitaire_app"
|
name = "solitaire_app"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin,
|
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
|
||||||
ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, GamePlugin,
|
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
|
||||||
HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin,
|
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||||
PausePlugin, ProfilePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin,
|
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
|
||||||
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -21,7 +21,12 @@ fn main() {
|
|||||||
DefaultPlugins.set(WindowPlugin {
|
DefaultPlugins.set(WindowPlugin {
|
||||||
primary_window: Some(Window {
|
primary_window: Some(Window {
|
||||||
title: "Solitaire Quest".into(),
|
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()
|
||||||
}),
|
}),
|
||||||
..default()
|
..default()
|
||||||
@@ -32,8 +37,10 @@ fn main() {
|
|||||||
.add_plugins(CardPlugin)
|
.add_plugins(CardPlugin)
|
||||||
.add_plugins(CursorPlugin)
|
.add_plugins(CursorPlugin)
|
||||||
.add_plugins(InputPlugin)
|
.add_plugins(InputPlugin)
|
||||||
|
.add_plugins(SelectionPlugin)
|
||||||
.add_plugins(AnimationPlugin)
|
.add_plugins(AnimationPlugin)
|
||||||
.add_plugins(FeedbackAnimPlugin)
|
.add_plugins(FeedbackAnimPlugin)
|
||||||
|
.add_plugins(CardAnimationPlugin)
|
||||||
.add_plugins(AutoCompletePlugin)
|
.add_plugins(AutoCompletePlugin)
|
||||||
.add_plugins(StatsPlugin::default())
|
.add_plugins(StatsPlugin::default())
|
||||||
.add_plugins(ProgressPlugin::default())
|
.add_plugins(ProgressPlugin::default())
|
||||||
@@ -52,5 +59,6 @@ fn main() {
|
|||||||
.add_plugins(OnboardingPlugin)
|
.add_plugins(OnboardingPlugin)
|
||||||
.add_plugins(SyncPlugin::new(sync_provider))
|
.add_plugins(SyncPlugin::new(sync_provider))
|
||||||
.add_plugins(LeaderboardPlugin)
|
.add_plugins(LeaderboardPlugin)
|
||||||
|
.add_plugins(WinSummaryPlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solitaire_assetgen"
|
name = "solitaire_assetgen"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solitaire_core"
|
name = "solitaire_core"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -12,20 +12,25 @@
|
|||||||
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
|
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AchievementContext {
|
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,
|
pub games_played: u32,
|
||||||
|
/// Total number of games won (after this win has been recorded).
|
||||||
pub games_won: u32,
|
pub games_won: u32,
|
||||||
|
/// Current consecutive win streak (after this win has been recorded).
|
||||||
pub win_streak_current: u32,
|
pub win_streak_current: u32,
|
||||||
|
/// Highest single-game score ever achieved.
|
||||||
pub best_single_score: u32,
|
pub best_single_score: u32,
|
||||||
|
/// Cumulative score across all games ever played.
|
||||||
pub lifetime_score: u64,
|
pub lifetime_score: u64,
|
||||||
|
/// Total wins completed in Draw 3 mode.
|
||||||
pub draw_three_wins: u32,
|
pub draw_three_wins: u32,
|
||||||
|
|
||||||
// Progression.
|
|
||||||
/// Current daily-challenge completion streak (consecutive days).
|
/// Current daily-challenge completion streak (consecutive days).
|
||||||
pub daily_challenge_streak: u32,
|
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,
|
pub last_win_score: i32,
|
||||||
|
/// Elapsed seconds for the just-won game.
|
||||||
pub last_win_time_seconds: u64,
|
pub last_win_time_seconds: u64,
|
||||||
/// `true` if `undo()` was called at least once during the won game.
|
/// `true` if `undo()` was called at least once during the won game.
|
||||||
pub last_win_used_undo: bool,
|
pub last_win_used_undo: bool,
|
||||||
@@ -55,13 +60,17 @@ pub enum Reward {
|
|||||||
/// A single achievement's static metadata + unlock condition.
|
/// A single achievement's static metadata + unlock condition.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct AchievementDef {
|
pub struct AchievementDef {
|
||||||
|
/// Unique string identifier for this achievement (e.g. `"first_win"`).
|
||||||
pub id: &'static str,
|
pub id: &'static str,
|
||||||
|
/// Human-readable display name shown in the achievements screen.
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
|
/// Flavour text describing how to unlock the achievement.
|
||||||
pub description: &'static str,
|
pub description: &'static str,
|
||||||
/// Hidden from the achievements screen until unlocked.
|
/// Hidden from the achievements screen until unlocked.
|
||||||
pub secret: bool,
|
pub secret: bool,
|
||||||
/// Reward granted on first unlock. `None` for cosmetic-only recognition.
|
/// Reward granted on first unlock. `None` for cosmetic-only recognition.
|
||||||
pub reward: Option<Reward>,
|
pub reward: Option<Reward>,
|
||||||
|
/// Predicate evaluated on every `GameWonEvent` and `StateChangedEvent`. Returns true when the achievement should unlock.
|
||||||
pub condition: fn(&AchievementContext) -> bool,
|
pub condition: fn(&AchievementContext) -> bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +486,109 @@ mod tests {
|
|||||||
assert!(achievement_by_id("nonexistent").is_none());
|
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]
|
#[test]
|
||||||
fn on_a_roll_requires_streak_of_3() {
|
fn on_a_roll_requires_streak_of_3() {
|
||||||
let mut c = ctx();
|
let mut c = ctx();
|
||||||
|
|||||||
@@ -63,9 +63,13 @@ impl Rank {
|
|||||||
/// A single playing card.
|
/// A single playing card.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Card {
|
pub struct Card {
|
||||||
|
/// Unique identifier for this card within the deal. Stable across moves and undo.
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
|
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
|
||||||
pub suit: Suit,
|
pub suit: Suit,
|
||||||
|
/// The card's rank (Ace through King).
|
||||||
pub rank: Rank,
|
pub rank: Rank,
|
||||||
|
/// Whether the card is visible to the player. Face-down cards may not be moved.
|
||||||
pub face_up: bool,
|
pub face_up: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const ALL_RANKS: [Rank; 13] = [
|
|||||||
|
|
||||||
/// A standard 52-card deck.
|
/// A standard 52-card deck.
|
||||||
pub struct Deck {
|
pub struct Deck {
|
||||||
|
/// All 52 cards in the deck, in deal order.
|
||||||
pub cards: Vec<Card>,
|
pub cards: Vec<Card>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ mod pile_map_serde {
|
|||||||
/// Whether cards are drawn one at a time or three at a time from the stock.
|
/// Whether cards are drawn one at a time or three at a time from the stock.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum DrawMode {
|
pub enum DrawMode {
|
||||||
|
/// Draw one card from stock per turn.
|
||||||
DrawOne,
|
DrawOne,
|
||||||
|
/// Draw three cards from stock per turn; only the top is playable.
|
||||||
DrawThree,
|
DrawThree,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,9 +48,13 @@ pub enum DrawMode {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
pub enum GameMode {
|
pub enum GameMode {
|
||||||
#[default]
|
#[default]
|
||||||
|
/// Standard Klondike rules with score and timer.
|
||||||
Classic,
|
Classic,
|
||||||
|
/// No timer, no score display, ambient audio only.
|
||||||
Zen,
|
Zen,
|
||||||
|
/// Fixed hard seeds, no undo, must win to advance.
|
||||||
Challenge,
|
Challenge,
|
||||||
|
/// Play as many games as possible within 10 minutes.
|
||||||
TimeAttack,
|
TimeAttack,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,18 +70,26 @@ struct StateSnapshot {
|
|||||||
/// Full state of an in-progress Klondike Solitaire game.
|
/// Full state of an in-progress Klondike Solitaire game.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct GameState {
|
pub struct GameState {
|
||||||
|
/// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles.
|
||||||
#[serde(with = "pile_map_serde")]
|
#[serde(with = "pile_map_serde")]
|
||||||
pub piles: HashMap<PileType, Pile>,
|
pub piles: HashMap<PileType, Pile>,
|
||||||
|
/// Whether the player draws one or three cards from the stock per turn.
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawMode,
|
||||||
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards
|
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards
|
||||||
/// compatibility with older save files via `#[serde(default)]`.
|
/// compatibility with older save files via `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
|
/// Current game score. Can be negative (undo penalties subtract from score).
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
|
/// Total moves made this game, including draws and stock recycles.
|
||||||
pub move_count: u32,
|
pub move_count: u32,
|
||||||
|
/// Seconds elapsed since the game started, used for time-bonus scoring.
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
|
/// RNG seed used to deal this game. Same seed always produces the same layout.
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
|
/// True once all 52 cards are on the foundations. No further moves are accepted.
|
||||||
pub is_won: bool,
|
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,
|
pub is_auto_completable: bool,
|
||||||
/// Number of times `undo()` has been successfully invoked this game.
|
/// Number of times `undo()` has been successfully invoked this game.
|
||||||
/// Used by achievement conditions like `no_undo`.
|
/// Used by achievement conditions like `no_undo`.
|
||||||
@@ -173,6 +187,7 @@ impl GameState {
|
|||||||
stock.cards.push(card);
|
stock.cards.push(card);
|
||||||
}
|
}
|
||||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||||
|
self.move_count += 1;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +367,15 @@ impl GameState {
|
|||||||
/// Scans tableau piles 0–6 in order, returning the first top card that
|
/// Scans tableau piles 0–6 in order, returning the first top card that
|
||||||
/// can be placed on any foundation pile. The scan order ensures Aces are
|
/// can be placed on any foundation pile. The scan order ensures Aces are
|
||||||
/// resolved before higher ranks that depend on them.
|
/// 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)> {
|
pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> {
|
||||||
if !self.is_auto_completable || self.is_won {
|
if !self.is_auto_completable || self.is_won {
|
||||||
return None;
|
return None;
|
||||||
@@ -562,6 +586,24 @@ mod tests {
|
|||||||
assert_eq!(g.recycle_count, 2);
|
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]
|
#[test]
|
||||||
fn draw_from_empty_stock_and_waste_returns_error() {
|
fn draw_from_empty_stock_and_waste_returns_error() {
|
||||||
// The only stop condition for draw() is: both stock AND waste are
|
// 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);
|
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 ---
|
// --- next_auto_complete_move ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ pub enum PileType {
|
|||||||
/// A named collection of cards in a specific board position.
|
/// A named collection of cards in a specific board position.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Pile {
|
pub struct Pile {
|
||||||
|
/// Which pile this is (Stock, Waste, Foundation suit, or Tableau column).
|
||||||
pub pile_type: PileType,
|
pub pile_type: PileType,
|
||||||
|
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||||||
pub cards: Vec<Card>,
|
pub cards: Vec<Card>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,6 @@ mod tests {
|
|||||||
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
|
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
|
||||||
// Very short elapsed time would overflow without the .min() guard.
|
// Very short elapsed time would overflow without the .min() guard.
|
||||||
let bonus = compute_time_bonus(1);
|
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,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solitaire_data"
|
name = "solitaire_data"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -148,8 +148,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_xp_saturates_on_overflow() {
|
fn add_xp_saturates_on_overflow() {
|
||||||
let mut p = PlayerProgress::default();
|
let mut p = PlayerProgress { total_xp: u64::MAX - 5, ..Default::default() };
|
||||||
p.total_xp = u64::MAX - 5;
|
|
||||||
p.add_xp(100);
|
p.add_xp(100);
|
||||||
assert_eq!(p.total_xp, u64::MAX);
|
assert_eq!(p.total_xp, u64::MAX);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,8 +207,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adjust_sfx_volume_clamps() {
|
fn adjust_sfx_volume_clamps() {
|
||||||
let mut s = Settings::default();
|
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
|
||||||
s.sfx_volume = 0.5;
|
|
||||||
assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6);
|
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(0.5) - 1.0).abs() < 1e-6);
|
||||||
assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6);
|
assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6);
|
||||||
@@ -217,8 +216,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adjust_music_volume_clamps() {
|
fn adjust_music_volume_clamps() {
|
||||||
let mut s = Settings::default();
|
let mut s = Settings { music_volume: 0.5, ..Default::default() };
|
||||||
s.music_volume = 0.5;
|
|
||||||
assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6);
|
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(0.5) - 1.0).abs() < 1e-6);
|
||||||
assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6);
|
assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6);
|
||||||
@@ -241,14 +239,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitized_clamps_music_volume() {
|
fn sanitized_clamps_music_volume() {
|
||||||
let mut s = Settings::default();
|
let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized();
|
||||||
s.music_volume = 2.0;
|
|
||||||
let s = s.sanitized();
|
|
||||||
assert_eq!(s.music_volume, 1.0);
|
assert_eq!(s.music_volume, 1.0);
|
||||||
|
|
||||||
let mut s2 = Settings::default();
|
let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized();
|
||||||
s2.music_volume = -0.5;
|
|
||||||
let s2 = s2.sanitized();
|
|
||||||
assert_eq!(s2.music_volume, 0.0);
|
assert_eq!(s2.music_volume, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub use solitaire_sync::StatsSnapshot;
|
|||||||
///
|
///
|
||||||
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
|
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
|
||||||
pub trait StatsExt {
|
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);
|
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +173,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lifetime_score_saturates_at_u64_max() {
|
fn lifetime_score_saturates_at_u64_max() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot { lifetime_score: u64::MAX - 100, ..Default::default() };
|
||||||
s.lifetime_score = u64::MAX - 100;
|
|
||||||
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
||||||
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
|
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use solitaire_core::game_state::DrawMode;
|
|||||||
/// XP awarded each time a weekly goal is just completed.
|
/// XP awarded each time a weekly goal is just completed.
|
||||||
pub const WEEKLY_GOAL_XP: u64 = 75;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum WeeklyGoalKind {
|
pub enum WeeklyGoalKind {
|
||||||
/// Any win counts.
|
/// Any win counts.
|
||||||
@@ -22,7 +22,7 @@ pub enum WeeklyGoalKind {
|
|||||||
WinDrawThree,
|
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)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct WeeklyGoalDef {
|
pub struct WeeklyGoalDef {
|
||||||
pub id: &'static str,
|
pub id: &'static str,
|
||||||
@@ -31,7 +31,7 @@ pub struct WeeklyGoalDef {
|
|||||||
pub kind: WeeklyGoalKind,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WeeklyGoalContext {
|
pub struct WeeklyGoalContext {
|
||||||
pub time_seconds: u64,
|
pub time_seconds: u64,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solitaire_engine"
|
name = "solitaire_engine"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ impl Plugin for AchievementPlugin {
|
|||||||
|
|
||||||
app.insert_resource(AchievementsResource(records))
|
app.insert_resource(AchievementsResource(records))
|
||||||
.insert_resource(AchievementsStoragePath(self.storage_path.clone()))
|
.insert_resource(AchievementsStoragePath(self.storage_path.clone()))
|
||||||
.add_event::<AchievementUnlockedEvent>()
|
.add_message::<AchievementUnlockedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_event::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
// Run after GameMutation (so GameWonEvent is available), after
|
// Run after GameMutation (so GameWonEvent is available), after
|
||||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||||
// (so daily_challenge_streak is up to date for daily_devotee).
|
// (so daily_challenge_streak is up to date for daily_devotee).
|
||||||
@@ -89,10 +89,10 @@ impl Plugin for AchievementPlugin {
|
|||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn evaluate_on_win(
|
fn evaluate_on_win(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: MessageReader<GameWonEvent>,
|
||||||
mut unlocks: EventWriter<AchievementUnlockedEvent>,
|
mut unlocks: MessageWriter<AchievementUnlockedEvent>,
|
||||||
mut levelups: EventWriter<LevelUpEvent>,
|
mut levelups: MessageWriter<LevelUpEvent>,
|
||||||
mut xp_awarded: EventWriter<XpAwardedEvent>,
|
mut xp_awarded: MessageWriter<XpAwardedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
stats: Res<StatsResource>,
|
stats: Res<StatsResource>,
|
||||||
path: Res<AchievementsStoragePath>,
|
path: Res<AchievementsStoragePath>,
|
||||||
@@ -156,10 +156,10 @@ fn evaluate_on_win(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reward::BonusXp(amount) => {
|
Reward::BonusXp(amount) => {
|
||||||
xp_awarded.send(XpAwardedEvent { amount });
|
xp_awarded.write(XpAwardedEvent { amount });
|
||||||
let prev_level = progress.0.add_xp(amount);
|
let prev_level = progress.0.add_xp(amount);
|
||||||
if progress.0.leveled_up_from(prev_level) {
|
if progress.0.leveled_up_from(prev_level) {
|
||||||
levelups.send(LevelUpEvent {
|
levelups.write(LevelUpEvent {
|
||||||
previous_level: prev_level,
|
previous_level: prev_level,
|
||||||
new_level: progress.0.level,
|
new_level: progress.0.level,
|
||||||
total_xp: progress.0.total_xp,
|
total_xp: progress.0.total_xp,
|
||||||
@@ -173,7 +173,7 @@ fn evaluate_on_win(
|
|||||||
record.reward_granted = true;
|
record.reward_granted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
unlocks.send(AchievementUnlockedEvent(record.clone()));
|
unlocks.write(AchievementUnlockedEvent(record.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if achievements_changed {
|
if achievements_changed {
|
||||||
@@ -211,8 +211,8 @@ fn toggle_achievements_screen(
|
|||||||
if !keys.just_pressed(KeyCode::KeyA) {
|
if !keys.just_pressed(KeyCode::KeyA) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(entity) = screens.get_single() {
|
if let Ok(entity) = screens.single() {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
} else {
|
} else {
|
||||||
spawn_achievements_screen(&mut commands, &achievements.0);
|
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),
|
min_width: Val::Px(380.0),
|
||||||
max_height: Val::Percent(80.0),
|
max_height: Val::Percent(80.0),
|
||||||
overflow: Overflow::clip_y(),
|
overflow: Overflow::clip_y(),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(8.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
|
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
|
||||||
BorderRadius::all(Val::Px(8.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|card| {
|
.with_children(|card| {
|
||||||
// Header
|
// Header
|
||||||
@@ -398,7 +398,7 @@ mod tests {
|
|||||||
|
|
||||||
// StatsPlugin runs update_stats_on_win first (after GameMutation); that
|
// StatsPlugin runs update_stats_on_win first (after GameMutation); that
|
||||||
// bumps games_won to 1 before evaluate_on_win reads StatsResource.
|
// 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,
|
score: 1000,
|
||||||
time_seconds: 300,
|
time_seconds: 300,
|
||||||
});
|
});
|
||||||
@@ -415,7 +415,7 @@ mod tests {
|
|||||||
assert!(unlocked_first_win);
|
assert!(unlocked_first_win);
|
||||||
|
|
||||||
// Verify the event was emitted.
|
// 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 mut cursor = events.get_cursor();
|
||||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
||||||
assert!(fired.contains(&"first_win".to_string()));
|
assert!(fired.contains(&"first_win".to_string()));
|
||||||
@@ -425,7 +425,7 @@ mod tests {
|
|||||||
fn repeated_win_does_not_refire_already_unlocked_achievement() {
|
fn repeated_win_does_not_refire_already_unlocked_achievement() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 1000,
|
score: 1000,
|
||||||
time_seconds: 300,
|
time_seconds: 300,
|
||||||
});
|
});
|
||||||
@@ -433,16 +433,16 @@ mod tests {
|
|||||||
|
|
||||||
// Clear events from first win.
|
// Clear events from first win.
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<Events<AchievementUnlockedEvent>>()
|
.resource_mut::<Messages<AchievementUnlockedEvent>>()
|
||||||
.clear();
|
.clear();
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 1000,
|
score: 1000,
|
||||||
time_seconds: 300,
|
time_seconds: 300,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
|
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -462,13 +462,13 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// "no_undo" achievement awards BonusXp(25). Trigger it by sending a
|
// "no_undo" achievement awards BonusXp(25). Trigger it by sending a
|
||||||
// GameWonEvent with undo_count == 0 (default) and enough stats to match.
|
// 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,
|
score: 1000,
|
||||||
time_seconds: 300,
|
time_seconds: 300,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<XpAwardedEvent>>();
|
let events = app.world().resource::<Messages<XpAwardedEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let xp_events: Vec<u64> = cursor.read(events).map(|e| e.amount).collect();
|
let xp_events: Vec<u64> = cursor.read(events).map(|e| e.amount).collect();
|
||||||
// The no_undo achievement (BonusXp 25) must have fired an XpAwardedEvent.
|
// The no_undo achievement (BonusXp 25) must have fired an XpAwardedEvent.
|
||||||
@@ -487,14 +487,14 @@ mod tests {
|
|||||||
.0
|
.0
|
||||||
.undo_count = 1;
|
.undo_count = 1;
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 1000,
|
score: 1000,
|
||||||
time_seconds: 300,
|
time_seconds: 300,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// "no_undo" awards BonusXp(25). If undo was used it must NOT fire.
|
// "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 mut cursor = events.get_cursor();
|
||||||
let xp_events: Vec<u64> = cursor.read(events).map(|e| e.amount).collect();
|
let xp_events: Vec<u64> = cursor.read(events).map(|e| e.amount).collect();
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ pub struct ActiveToast {
|
|||||||
/// Duration of each queued info-toast in seconds.
|
/// Duration of each queued info-toast in seconds.
|
||||||
const QUEUED_TOAST_SECS: f32 = 2.5;
|
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;
|
pub struct AnimationPlugin;
|
||||||
|
|
||||||
impl Plugin for 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
|
// Register the events this plugin consumes so tests that don't include
|
||||||
// GamePlugin can still run AnimationPlugin in isolation. Double-registration
|
// GamePlugin can still run AnimationPlugin in isolation. Double-registration
|
||||||
// is idempotent in Bevy.
|
// is idempotent in Bevy.
|
||||||
app.add_event::<GameWonEvent>()
|
app.add_message::<GameWonEvent>()
|
||||||
.add_event::<AchievementUnlockedEvent>()
|
.add_message::<AchievementUnlockedEvent>()
|
||||||
.add_event::<LevelUpEvent>()
|
.add_message::<LevelUpEvent>()
|
||||||
.add_event::<DailyChallengeCompletedEvent>()
|
.add_message::<DailyChallengeCompletedEvent>()
|
||||||
.add_event::<DailyGoalAnnouncementEvent>()
|
.add_message::<DailyGoalAnnouncementEvent>()
|
||||||
.add_event::<WeeklyGoalCompletedEvent>()
|
.add_message::<WeeklyGoalCompletedEvent>()
|
||||||
.add_event::<TimeAttackEndedEvent>()
|
.add_message::<TimeAttackEndedEvent>()
|
||||||
.add_event::<ChallengeAdvancedEvent>()
|
.add_message::<ChallengeAdvancedEvent>()
|
||||||
.add_event::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_event::<NewGameConfirmEvent>()
|
.add_message::<NewGameConfirmEvent>()
|
||||||
.add_event::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_event::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
.init_resource::<ToastQueue>()
|
.init_resource::<ToastQueue>()
|
||||||
.init_resource::<ActiveToast>()
|
.init_resource::<ActiveToast>()
|
||||||
@@ -207,7 +208,7 @@ fn init_slide_duration(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sync_slide_duration(
|
fn sync_slide_duration(
|
||||||
mut events: EventReader<SettingsChangedEvent>,
|
mut events: MessageReader<SettingsChangedEvent>,
|
||||||
mut dur: ResMut<EffectiveSlideDuration>,
|
mut dur: ResMut<EffectiveSlideDuration>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
@@ -245,7 +246,7 @@ fn advance_card_anims(
|
|||||||
|
|
||||||
fn handle_win_cascade(
|
fn handle_win_cascade(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: EventReader<GameWonEvent>,
|
mut events: MessageReader<GameWonEvent>,
|
||||||
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
@@ -290,7 +291,7 @@ fn handle_win_cascade(
|
|||||||
|
|
||||||
fn handle_achievement_toast(
|
fn handle_achievement_toast(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: EventReader<AchievementUnlockedEvent>,
|
mut events: MessageReader<AchievementUnlockedEvent>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
spawn_toast(
|
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() {
|
for ev in events.read() {
|
||||||
spawn_toast(
|
spawn_toast(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
@@ -313,7 +314,7 @@ fn handle_levelup_toast(mut commands: Commands, mut events: EventReader<LevelUpE
|
|||||||
|
|
||||||
fn handle_daily_goal_announcement_toast(
|
fn handle_daily_goal_announcement_toast(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: EventReader<DailyGoalAnnouncementEvent>,
|
mut events: MessageReader<DailyGoalAnnouncementEvent>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
spawn_toast(&mut commands, format!("Goal: {}", ev.0), DAILY_TOAST_SECS);
|
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(
|
fn handle_daily_toast(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: EventReader<DailyChallengeCompletedEvent>,
|
mut events: MessageReader<DailyChallengeCompletedEvent>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
spawn_toast(
|
spawn_toast(
|
||||||
@@ -335,7 +336,7 @@ fn handle_daily_toast(
|
|||||||
|
|
||||||
fn handle_weekly_toast(
|
fn handle_weekly_toast(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: EventReader<WeeklyGoalCompletedEvent>,
|
mut events: MessageReader<WeeklyGoalCompletedEvent>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
spawn_toast(
|
spawn_toast(
|
||||||
@@ -348,7 +349,7 @@ fn handle_weekly_toast(
|
|||||||
|
|
||||||
fn handle_time_attack_toast(
|
fn handle_time_attack_toast(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: EventReader<TimeAttackEndedEvent>,
|
mut events: MessageReader<TimeAttackEndedEvent>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
spawn_toast(
|
spawn_toast(
|
||||||
@@ -361,7 +362,7 @@ fn handle_time_attack_toast(
|
|||||||
|
|
||||||
fn handle_challenge_toast(
|
fn handle_challenge_toast(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: EventReader<ChallengeAdvancedEvent>,
|
mut events: MessageReader<ChallengeAdvancedEvent>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
spawn_toast(
|
spawn_toast(
|
||||||
@@ -374,7 +375,7 @@ fn handle_challenge_toast(
|
|||||||
|
|
||||||
fn handle_settings_toast(
|
fn handle_settings_toast(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: EventReader<SettingsChangedEvent>,
|
mut events: MessageReader<SettingsChangedEvent>,
|
||||||
mut last_sfx: Local<Option<f32>>,
|
mut last_sfx: Local<Option<f32>>,
|
||||||
mut last_music: Local<Option<f32>>,
|
mut last_music: Local<Option<f32>>,
|
||||||
) {
|
) {
|
||||||
@@ -417,7 +418,7 @@ fn handle_auto_complete_toast(
|
|||||||
|
|
||||||
fn handle_new_game_confirm_toast(
|
fn handle_new_game_confirm_toast(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: EventReader<NewGameConfirmEvent>,
|
mut events: MessageReader<NewGameConfirmEvent>,
|
||||||
) {
|
) {
|
||||||
for _ in events.read() {
|
for _ in events.read() {
|
||||||
spawn_toast(&mut commands, "Press N again to start a new game".to_string(), 3.0);
|
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
|
/// decouples event production from rendering so multiple simultaneous events do
|
||||||
/// not cause overlapping toast text on screen.
|
/// not cause overlapping toast text on screen.
|
||||||
fn enqueue_toasts(
|
fn enqueue_toasts(
|
||||||
mut events: EventReader<InfoToastEvent>,
|
mut events: MessageReader<InfoToastEvent>,
|
||||||
mut queue: ResMut<ToastQueue>,
|
mut queue: ResMut<ToastQueue>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
@@ -465,7 +466,7 @@ fn drive_toast_display(
|
|||||||
active.timer -= dt;
|
active.timer -= dt;
|
||||||
if active.timer <= 0.0 {
|
if active.timer <= 0.0 {
|
||||||
// Despawn the toast entity and clear the active slot.
|
// Despawn the toast entity and clear the active slot.
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
active.entity = None;
|
active.entity = None;
|
||||||
active.timer = 0.0;
|
active.timer = 0.0;
|
||||||
}
|
}
|
||||||
@@ -509,7 +510,7 @@ fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
|||||||
.id()
|
.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() {
|
for ev in events.read() {
|
||||||
spawn_toast(&mut commands, format!("+{} XP", ev.amount), 3.0);
|
spawn_toast(&mut commands, format!("+{} XP", ev.amount), 3.0);
|
||||||
}
|
}
|
||||||
@@ -532,7 +533,7 @@ fn tick_toasts(
|
|||||||
for (entity, mut timer) in &mut toasts {
|
for (entity, mut timer) in &mut toasts {
|
||||||
timer.0 -= dt;
|
timer.0 -= dt;
|
||||||
if timer.0 <= 0.0 {
|
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();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
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();
|
app.update();
|
||||||
|
|
||||||
let count = app
|
let count = app
|
||||||
@@ -745,7 +746,7 @@ mod tests {
|
|||||||
fn toast_queue_enqueues_on_event() {
|
fn toast_queue_enqueues_on_event() {
|
||||||
let mut app = queue_app();
|
let mut app = queue_app();
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(InfoToastEvent("test message".to_string()));
|
.write_message(InfoToastEvent("test message".to_string()));
|
||||||
app.update();
|
app.update();
|
||||||
// After one update the message should have been consumed (shown) or is
|
// 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
|
// still in the queue — either way we verify the system processed it by
|
||||||
@@ -775,9 +776,8 @@ mod tests {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
|
||||||
let mut fast_settings = Settings::default();
|
let fast_settings = Settings { animation_speed: AnimSpeed::Fast, ..Default::default() };
|
||||||
fast_settings.animation_speed = AnimSpeed::Fast;
|
app.world_mut().write_message(SettingsChangedEvent(fast_settings));
|
||||||
app.world_mut().send_event(SettingsChangedEvent(fast_settings));
|
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let dur = app.world().resource::<EffectiveSlideDuration>().slide_secs;
|
let dur = app.world().resource::<EffectiveSlideDuration>().slide_secs;
|
||||||
@@ -796,7 +796,7 @@ mod tests {
|
|||||||
assert_eq!(before, 0, "no animations before win");
|
assert_eq!(before, 0, "no animations before win");
|
||||||
|
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(GameWonEvent { score: 500, time_seconds: 60 });
|
.write_message(GameWonEvent { score: 500, time_seconds: 60 });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let after = app
|
let after = app
|
||||||
|
|||||||
@@ -23,13 +23,10 @@
|
|||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use kira::manager::backend::DefaultBackend;
|
|
||||||
use kira::manager::{AudioManager, AudioManagerSettings};
|
|
||||||
use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
|
use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
|
||||||
use kira::sound::Region;
|
use kira::sound::Region;
|
||||||
use kira::track::{TrackBuilder, TrackHandle};
|
use kira::track::{TrackBuilder, TrackHandle};
|
||||||
use kira::tween::Tween;
|
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
|
||||||
use kira::Volume;
|
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
|
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
|
||||||
@@ -46,6 +43,16 @@ const RECYCLE_VOLUME: f64 = 0.5;
|
|||||||
/// Volume amplitude for the ambient music loop placeholder.
|
/// Volume amplitude for the ambient music loop placeholder.
|
||||||
const AMBIENT_VOLUME: f64 = 0.05;
|
const AMBIENT_VOLUME: f64 = 0.05;
|
||||||
|
|
||||||
|
/// Converts a linear amplitude (0.0–1.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
|
/// Returns `true` when a `DrawRequestEvent` will recycle the waste pile back
|
||||||
/// to stock rather than drawing a new card.
|
/// 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]>`),
|
/// 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)]
|
#[derive(Resource, Clone)]
|
||||||
pub struct SoundLibrary {
|
pub struct SoundLibrary {
|
||||||
pub deal: StaticSoundData,
|
pub deal: StaticSoundData,
|
||||||
@@ -90,6 +97,7 @@ pub struct MuteState {
|
|||||||
pub music_muted: bool,
|
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;
|
pub struct AudioPlugin;
|
||||||
|
|
||||||
impl Plugin for AudioPlugin {
|
impl Plugin for AudioPlugin {
|
||||||
@@ -104,7 +112,7 @@ impl Plugin for AudioPlugin {
|
|||||||
warn!("failed to decode embedded SFX assets; SFX disabled");
|
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) => {
|
Some(mgr) => {
|
||||||
let sfx = mgr.add_sub_track(TrackBuilder::default()).ok();
|
let sfx = mgr.add_sub_track(TrackBuilder::default()).ok();
|
||||||
let music = 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
|
// Start the ambient loop placeholder (card_flip.wav looped at very low
|
||||||
// volume through music_track).
|
// volume through music_track).
|
||||||
let ambient_handle =
|
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 {
|
app.insert_non_send_resource(AudioState {
|
||||||
manager,
|
manager,
|
||||||
@@ -130,15 +138,15 @@ impl Plugin for AudioPlugin {
|
|||||||
app.insert_resource(lib);
|
app.insert_resource(lib);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.add_event::<DrawRequestEvent>()
|
app.add_message::<DrawRequestEvent>()
|
||||||
.add_event::<MoveRequestEvent>()
|
.add_message::<MoveRequestEvent>()
|
||||||
.add_event::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_event::<CardFlippedEvent>()
|
.add_message::<CardFlippedEvent>()
|
||||||
.add_event::<CardFaceRevealedEvent>()
|
.add_message::<CardFaceRevealedEvent>()
|
||||||
.add_event::<UndoRequestEvent>()
|
.add_message::<UndoRequestEvent>()
|
||||||
.add_event::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_systems(Startup, apply_initial_volume)
|
.add_systems(Startup, apply_initial_volume)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -190,20 +198,22 @@ fn decode(bytes: &'static [u8]) -> Option<StaticSoundData> {
|
|||||||
fn start_ambient_loop(
|
fn start_ambient_loop(
|
||||||
manager: Option<&mut AudioManager<DefaultBackend>>,
|
manager: Option<&mut AudioManager<DefaultBackend>>,
|
||||||
library: Option<&SoundLibrary>,
|
library: Option<&SoundLibrary>,
|
||||||
music_track: &Option<TrackHandle>,
|
music_track: &mut Option<TrackHandle>,
|
||||||
) -> Option<StaticSoundHandle> {
|
) -> Option<StaticSoundHandle> {
|
||||||
let manager = manager?;
|
let manager = manager?;
|
||||||
let lib = library?;
|
let lib = library?;
|
||||||
|
|
||||||
let mut data = lib.flip.clone();
|
let mut data = lib.flip.clone();
|
||||||
// Loop the entire file from start to end.
|
|
||||||
data.settings.loop_region = Some(Region::default());
|
data.settings.loop_region = Some(Region::default());
|
||||||
data.settings.volume = Volume::Amplitude(AMBIENT_VOLUME).into();
|
data.settings.volume = Value::Fixed(amplitude_to_decibels(AMBIENT_VOLUME as f32));
|
||||||
if let Some(track) = music_track {
|
|
||||||
data.settings.output_destination = track.id().into();
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
Ok(handle) => Some(handle),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("failed to start ambient loop: {e}");
|
warn!("failed to start ambient loop: {e}");
|
||||||
@@ -213,16 +223,17 @@ fn start_ambient_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn play(audio: &mut AudioState, sound: &StaticSoundData) {
|
fn play(audio: &mut AudioState, sound: &StaticSoundData) {
|
||||||
let Some(manager) = audio.manager.as_mut() else {
|
let data = sound.clone();
|
||||||
return;
|
|
||||||
};
|
|
||||||
// Route SFX through the dedicated sfx_track so its volume is independent
|
// Route SFX through the dedicated sfx_track so its volume is independent
|
||||||
// of the music_track volume.
|
// of the music_track volume.
|
||||||
let mut data = sound.clone();
|
let result = if let Some(track) = audio.sfx_track.as_mut() {
|
||||||
if let Some(track) = &audio.sfx_track {
|
track.play(data)
|
||||||
data.settings.output_destination = track.id().into();
|
} else if let Some(manager) = audio.manager.as_mut() {
|
||||||
}
|
manager.play(data)
|
||||||
if let Err(e) = manager.play(data) {
|
} else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(e) = result {
|
||||||
warn!("failed to play SFX: {e}");
|
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
|
/// explicit volume override so callers can play sounds at a fraction of their
|
||||||
/// normal level. Silently does nothing when audio is unavailable.
|
/// normal level. Silently does nothing when audio is unavailable.
|
||||||
pub fn play_sfx_at_volume(&mut self, sound: &StaticSoundData, volume: f64) {
|
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;
|
return;
|
||||||
};
|
};
|
||||||
let mut data = sound.clone();
|
if let Err(e) = result {
|
||||||
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) {
|
|
||||||
warn!("failed to play SFX at volume {volume}: {e}");
|
warn!("failed to play SFX at volume {volume}: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,13 +263,13 @@ impl AudioState {
|
|||||||
|
|
||||||
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
|
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
|
||||||
if let Some(track) = audio.sfx_track.as_mut() {
|
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) {
|
fn set_music_volume(audio: &mut AudioState, volume: f32) {
|
||||||
if let Some(track) = audio.music_track.as_mut() {
|
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(
|
fn play_on_undo(
|
||||||
mut events: EventReader<UndoRequestEvent>,
|
mut events: MessageReader<UndoRequestEvent>,
|
||||||
mut audio: NonSendMut<AudioState>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
lib: Option<Res<SoundLibrary>>,
|
lib: Option<Res<SoundLibrary>>,
|
||||||
) {
|
) {
|
||||||
@@ -281,7 +294,7 @@ fn play_on_undo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn apply_volume_on_change(
|
fn apply_volume_on_change(
|
||||||
mut events: EventReader<SettingsChangedEvent>,
|
mut events: MessageReader<SettingsChangedEvent>,
|
||||||
mut audio: NonSendMut<AudioState>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
mute: Option<Res<MuteState>>,
|
mute: Option<Res<MuteState>>,
|
||||||
) {
|
) {
|
||||||
@@ -326,7 +339,7 @@ fn handle_mute_keys(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn play_on_draw(
|
fn play_on_draw(
|
||||||
mut events: EventReader<DrawRequestEvent>,
|
mut events: MessageReader<DrawRequestEvent>,
|
||||||
mut audio: NonSendMut<AudioState>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
lib: Option<Res<SoundLibrary>>,
|
lib: Option<Res<SoundLibrary>>,
|
||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
@@ -345,14 +358,17 @@ fn play_on_draw(
|
|||||||
|
|
||||||
if is_recycle(stock_len) {
|
if is_recycle(stock_len) {
|
||||||
let mut data = lib.flip.clone();
|
let mut data = lib.flip.clone();
|
||||||
data.settings.volume = Volume::Amplitude(RECYCLE_VOLUME).into();
|
data.settings.volume =
|
||||||
if let Some(track) = &audio.sfx_track {
|
Value::Fixed(amplitude_to_decibels(RECYCLE_VOLUME as f32));
|
||||||
data.settings.output_destination = track.id().into();
|
let result = if let Some(track) = audio.sfx_track.as_mut() {
|
||||||
}
|
track.play(data)
|
||||||
if let Some(manager) = audio.manager.as_mut() {
|
} else if let Some(manager) = audio.manager.as_mut() {
|
||||||
if let Err(e) = manager.play(data) {
|
manager.play(data)
|
||||||
warn!("failed to play recycle SFX: {e}");
|
} else {
|
||||||
}
|
continue;
|
||||||
|
};
|
||||||
|
if let Err(e) = result {
|
||||||
|
warn!("failed to play recycle SFX: {e}");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
play(&mut audio, &lib.flip);
|
play(&mut audio, &lib.flip);
|
||||||
@@ -361,7 +377,7 @@ fn play_on_draw(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn play_on_move(
|
fn play_on_move(
|
||||||
mut events: EventReader<MoveRequestEvent>,
|
mut events: MessageReader<MoveRequestEvent>,
|
||||||
mut audio: NonSendMut<AudioState>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
lib: Option<Res<SoundLibrary>>,
|
lib: Option<Res<SoundLibrary>>,
|
||||||
) {
|
) {
|
||||||
@@ -374,7 +390,7 @@ fn play_on_move(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn play_on_rejected(
|
fn play_on_rejected(
|
||||||
mut events: EventReader<MoveRejectedEvent>,
|
mut events: MessageReader<MoveRejectedEvent>,
|
||||||
mut audio: NonSendMut<AudioState>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
lib: Option<Res<SoundLibrary>>,
|
lib: Option<Res<SoundLibrary>>,
|
||||||
) {
|
) {
|
||||||
@@ -387,7 +403,7 @@ fn play_on_rejected(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn play_on_new_game(
|
fn play_on_new_game(
|
||||||
mut events: EventReader<NewGameRequestEvent>,
|
mut events: MessageReader<NewGameRequestEvent>,
|
||||||
mut audio: NonSendMut<AudioState>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
lib: Option<Res<SoundLibrary>>,
|
lib: Option<Res<SoundLibrary>>,
|
||||||
) {
|
) {
|
||||||
@@ -400,7 +416,7 @@ fn play_on_new_game(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn play_on_win(
|
fn play_on_win(
|
||||||
mut events: EventReader<GameWonEvent>,
|
mut events: MessageReader<GameWonEvent>,
|
||||||
mut audio: NonSendMut<AudioState>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
lib: Option<Res<SoundLibrary>>,
|
lib: Option<Res<SoundLibrary>>,
|
||||||
) {
|
) {
|
||||||
@@ -418,7 +434,7 @@ fn play_on_win(
|
|||||||
/// Driven by `CardFaceRevealedEvent`, which is fired by `tick_flip_anim` at
|
/// Driven by `CardFaceRevealedEvent`, which is fired by `tick_flip_anim` at
|
||||||
/// the phase transition (scale.x crosses 0), not by the move event itself.
|
/// the phase transition (scale.x crosses 0), not by the move event itself.
|
||||||
fn play_on_face_revealed(
|
fn play_on_face_revealed(
|
||||||
mut events: EventReader<CardFaceRevealedEvent>,
|
mut events: MessageReader<CardFaceRevealedEvent>,
|
||||||
mut audio: NonSendMut<AudioState>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
lib: Option<Res<SoundLibrary>>,
|
lib: Option<Res<SoundLibrary>>,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ impl Plugin for AutoCompletePlugin {
|
|||||||
fn detect_auto_complete(
|
fn detect_auto_complete(
|
||||||
mut state: ResMut<AutoCompleteState>,
|
mut state: ResMut<AutoCompleteState>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut changed: EventReader<StateChangedEvent>,
|
mut changed: MessageReader<StateChangedEvent>,
|
||||||
) {
|
) {
|
||||||
// Only re-evaluate on state changes to avoid per-frame allocations.
|
// Only re-evaluate on state changes to avoid per-frame allocations.
|
||||||
if changed.is_empty() && !game.is_changed() {
|
if changed.is_empty() && !game.is_changed() {
|
||||||
@@ -106,7 +106,7 @@ fn drive_auto_complete(
|
|||||||
mut state: ResMut<AutoCompleteState>,
|
mut state: ResMut<AutoCompleteState>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
mut moves: EventWriter<MoveRequestEvent>,
|
mut moves: MessageWriter<MoveRequestEvent>,
|
||||||
) {
|
) {
|
||||||
if !state.active {
|
if !state.active {
|
||||||
return;
|
return;
|
||||||
@@ -122,7 +122,7 @@ fn drive_auto_complete(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
moves.send(MoveRequestEvent { from, to, count: 1 });
|
moves.write(MoveRequestEvent { from, to, count: 1 });
|
||||||
state.cooldown = STEP_INTERVAL;
|
state.cooldown = STEP_INTERVAL;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Install a nearly-won state and fire StateChangedEvent.
|
// Install a nearly-won state and fire StateChangedEvent.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
|
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
|
||||||
app.world_mut().send_event(StateChangedEvent);
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
assert!(app.world().resource::<AutoCompleteState>().active);
|
assert!(app.world().resource::<AutoCompleteState>().active);
|
||||||
@@ -186,11 +186,11 @@ mod tests {
|
|||||||
fn drive_fires_move_request_when_active() {
|
fn drive_fires_move_request_when_active() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
|
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(); // detect runs, sets active
|
||||||
app.update(); // drive fires the move
|
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 mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).collect();
|
let fired: Vec<_> = cursor.read(events).collect();
|
||||||
// At least one MoveRequestEvent should have been fired.
|
// At least one MoveRequestEvent should have been fired.
|
||||||
@@ -206,7 +206,7 @@ mod tests {
|
|||||||
let mut gs = nearly_won_state();
|
let mut gs = nearly_won_state();
|
||||||
gs.is_won = true;
|
gs.is_won = true;
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||||
app.world_mut().send_event(StateChangedEvent);
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
assert!(!app.world().resource::<AutoCompleteState>().active);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,11 +82,28 @@ pub struct HintHighlight {
|
|||||||
pub remaining: f32,
|
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
|
/// Marker on a `PileMarker` entity that is highlighted because the right-clicked
|
||||||
/// card can legally be placed there.
|
/// card can legally be placed there.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct RightClickHighlight;
|
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 placed on the child `Text2d` entity that shows "↺" on the stock pile
|
||||||
/// marker when the stock pile is empty.
|
/// marker when the stock pile is empty.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
@@ -140,9 +157,9 @@ impl Plugin for CardPlugin {
|
|||||||
// `MinimalPlugins` (tests) this resource is absent by default, so we
|
// `MinimalPlugins` (tests) this resource is absent by default, so we
|
||||||
// ensure it exists here. Under `DefaultPlugins` the call is a no-op.
|
// ensure it exists here. Under `DefaultPlugins` the call is a no-op.
|
||||||
app.init_resource::<ButtonInput<MouseButton>>()
|
app.init_resource::<ButtonInput<MouseButton>>()
|
||||||
.add_event::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_event::<CardFlippedEvent>()
|
.add_message::<CardFlippedEvent>()
|
||||||
.add_event::<CardFaceRevealedEvent>()
|
.add_message::<CardFaceRevealedEvent>()
|
||||||
.add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup))
|
.add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup))
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -154,6 +171,7 @@ impl Plugin for CardPlugin {
|
|||||||
update_drag_shadow,
|
update_drag_shadow,
|
||||||
tick_hint_highlight,
|
tick_hint_highlight,
|
||||||
handle_right_click,
|
handle_right_click,
|
||||||
|
tick_right_click_highlights,
|
||||||
clear_right_click_highlights_on_state_change.after(GameMutation),
|
clear_right_click_highlights_on_state_change.after(GameMutation),
|
||||||
clear_right_click_highlights_on_pause,
|
clear_right_click_highlights_on_pause,
|
||||||
update_stock_empty_indicator.after(GameMutation),
|
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
|
/// 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).
|
/// new back colour is applied immediately (without waiting for a state change).
|
||||||
fn resync_cards_on_settings_change(
|
fn resync_cards_on_settings_change(
|
||||||
mut setting_events: EventReader<SettingsChangedEvent>,
|
mut setting_events: MessageReader<SettingsChangedEvent>,
|
||||||
mut state_events: EventWriter<StateChangedEvent>,
|
mut state_events: MessageWriter<StateChangedEvent>,
|
||||||
) {
|
) {
|
||||||
if setting_events.read().next().is_some() {
|
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(
|
fn sync_cards_on_change(
|
||||||
mut events: EventReader<StateChangedEvent>,
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
commands: Commands,
|
commands: Commands,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
@@ -238,7 +256,7 @@ fn sync_cards(
|
|||||||
// Despawn any entity whose card is no longer tracked.
|
// Despawn any entity whose card is no longer tracked.
|
||||||
for (card_id, (entity, _)) in &existing {
|
for (card_id, (entity, _)) in &existing {
|
||||||
if !live_ids.contains(card_id) {
|
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/
|
// 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.
|
// 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| {
|
commands.entity(entity).with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
CardLabel,
|
CardLabel,
|
||||||
@@ -490,7 +508,7 @@ fn label_visibility(card: &Card) -> Visibility {
|
|||||||
///
|
///
|
||||||
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
|
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
|
||||||
fn start_flip_anim(
|
fn start_flip_anim(
|
||||||
mut events: EventReader<CardFlippedEvent>,
|
mut events: MessageReader<CardFlippedEvent>,
|
||||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
card_entities: Query<(Entity, &CardEntity)>,
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
@@ -525,7 +543,7 @@ fn tick_flip_anim(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
|
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
|
||||||
mut reveal_events: EventWriter<CardFaceRevealedEvent>,
|
mut reveal_events: MessageWriter<CardFaceRevealedEvent>,
|
||||||
) {
|
) {
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (entity, card_entity, mut transform, mut anim) in &mut anims {
|
for (entity, card_entity, mut transform, mut anim) in &mut anims {
|
||||||
@@ -540,7 +558,7 @@ fn tick_flip_anim(
|
|||||||
transform.scale.x = 0.0;
|
transform.scale.x = 0.0;
|
||||||
// Fire the reveal event exactly once, at the phase transition,
|
// Fire the reveal event exactly once, at the phase transition,
|
||||||
// so the flip sound is synchronised with the visual face reveal.
|
// 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 => {
|
FlipPhase::ScalingUp => {
|
||||||
@@ -574,7 +592,7 @@ fn update_drag_shadow(
|
|||||||
if drag.is_idle() {
|
if drag.is_idle() {
|
||||||
// No drag in progress — remove shadow if it exists.
|
// No drag in progress — remove shadow if it exists.
|
||||||
if let Some(e) = shadow.take() {
|
if let Some(e) = shadow.take() {
|
||||||
commands.entity(e).despawn_recursive();
|
commands.entity(e).despawn();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -627,7 +645,8 @@ fn update_drag_shadow(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
|
/// 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(
|
fn tick_hint_highlight(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
@@ -649,7 +668,10 @@ fn tick_hint_highlight(
|
|||||||
} else {
|
} else {
|
||||||
card_back_colour(back_idx)
|
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.
|
/// 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);
|
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
|
/// Removes the `RightClickHighlight` marker from every highlighted pile and
|
||||||
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
|
/// 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.
|
/// This ensures stale highlights do not linger after a card is moved.
|
||||||
fn clear_right_click_highlights_on_state_change(
|
fn clear_right_click_highlights_on_state_change(
|
||||||
mut events: EventReader<StateChangedEvent>,
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
highlighted: Query<Entity, With<RightClickHighlight>>,
|
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
@@ -781,7 +834,10 @@ fn handle_right_click(
|
|||||||
};
|
};
|
||||||
if legal {
|
if legal {
|
||||||
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
|
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>>,
|
windows: &Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||||
cameras: &Query<(&Camera, &GlobalTransform)>,
|
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||||
) -> Option<Vec2> {
|
) -> Option<Vec2> {
|
||||||
let window = windows.get_single().ok()?;
|
let window = windows.single().ok()?;
|
||||||
let cursor = window.cursor_position()?;
|
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()
|
camera.viewport_to_world_2d(camera_transform, cursor).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,7 +911,7 @@ fn apply_stock_empty_indicator(
|
|||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
game: &GameState,
|
game: &GameState,
|
||||||
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
|
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
label_children: &Query<(Entity, &Parent), With<StockEmptyLabel>>,
|
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
) {
|
) {
|
||||||
let stock_empty = game
|
let stock_empty = game
|
||||||
@@ -875,7 +931,7 @@ fn apply_stock_empty_indicator(
|
|||||||
// Spawn the "↺" label only if one does not already exist.
|
// Spawn the "↺" label only if one does not already exist.
|
||||||
let already_has_label = label_children
|
let already_has_label = label_children
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(_, parent)| parent.get() == entity);
|
.any(|(_, parent)| parent.parent() == entity);
|
||||||
if !already_has_label {
|
if !already_has_label {
|
||||||
let font_size = layout.card_size.x * 0.4;
|
let font_size = layout.card_size.x * 0.4;
|
||||||
commands.entity(entity).with_children(|b| {
|
commands.entity(entity).with_children(|b| {
|
||||||
@@ -894,8 +950,8 @@ fn apply_stock_empty_indicator(
|
|||||||
|
|
||||||
// Despawn any existing "↺" label children.
|
// Despawn any existing "↺" label children.
|
||||||
for (label_entity, parent) in label_children.iter() {
|
for (label_entity, parent) in label_children.iter() {
|
||||||
if parent.get() == entity {
|
if parent.parent() == entity {
|
||||||
commands.entity(label_entity).despawn_recursive();
|
commands.entity(label_entity).despawn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -909,7 +965,7 @@ fn update_stock_empty_indicator_startup(
|
|||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
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 };
|
let Some(layout) = layout else { return };
|
||||||
apply_stock_empty_indicator(
|
apply_stock_empty_indicator(
|
||||||
@@ -924,12 +980,12 @@ fn update_stock_empty_indicator_startup(
|
|||||||
/// Runs each `Update` tick when a `StateChangedEvent` arrives, keeping the
|
/// Runs each `Update` tick when a `StateChangedEvent` arrives, keeping the
|
||||||
/// stock pile marker dim state and "↺" label in sync with the current stock.
|
/// stock pile marker dim state and "↺" label in sync with the current stock.
|
||||||
fn update_stock_empty_indicator(
|
fn update_stock_empty_indicator(
|
||||||
mut events: EventReader<StateChangedEvent>,
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
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() {
|
if events.read().next().is_none() {
|
||||||
return;
|
return;
|
||||||
@@ -1050,7 +1106,7 @@ mod tests {
|
|||||||
let mut app = app();
|
let mut app = app();
|
||||||
// Trigger a draw, which moves a card from stock to waste and should
|
// Trigger a draw, which moves a card from stock to waste and should
|
||||||
// flip it face-up. Count visible labels after.
|
// 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();
|
app.update();
|
||||||
// Now 1 card in waste (face-up), 23 in stock (face-down). So 24
|
// Now 1 card in waste (face-up), 23 in stock (face-down). So 24
|
||||||
// hidden labels total in stock, plus 21 in tableau = 44.
|
// hidden labels total in stock, plus 21 in tableau = 44.
|
||||||
@@ -1223,6 +1279,172 @@ mod tests {
|
|||||||
assert_ne!(FlipPhase::ScalingDown, FlipPhase::ScalingUp);
|
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]
|
#[test]
|
||||||
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
||||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||||
|
|||||||
@@ -18,20 +18,22 @@ pub const CHALLENGE_UNLOCK_LEVEL: u32 = 5;
|
|||||||
|
|
||||||
/// Fired when the player has just completed a Challenge-mode game and the
|
/// Fired when the player has just completed a Challenge-mode game and the
|
||||||
/// `challenge_index` cursor advances.
|
/// `challenge_index` cursor advances.
|
||||||
#[derive(Event, Debug, Clone, Copy)]
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
pub struct ChallengeAdvancedEvent {
|
pub struct ChallengeAdvancedEvent {
|
||||||
pub previous_index: u32,
|
pub previous_index: u32,
|
||||||
pub new_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;
|
pub struct ChallengePlugin;
|
||||||
|
|
||||||
impl Plugin for ChallengePlugin {
|
impl Plugin for ChallengePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_event::<ChallengeAdvancedEvent>()
|
app.add_message::<ChallengeAdvancedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_event::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
|
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
|
||||||
.add_systems(Update, advance_on_challenge_win.after(ProgressUpdate))
|
.add_systems(Update, advance_on_challenge_win.after(ProgressUpdate))
|
||||||
.add_systems(Update, handle_start_challenge_request.before(GameMutation));
|
.add_systems(Update, handle_start_challenge_request.before(GameMutation));
|
||||||
@@ -39,12 +41,12 @@ impl Plugin for ChallengePlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn advance_on_challenge_win(
|
fn advance_on_challenge_win(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: MessageReader<GameWonEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
path: Res<ProgressStoragePath>,
|
path: Res<ProgressStoragePath>,
|
||||||
mut advanced: EventWriter<ChallengeAdvancedEvent>,
|
mut advanced: MessageWriter<ChallengeAdvancedEvent>,
|
||||||
mut toast: EventWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
for _ in wins.read() {
|
for _ in wins.read() {
|
||||||
if game.0.mode != GameMode::Challenge {
|
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").
|
// Human-readable level is 1-based (index 0 → "Challenge 1").
|
||||||
let level_number = prev.saturating_add(1);
|
let level_number = prev.saturating_add(1);
|
||||||
toast.send(InfoToastEvent(format!("Challenge {level_number} complete!")));
|
toast.write(InfoToastEvent(format!("Challenge {level_number} complete!")));
|
||||||
advanced.send(ChallengeAdvancedEvent {
|
advanced.write(ChallengeAdvancedEvent {
|
||||||
previous_index: prev,
|
previous_index: prev,
|
||||||
new_index: progress.0.challenge_index,
|
new_index: progress.0.challenge_index,
|
||||||
});
|
});
|
||||||
@@ -70,14 +72,14 @@ fn advance_on_challenge_win(
|
|||||||
fn handle_start_challenge_request(
|
fn handle_start_challenge_request(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
progress: Res<ProgressResource>,
|
progress: Res<ProgressResource>,
|
||||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
mut info_toast: EventWriter<InfoToastEvent>,
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
if !keys.just_pressed(KeyCode::KeyX) {
|
if !keys.just_pressed(KeyCode::KeyX) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if progress.0.level < CHALLENGE_UNLOCK_LEVEL {
|
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}"
|
"Challenge mode unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
|
||||||
)));
|
)));
|
||||||
return;
|
return;
|
||||||
@@ -86,7 +88,7 @@ fn handle_start_challenge_request(
|
|||||||
warn!("challenge seed list is empty");
|
warn!("challenge seed list is empty");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
new_game.send(NewGameRequestEvent {
|
new_game.write(NewGameRequestEvent {
|
||||||
seed: Some(seed),
|
seed: Some(seed),
|
||||||
mode: Some(GameMode::Challenge),
|
mode: Some(GameMode::Challenge),
|
||||||
});
|
});
|
||||||
@@ -124,7 +126,7 @@ mod tests {
|
|||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 100,
|
time_seconds: 100,
|
||||||
});
|
});
|
||||||
@@ -133,7 +135,7 @@ mod tests {
|
|||||||
let p = &app.world().resource::<ProgressResource>().0;
|
let p = &app.world().resource::<ProgressResource>().0;
|
||||||
assert_eq!(p.challenge_index, 1);
|
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 mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
@@ -145,7 +147,7 @@ mod tests {
|
|||||||
fn classic_win_does_not_advance_challenge_index() {
|
fn classic_win_does_not_advance_challenge_index() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Default GameStateResource is Classic mode.
|
// Default GameStateResource is Classic mode.
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 100,
|
time_seconds: 100,
|
||||||
});
|
});
|
||||||
@@ -154,7 +156,7 @@ mod tests {
|
|||||||
let p = &app.world().resource::<ProgressResource>().0;
|
let p = &app.world().resource::<ProgressResource>().0;
|
||||||
assert_eq!(p.challenge_index, 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();
|
let mut cursor = events.get_cursor();
|
||||||
assert!(cursor.read(events).next().is_none());
|
assert!(cursor.read(events).next().is_none());
|
||||||
}
|
}
|
||||||
@@ -168,7 +170,7 @@ mod tests {
|
|||||||
.press(KeyCode::KeyX);
|
.press(KeyCode::KeyX);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<NewGameRequestEvent>>();
|
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
assert!(cursor.read(events).next().is_none());
|
assert!(cursor.read(events).next().is_none());
|
||||||
}
|
}
|
||||||
@@ -188,7 +190,7 @@ mod tests {
|
|||||||
.press(KeyCode::KeyX);
|
.press(KeyCode::KeyX);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<NewGameRequestEvent>>();
|
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
@@ -211,13 +213,13 @@ mod tests {
|
|||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 100,
|
time_seconds: 100,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).collect();
|
let fired: Vec<_> = cursor.read(events).collect();
|
||||||
assert_eq!(fired.len(), 1, "exactly one toast must fire on challenge win");
|
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() {
|
fn classic_win_does_not_fire_challenge_complete_toast() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Default mode is Classic.
|
// Default mode is Classic.
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 100,
|
time_seconds: 100,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
assert!(
|
assert!(
|
||||||
cursor.read(events).next().is_none(),
|
cursor.read(events).next().is_none(),
|
||||||
@@ -254,7 +256,7 @@ mod tests {
|
|||||||
.press(KeyCode::KeyX);
|
.press(KeyCode::KeyX);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).collect();
|
let fired: Vec<_> = cursor.read(events).collect();
|
||||||
assert_eq!(fired.len(), 1, "must show a toast explaining the lock");
|
assert_eq!(fired.len(), 1, "must show a toast explaining the lock");
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
//! The tint is cleared to default the frame the drag ends.
|
//! The tint is cleared to default the frame the drag ends.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::{PrimaryWindow, SystemCursorIcon};
|
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||||
use bevy::winit::cursor::CursorIcon;
|
|
||||||
use solitaire_core::card::Suit;
|
use solitaire_core::card::Suit;
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
use solitaire_core::pile::PileType;
|
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.
|
/// 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);
|
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;
|
pub struct CursorPlugin;
|
||||||
|
|
||||||
impl Plugin for CursorPlugin {
|
impl Plugin for CursorPlugin {
|
||||||
@@ -52,7 +52,7 @@ fn update_cursor_icon(
|
|||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
mut commands: Commands,
|
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() {
|
if !drag.is_idle() {
|
||||||
commands
|
commands
|
||||||
@@ -63,7 +63,7 @@ fn update_cursor_icon(
|
|||||||
|
|
||||||
let hovering = (|| {
|
let hovering = (|| {
|
||||||
let cursor = window.cursor_position()?;
|
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 world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
|
||||||
let layout = layout.as_ref()?.0.clone();
|
let layout = layout.as_ref()?.0.clone();
|
||||||
let game = game.as_ref()?;
|
let game = game.as_ref()?;
|
||||||
@@ -214,7 +214,6 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use solitaire_core::card::{Card, Rank};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn point_in_rect_center_is_inside() {
|
fn point_in_rect_center_is_inside() {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub struct DailyChallengeResource {
|
|||||||
|
|
||||||
/// Fired when the player presses C to start the daily challenge.
|
/// Fired when the player presses C to start the daily challenge.
|
||||||
/// Carries the current goal description so it can be displayed as a toast.
|
/// 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);
|
pub struct DailyGoalAnnouncementEvent(pub String);
|
||||||
|
|
||||||
impl DailyChallengeResource {
|
impl DailyChallengeResource {
|
||||||
@@ -60,7 +60,7 @@ impl DailyChallengeResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fired when the player has just completed today's daily challenge.
|
/// 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 struct DailyChallengeCompletedEvent {
|
||||||
pub date: NaiveDate,
|
pub date: NaiveDate,
|
||||||
pub streak: u32,
|
pub streak: u32,
|
||||||
@@ -71,17 +71,19 @@ pub struct DailyChallengeCompletedEvent {
|
|||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
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;
|
pub struct DailyChallengePlugin;
|
||||||
|
|
||||||
impl Plugin for DailyChallengePlugin {
|
impl Plugin for DailyChallengePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.insert_resource(DailyChallengeResource::for_today())
|
app.insert_resource(DailyChallengeResource::for_today())
|
||||||
.init_resource::<DailyChallengeTask>()
|
.init_resource::<DailyChallengeTask>()
|
||||||
.add_event::<DailyChallengeCompletedEvent>()
|
.add_message::<DailyChallengeCompletedEvent>()
|
||||||
.add_event::<DailyGoalAnnouncementEvent>()
|
.add_message::<DailyGoalAnnouncementEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_event::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.add_systems(Startup, fetch_server_challenge)
|
.add_systems(Startup, fetch_server_challenge)
|
||||||
.add_systems(Update, poll_server_challenge)
|
.add_systems(Update, poll_server_challenge)
|
||||||
// record/award after the base ProgressUpdate so we don't fight
|
// record/award after the base ProgressUpdate so we don't fight
|
||||||
@@ -145,14 +147,14 @@ fn poll_server_challenge(
|
|||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_daily_completion(
|
fn handle_daily_completion(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: MessageReader<GameWonEvent>,
|
||||||
daily: Res<DailyChallengeResource>,
|
daily: Res<DailyChallengeResource>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
path: Res<ProgressStoragePath>,
|
path: Res<ProgressStoragePath>,
|
||||||
mut completed: EventWriter<DailyChallengeCompletedEvent>,
|
mut completed: MessageWriter<DailyChallengeCompletedEvent>,
|
||||||
mut xp_awarded: EventWriter<XpAwardedEvent>,
|
mut xp_awarded: MessageWriter<XpAwardedEvent>,
|
||||||
mut toast: EventWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
for ev in wins.read() {
|
for ev in wins.read() {
|
||||||
if game.0.seed != daily.seed {
|
if game.0.seed != daily.seed {
|
||||||
@@ -174,28 +176,28 @@ fn handle_daily_completion(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
progress.0.add_xp(DAILY_BONUS_XP);
|
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 Some(target) = &path.0 {
|
||||||
if let Err(e) = save_progress_to(target, &progress.0) {
|
if let Err(e) = save_progress_to(target, &progress.0) {
|
||||||
warn!("failed to save progress after daily completion: {e}");
|
warn!("failed to save progress after daily completion: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
completed.send(DailyChallengeCompletedEvent {
|
completed.write(DailyChallengeCompletedEvent {
|
||||||
date: daily.date,
|
date: daily.date,
|
||||||
streak: progress.0.daily_challenge_streak,
|
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(
|
fn handle_start_daily_request(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
daily: Res<DailyChallengeResource>,
|
daily: Res<DailyChallengeResource>,
|
||||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
mut announce: EventWriter<DailyGoalAnnouncementEvent>,
|
mut announce: MessageWriter<DailyGoalAnnouncementEvent>,
|
||||||
) {
|
) {
|
||||||
if keys.just_pressed(KeyCode::KeyC) {
|
if keys.just_pressed(KeyCode::KeyC) {
|
||||||
new_game.send(NewGameRequestEvent {
|
new_game.write(NewGameRequestEvent {
|
||||||
seed: Some(daily.seed),
|
seed: Some(daily.seed),
|
||||||
mode: None,
|
mode: None,
|
||||||
});
|
});
|
||||||
@@ -203,7 +205,7 @@ fn handle_start_daily_request(
|
|||||||
.goal_description
|
.goal_description
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "Daily Challenge".to_string());
|
.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 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(daily_seed, DrawMode::DrawOne);
|
GameState::new(daily_seed, DrawMode::DrawOne);
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 200,
|
time_seconds: 200,
|
||||||
});
|
});
|
||||||
@@ -255,7 +257,7 @@ mod tests {
|
|||||||
// +100 from the daily bonus
|
// +100 from the daily bonus
|
||||||
assert!(progress.total_xp >= DAILY_BONUS_XP);
|
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 mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
@@ -270,7 +272,7 @@ mod tests {
|
|||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
|
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 200,
|
time_seconds: 200,
|
||||||
});
|
});
|
||||||
@@ -279,7 +281,7 @@ mod tests {
|
|||||||
let progress = &app.world().resource::<ProgressResource>().0;
|
let progress = &app.world().resource::<ProgressResource>().0;
|
||||||
assert_eq!(progress.daily_challenge_streak, 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();
|
let mut cursor = events.get_cursor();
|
||||||
assert!(cursor.read(events).next().is_none());
|
assert!(cursor.read(events).next().is_none());
|
||||||
}
|
}
|
||||||
@@ -291,13 +293,13 @@ mod tests {
|
|||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(daily_seed, DrawMode::DrawOne);
|
GameState::new(daily_seed, DrawMode::DrawOne);
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 200,
|
time_seconds: 200,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
// Re-send win.
|
// Re-send win.
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 200,
|
time_seconds: 200,
|
||||||
});
|
});
|
||||||
@@ -317,7 +319,7 @@ mod tests {
|
|||||||
.press(KeyCode::KeyC);
|
.press(KeyCode::KeyC);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<NewGameRequestEvent>>();
|
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
@@ -337,7 +339,7 @@ mod tests {
|
|||||||
.press(KeyCode::KeyC);
|
.press(KeyCode::KeyC);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<DailyGoalAnnouncementEvent>>();
|
let events = app.world().resource::<Messages<DailyGoalAnnouncementEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).cloned().collect();
|
let fired: Vec<_> = cursor.read(events).cloned().collect();
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
@@ -355,7 +357,7 @@ mod tests {
|
|||||||
.press(KeyCode::KeyC);
|
.press(KeyCode::KeyC);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<DailyGoalAnnouncementEvent>>();
|
let events = app.world().resource::<Messages<DailyGoalAnnouncementEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).cloned().collect();
|
let fired: Vec<_> = cursor.read(events).cloned().collect();
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
//! Cross-system events used by the engine's plugins.
|
//! 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::game_state::GameMode;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
|
|
||||||
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
||||||
/// consumed by `GamePlugin`.
|
/// consumed by `GamePlugin`.
|
||||||
#[derive(Event, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct MoveRequestEvent {
|
pub struct MoveRequestEvent {
|
||||||
pub from: PileType,
|
pub from: PileType,
|
||||||
pub to: PileType,
|
pub to: PileType,
|
||||||
@@ -15,16 +15,16 @@ pub struct MoveRequestEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Request to draw from the stock (or recycle waste when stock is empty).
|
/// 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;
|
pub struct DrawRequestEvent;
|
||||||
|
|
||||||
/// Request to undo the most recent state change.
|
/// Request to undo the most recent state change.
|
||||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct UndoRequestEvent;
|
pub struct UndoRequestEvent;
|
||||||
|
|
||||||
/// Request to start a new game. `seed = None` uses a system-time seed.
|
/// Request to start a new game. `seed = None` uses a system-time seed.
|
||||||
/// `mode = None` reuses the current game's `GameMode`.
|
/// `mode = None` reuses the current game's `GameMode`.
|
||||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct NewGameRequestEvent {
|
pub struct NewGameRequestEvent {
|
||||||
pub seed: Option<u64>,
|
pub seed: Option<u64>,
|
||||||
pub mode: Option<GameMode>,
|
pub mode: Option<GameMode>,
|
||||||
@@ -32,13 +32,13 @@ pub struct NewGameRequestEvent {
|
|||||||
|
|
||||||
/// Fired by `GamePlugin` after any successful state mutation. Rendering and
|
/// Fired by `GamePlugin` after any successful state mutation. Rendering and
|
||||||
/// score-display systems listen for this to refresh.
|
/// score-display systems listen for this to refresh.
|
||||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct StateChangedEvent;
|
pub struct StateChangedEvent;
|
||||||
|
|
||||||
/// Fired by input/UI systems when a player attempts to drop dragged cards
|
/// 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
|
/// on a real pile but the move violates the rules. Drives the
|
||||||
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
|
/// `card_invalid.wav` SFX. Not fired for drops in empty space.
|
||||||
#[derive(Event, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct MoveRejectedEvent {
|
pub struct MoveRejectedEvent {
|
||||||
pub from: PileType,
|
pub from: PileType,
|
||||||
pub to: PileType,
|
pub to: PileType,
|
||||||
@@ -46,14 +46,14 @@ pub struct MoveRejectedEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fired once when the active game transitions to won.
|
/// Fired once when the active game transitions to won.
|
||||||
#[derive(Event, Debug, Clone, Copy)]
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
pub struct GameWonEvent {
|
pub struct GameWonEvent {
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
pub time_seconds: u64,
|
pub time_seconds: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fired when a card's face-up state changes during gameplay.
|
/// 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);
|
pub struct CardFlippedEvent(pub u32);
|
||||||
|
|
||||||
/// Fired by the flip animation at its midpoint — the instant the card face
|
/// 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`
|
/// Audio systems should listen to this event rather than `CardFlippedEvent`
|
||||||
/// so the flip sound is synchronised with the visual reveal, not the move
|
/// so the flip sound is synchronised with the visual reveal, not the move
|
||||||
/// that triggered the animation.
|
/// that triggered the animation.
|
||||||
#[derive(Event, Debug, Clone, Copy)]
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
pub struct CardFaceRevealedEvent(pub u32);
|
pub struct CardFaceRevealedEvent(pub u32);
|
||||||
|
|
||||||
/// Achievement unlocked notification carrying the full `AchievementRecord` for
|
/// Achievement unlocked notification carrying the full `AchievementRecord` for
|
||||||
/// the newly unlocked achievement. Consumed by the toast renderer and any
|
/// the newly unlocked achievement. Consumed by the toast renderer and any
|
||||||
/// persistence/UI systems that need unlock metadata.
|
/// persistence/UI systems that need unlock metadata.
|
||||||
#[derive(Event, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct AchievementUnlockedEvent(pub AchievementRecord);
|
pub struct AchievementUnlockedEvent(pub AchievementRecord);
|
||||||
|
|
||||||
/// Request to manually trigger a sync pull from the active backend.
|
/// Request to manually trigger a sync pull from the active backend.
|
||||||
///
|
///
|
||||||
/// Fired by the Settings panel "Sync Now" button. `SyncPlugin` responds by
|
/// Fired by the Settings panel "Sync Now" button. `SyncPlugin` responds by
|
||||||
/// starting a new pull task if one is not already in flight.
|
/// 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;
|
pub struct ManualSyncRequestEvent;
|
||||||
|
|
||||||
/// Fired by `InputPlugin` when N is pressed while a game is in progress
|
/// Fired by `InputPlugin` when N is pressed while a game is in progress
|
||||||
/// but confirmation has not yet been received. The animation plugin shows
|
/// but confirmation has not yet been received. The animation plugin shows
|
||||||
/// a "Press N again to confirm" toast. A second N press within the
|
/// a "Press N again to confirm" toast. A second N press within the
|
||||||
/// confirmation window sends `NewGameRequestEvent`.
|
/// confirmation window sends `NewGameRequestEvent`.
|
||||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct NewGameConfirmEvent;
|
pub struct NewGameConfirmEvent;
|
||||||
|
|
||||||
/// Generic informational toast message. Any system can fire this to display
|
/// Generic informational toast message. Any system can fire this to display
|
||||||
/// a short string to the player, e.g. "Locked — reach level 5".
|
/// 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);
|
pub struct InfoToastEvent(pub String);
|
||||||
|
|
||||||
/// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
|
/// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
|
||||||
/// animation layer can display a "+N XP" toast alongside the win cascade.
|
/// 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 struct XpAwardedEvent {
|
||||||
pub amount: u64,
|
pub amount: u64,
|
||||||
}
|
}
|
||||||
@@ -100,5 +100,18 @@ pub struct XpAwardedEvent {
|
|||||||
/// Fired by `InputPlugin` when the player presses G to forfeit the current
|
/// Fired by `InputPlugin` when the player presses G to forfeit the current
|
||||||
/// game. Consumed by `StatsPlugin` which records the abandoned game,
|
/// game. Consumed by `StatsPlugin` which records the abandoned game,
|
||||||
/// persists stats, and starts a fresh deal.
|
/// persists stats, and starts a fresh deal.
|
||||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct ForfeitEvent;
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
/// Inserts `ShakeAnim` on all card entities belonging to the destination pile
|
/// Inserts `ShakeAnim` on all card entities belonging to the destination pile
|
||||||
/// when a `MoveRejectedEvent` fires.
|
/// when a `MoveRejectedEvent` fires.
|
||||||
fn start_shake_anim(
|
fn start_shake_anim(
|
||||||
mut events: EventReader<MoveRejectedEvent>,
|
mut events: MessageReader<MoveRejectedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
@@ -243,7 +243,7 @@ fn tick_shake_anim(
|
|||||||
/// Inserts `SettleAnim` on the top card of every non-empty pile when
|
/// Inserts `SettleAnim` on the top card of every non-empty pile when
|
||||||
/// `StateChangedEvent` fires.
|
/// `StateChangedEvent` fires.
|
||||||
fn start_settle_anim(
|
fn start_settle_anim(
|
||||||
mut events: EventReader<StateChangedEvent>,
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
card_entities: Query<(Entity, &CardEntity)>,
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
@@ -304,7 +304,7 @@ fn tick_settle_anim(
|
|||||||
/// and fires the deal animation for every card entity currently in the world.
|
/// 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`.
|
/// The stagger is looked up from `SettingsResource` via `deal_stagger_secs_for_speed`.
|
||||||
fn start_deal_anim(
|
fn start_deal_anim(
|
||||||
mut events: EventReader<NewGameRequestEvent>,
|
mut events: MessageReader<NewGameRequestEvent>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
|||||||
@@ -71,16 +71,16 @@ impl Plugin for GamePlugin {
|
|||||||
.insert_resource(GameStatePath(path))
|
.insert_resource(GameStatePath(path))
|
||||||
.init_resource::<DragState>()
|
.init_resource::<DragState>()
|
||||||
.init_resource::<SyncStatusResource>()
|
.init_resource::<SyncStatusResource>()
|
||||||
.add_event::<MoveRequestEvent>()
|
.add_message::<MoveRequestEvent>()
|
||||||
.add_event::<DrawRequestEvent>()
|
.add_message::<DrawRequestEvent>()
|
||||||
.add_event::<UndoRequestEvent>()
|
.add_message::<UndoRequestEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_event::<StateChangedEvent>()
|
.add_message::<StateChangedEvent>()
|
||||||
.add_event::<crate::events::MoveRejectedEvent>()
|
.add_message::<crate::events::MoveRejectedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_event::<crate::events::CardFlippedEvent>()
|
.add_message::<crate::events::CardFlippedEvent>()
|
||||||
.add_event::<crate::events::AchievementUnlockedEvent>()
|
.add_message::<crate::events::AchievementUnlockedEvent>()
|
||||||
.add_event::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -152,9 +152,9 @@ fn seed_from_system_time() -> u64 {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_new_game(
|
fn handle_new_game(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut new_game: EventReader<NewGameRequestEvent>,
|
mut new_game: MessageReader<NewGameRequestEvent>,
|
||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut changed: EventWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||||
path: Option<Res<GameStatePath>>,
|
path: Option<Res<GameStatePath>>,
|
||||||
confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>,
|
confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>,
|
||||||
@@ -169,7 +169,7 @@ fn handle_new_game(
|
|||||||
if needs_confirm && !confirm_already_open {
|
if needs_confirm && !confirm_already_open {
|
||||||
// Despawn any stale game-over overlay before showing confirm dialog.
|
// Despawn any stale game-over overlay before showing confirm dialog.
|
||||||
for entity in &game_over_screens {
|
for entity in &game_over_screens {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
spawn_confirm_dialog(&mut commands, *ev);
|
spawn_confirm_dialog(&mut commands, *ev);
|
||||||
continue;
|
continue;
|
||||||
@@ -177,10 +177,10 @@ fn handle_new_game(
|
|||||||
|
|
||||||
// Despawn confirm and game-over overlays before starting the new game.
|
// Despawn confirm and game-over overlays before starting the new game.
|
||||||
for entity in &confirm_screens {
|
for entity in &confirm_screens {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
for entity in &game_over_screens {
|
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);
|
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}");
|
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),
|
row_gap: Val::Px(20.0),
|
||||||
min_width: Val::Px(360.0),
|
min_width: Val::Px(360.0),
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(12.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.10, 0.12, 0.15)),
|
BackgroundColor(Color::srgb(0.10, 0.12, 0.15)),
|
||||||
BorderRadius::all(Val::Px(12.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|card| {
|
.with_children(|card| {
|
||||||
// Heading
|
// Heading
|
||||||
@@ -287,9 +287,9 @@ fn handle_confirm_input(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
screens: Query<(Entity, &OriginalNewGameRequest), With<ConfirmNewGameScreen>>,
|
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;
|
return;
|
||||||
};
|
};
|
||||||
let Some(keys) = keys else {
|
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);
|
let cancelled = keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape);
|
||||||
|
|
||||||
if confirmed {
|
if confirmed {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
// Re-send with move_count already 0 would bypass the dialog next time.
|
// 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
|
// We fire the event — handle_new_game will skip the dialog because
|
||||||
// the screen is despawned before the next read.
|
// the screen is despawned before the next read.
|
||||||
new_game.send(NewGameRequestEvent {
|
new_game.write(NewGameRequestEvent {
|
||||||
seed: original.0.seed,
|
seed: original.0.seed,
|
||||||
mode: original.0.mode,
|
mode: original.0.mode,
|
||||||
});
|
});
|
||||||
} else if cancelled {
|
} else if cancelled {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_draw(
|
fn handle_draw(
|
||||||
mut draws: EventReader<DrawRequestEvent>,
|
mut draws: MessageReader<DrawRequestEvent>,
|
||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut changed: EventWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
mut flipped: EventWriter<CardFlippedEvent>,
|
mut flipped: MessageWriter<CardFlippedEvent>,
|
||||||
) {
|
) {
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
@@ -347,9 +347,9 @@ fn handle_draw(
|
|||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Fire a flip event for each card that moved from stock to waste.
|
// Fire a flip event for each card that moved from stock to waste.
|
||||||
for id in drawn_ids {
|
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}"),
|
Err(e) => warn!("draw rejected: {e}"),
|
||||||
}
|
}
|
||||||
@@ -357,11 +357,11 @@ fn handle_draw(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_move(
|
fn handle_move(
|
||||||
mut moves: EventReader<MoveRequestEvent>,
|
mut moves: MessageReader<MoveRequestEvent>,
|
||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut changed: EventWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
mut won: EventWriter<GameWonEvent>,
|
mut won: MessageWriter<GameWonEvent>,
|
||||||
mut flipped: EventWriter<crate::events::CardFlippedEvent>,
|
mut flipped: MessageWriter<crate::events::CardFlippedEvent>,
|
||||||
path: Option<Res<GameStatePath>>,
|
path: Option<Res<GameStatePath>>,
|
||||||
) {
|
) {
|
||||||
for ev in moves.read() {
|
for ev in moves.read() {
|
||||||
@@ -385,12 +385,12 @@ fn handle_move(
|
|||||||
.and_then(|p| p.cards.last())
|
.and_then(|p| p.cards.last())
|
||||||
.is_some_and(|c| c.id == fid && c.face_up)
|
.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 {
|
if !was_won && game.0.is_won {
|
||||||
won.send(GameWonEvent {
|
won.write(GameWonEvent {
|
||||||
score: game.0.score,
|
score: game.0.score,
|
||||||
time_seconds: game.0.elapsed_seconds,
|
time_seconds: game.0.elapsed_seconds,
|
||||||
});
|
});
|
||||||
@@ -408,20 +408,20 @@ fn handle_move(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_undo(
|
fn handle_undo(
|
||||||
mut undos: EventReader<UndoRequestEvent>,
|
mut undos: MessageReader<UndoRequestEvent>,
|
||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut changed: EventWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
mut toast: EventWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
use solitaire_core::error::MoveError;
|
use solitaire_core::error::MoveError;
|
||||||
|
|
||||||
for _ in undos.read() {
|
for _ in undos.read() {
|
||||||
match game.0.undo() {
|
match game.0.undo() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
changed.send(StateChangedEvent);
|
changed.write(StateChangedEvent);
|
||||||
}
|
}
|
||||||
Err(MoveError::UndoStackEmpty) => {
|
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}"),
|
Err(e) => warn!("undo rejected: {e}"),
|
||||||
}
|
}
|
||||||
@@ -500,9 +500,9 @@ pub fn has_legal_moves(game: &GameState) -> bool {
|
|||||||
/// game is won.
|
/// game is won.
|
||||||
fn check_no_moves(
|
fn check_no_moves(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: EventReader<StateChangedEvent>,
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut toast: EventWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
mut already_fired: Local<bool>,
|
mut already_fired: Local<bool>,
|
||||||
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
||||||
) {
|
) {
|
||||||
@@ -523,7 +523,7 @@ fn check_no_moves(
|
|||||||
let moves_ok = has_legal_moves(&game.0);
|
let moves_ok = has_legal_moves(&game.0);
|
||||||
if moves_ok || game.0.is_won {
|
if moves_ok || game.0.is_won {
|
||||||
for entity in &game_over_screens {
|
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 {
|
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(),
|
"No moves available \u{2014} press D to draw or N for a new game".to_string(),
|
||||||
));
|
));
|
||||||
*already_fired = true;
|
*already_fired = true;
|
||||||
@@ -574,10 +574,10 @@ fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
|
|||||||
row_gap: Val::Px(16.0),
|
row_gap: Val::Px(16.0),
|
||||||
min_width: Val::Px(340.0),
|
min_width: Val::Px(340.0),
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(12.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.10, 0.08, 0.08)),
|
BackgroundColor(Color::srgb(0.10, 0.08, 0.08)),
|
||||||
BorderRadius::all(Val::Px(12.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|card| {
|
.with_children(|card| {
|
||||||
// Header — explains why the overlay appeared.
|
// Header — explains why the overlay appeared.
|
||||||
@@ -628,8 +628,8 @@ fn handle_game_over_input(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
screens: Query<Entity, With<GameOverScreen>>,
|
screens: Query<Entity, With<GameOverScreen>>,
|
||||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
mut undo: EventWriter<UndoRequestEvent>,
|
mut undo: MessageWriter<UndoRequestEvent>,
|
||||||
) {
|
) {
|
||||||
if screens.is_empty() {
|
if screens.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -639,12 +639,12 @@ fn handle_game_over_input(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) {
|
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) {
|
} else if keys.just_pressed(KeyCode::KeyU) {
|
||||||
for entity in &screens {
|
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
|
/// `save_game_state_to` helper skips them). Blocking on exit is acceptable
|
||||||
/// because the game loop is already shutting down.
|
/// because the game loop is already shutting down.
|
||||||
fn save_game_state_on_exit(
|
fn save_game_state_on_exit(
|
||||||
mut exit_events: EventReader<AppExit>,
|
mut exit_events: MessageReader<AppExit>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
path: Res<GameStatePath>,
|
path: Res<GameStatePath>,
|
||||||
) {
|
) {
|
||||||
@@ -739,7 +739,7 @@ mod tests {
|
|||||||
.cards
|
.cards
|
||||||
.len();
|
.len();
|
||||||
|
|
||||||
app.world_mut().send_event(DrawRequestEvent);
|
app.world_mut().write_message(DrawRequestEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let stock_after = app
|
let stock_after = app
|
||||||
@@ -763,9 +763,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn draw_request_fires_state_changed_event() {
|
fn draw_request_fires_state_changed_event() {
|
||||||
let mut app = test_app(42);
|
let mut app = test_app(42);
|
||||||
app.world_mut().send_event(DrawRequestEvent);
|
app.world_mut().write_message(DrawRequestEvent);
|
||||||
app.update();
|
app.update();
|
||||||
let events = app.world().resource::<Events<StateChangedEvent>>();
|
let events = app.world().resource::<Messages<StateChangedEvent>>();
|
||||||
let mut reader = events.get_cursor();
|
let mut reader = events.get_cursor();
|
||||||
assert!(reader.read(events).next().is_some());
|
assert!(reader.read(events).next().is_some());
|
||||||
}
|
}
|
||||||
@@ -773,9 +773,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn undo_after_draw_restores_state() {
|
fn undo_after_draw_restores_state() {
|
||||||
let mut app = test_app(42);
|
let mut app = test_app(42);
|
||||||
app.world_mut().send_event(DrawRequestEvent);
|
app.world_mut().write_message(DrawRequestEvent);
|
||||||
app.update();
|
app.update();
|
||||||
app.world_mut().send_event(UndoRequestEvent);
|
app.world_mut().write_message(UndoRequestEvent);
|
||||||
app.update();
|
app.update();
|
||||||
let g = &app.world().resource::<GameStateResource>().0;
|
let g = &app.world().resource::<GameStateResource>().0;
|
||||||
assert_eq!(g.piles[&PileType::Stock].cards.len(), 24);
|
assert_eq!(g.piles[&PileType::Stock].cards.len(), 24);
|
||||||
@@ -795,7 +795,7 @@ mod tests {
|
|||||||
.map(|c| c.id)
|
.map(|c| c.id)
|
||||||
.collect();
|
.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();
|
app.update();
|
||||||
|
|
||||||
let after: Vec<u32> = app
|
let after: Vec<u32> = app
|
||||||
@@ -858,13 +858,13 @@ mod tests {
|
|||||||
fn invalid_move_does_not_fire_state_changed() {
|
fn invalid_move_does_not_fire_state_changed() {
|
||||||
let mut app = test_app(42);
|
let mut app = test_app(42);
|
||||||
// Stock -> Waste is InvalidDestination; no state change expected.
|
// Stock -> Waste is InvalidDestination; no state change expected.
|
||||||
app.world_mut().send_event(MoveRequestEvent {
|
app.world_mut().write_message(MoveRequestEvent {
|
||||||
from: PileType::Stock,
|
from: PileType::Stock,
|
||||||
to: PileType::Waste,
|
to: PileType::Waste,
|
||||||
count: 1,
|
count: 1,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
let events = app.world().resource::<Events<StateChangedEvent>>();
|
let events = app.world().resource::<Messages<StateChangedEvent>>();
|
||||||
let mut reader = events.get_cursor();
|
let mut reader = events.get_cursor();
|
||||||
assert!(reader.read(events).next().is_none());
|
assert!(reader.read(events).next().is_none());
|
||||||
}
|
}
|
||||||
@@ -892,7 +892,7 @@ mod tests {
|
|||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new(7654, DrawMode::DrawOne);
|
GameState::new(7654, DrawMode::DrawOne);
|
||||||
|
|
||||||
app.world_mut().send_event(AppExit::Success);
|
app.world_mut().write_message(AppExit::Success);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let loaded = load_game_state_from(&path).expect("file should exist after exit");
|
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);
|
let mut app = test_app(1);
|
||||||
app.insert_resource(GameStatePath(Some(path.clone())));
|
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();
|
app.update();
|
||||||
|
|
||||||
assert!(!path.exists(), "saved file should be deleted after new game");
|
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 });
|
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),
|
from: PileType::Tableau(0),
|
||||||
to: PileType::Tableau(1),
|
to: PileType::Tableau(1),
|
||||||
count: 1,
|
count: 1,
|
||||||
});
|
});
|
||||||
app.update();
|
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 mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).collect();
|
let fired: Vec<_> = cursor.read(events).collect();
|
||||||
assert_eq!(fired.len(), 1, "CardFlippedEvent must fire when a face-down card is exposed");
|
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 });
|
.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),
|
from: PileType::Tableau(0),
|
||||||
to: PileType::Tableau(1),
|
to: PileType::Tableau(1),
|
||||||
count: 1,
|
count: 1,
|
||||||
});
|
});
|
||||||
app.update();
|
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 mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).collect();
|
let fired: Vec<_> = cursor.read(events).collect();
|
||||||
assert!(fired.is_empty(), "no flip event when exposed card was already face-up");
|
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.
|
// Simulate an active game with moves made.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.move_count = 5;
|
app.world_mut().resource_mut::<GameStateResource>().0.move_count = 5;
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(NewGameRequestEvent { seed: None, mode: None });
|
.write_message(NewGameRequestEvent { seed: None, mode: None });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let count = app
|
let count = app
|
||||||
@@ -1146,7 +1146,7 @@ mod tests {
|
|||||||
"test assumes a fresh game with no moves"
|
"test assumes a fresh game with no moves"
|
||||||
);
|
);
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(NewGameRequestEvent { seed: None, mode: None });
|
.write_message(NewGameRequestEvent { seed: None, mode: None });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let count = app
|
let count = app
|
||||||
@@ -1165,7 +1165,7 @@ mod tests {
|
|||||||
fn game_over_screen_absent_when_moves_available() {
|
fn game_over_screen_absent_when_moves_available() {
|
||||||
// A fresh game always has moves (stock is non-empty).
|
// A fresh game always has moves (stock is non-empty).
|
||||||
let mut app = test_app_with_input(42);
|
let mut app = test_app_with_input(42);
|
||||||
app.world_mut().send_event(StateChangedEvent);
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let count = app
|
let count = app
|
||||||
@@ -1201,7 +1201,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
app.world_mut().send_event(StateChangedEvent);
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let count = app
|
let count = app
|
||||||
@@ -1240,7 +1240,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
app.world_mut().send_event(StateChangedEvent);
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// Collect all Text values that are children of the GameOverScreen entity tree.
|
// Collect all Text values that are children of the GameOverScreen entity tree.
|
||||||
@@ -1295,7 +1295,7 @@ mod tests {
|
|||||||
face_up: true,
|
face_up: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
app.world_mut().send_event(StateChangedEvent);
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// Confirm the overlay is present.
|
// Confirm the overlay is present.
|
||||||
@@ -1309,7 +1309,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Clear the NewGameRequestEvent queue so we start with a clean slate.
|
// 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.
|
// Simulate Escape press.
|
||||||
{
|
{
|
||||||
@@ -1320,7 +1320,7 @@ mod tests {
|
|||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// NewGameRequestEvent must have been fired.
|
// 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();
|
let mut reader = events.get_cursor();
|
||||||
assert!(
|
assert!(
|
||||||
reader.read(events).next().is_some(),
|
reader.read(events).next().is_some(),
|
||||||
@@ -1338,10 +1338,10 @@ mod tests {
|
|||||||
fn undo_on_empty_stack_fires_info_toast() {
|
fn undo_on_empty_stack_fires_info_toast() {
|
||||||
let mut app = test_app(42);
|
let mut app = test_app(42);
|
||||||
// Fresh game — undo stack is empty, so undo() returns UndoStackEmpty.
|
// Fresh game — undo stack is empty, so undo() returns UndoStackEmpty.
|
||||||
app.world_mut().send_event(UndoRequestEvent);
|
app.world_mut().write_message(UndoRequestEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
||||||
let mut reader = events.get_cursor();
|
let mut reader = events.get_cursor();
|
||||||
let fired: Vec<_> = reader.read(events).collect();
|
let fired: Vec<_> = reader.read(events).collect();
|
||||||
assert_eq!(fired.len(), 1, "exactly one InfoToastEvent must fire on empty-stack undo");
|
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() {
|
fn undo_after_draw_does_not_fire_info_toast() {
|
||||||
let mut app = test_app(42);
|
let mut app = test_app(42);
|
||||||
// Make a move so the undo stack is non-empty.
|
// Make a move so the undo stack is non-empty.
|
||||||
app.world_mut().send_event(DrawRequestEvent);
|
app.world_mut().write_message(DrawRequestEvent);
|
||||||
app.update();
|
app.update();
|
||||||
// Clear events from the draw so we start with a clean slate.
|
// 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();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
||||||
let mut reader = events.get_cursor();
|
let mut reader = events.get_cursor();
|
||||||
let fired: Vec<_> = reader.read(events).collect();
|
let fired: Vec<_> = reader.read(events).collect();
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use bevy::prelude::*;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HelpScreen;
|
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;
|
pub struct HelpPlugin;
|
||||||
|
|
||||||
impl Plugin for HelpPlugin {
|
impl Plugin for HelpPlugin {
|
||||||
@@ -25,8 +27,8 @@ fn toggle_help_screen(
|
|||||||
if !keys.just_pressed(KeyCode::F1) {
|
if !keys.just_pressed(KeyCode::F1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(entity) = screens.get_single() {
|
if let Ok(entity) = screens.single() {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
} else {
|
} else {
|
||||||
spawn_help_screen(&mut commands);
|
spawn_help_screen(&mut commands);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ fn toggle_home_screen(
|
|||||||
if !keys.just_pressed(KeyCode::KeyM) {
|
if !keys.just_pressed(KeyCode::KeyM) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(entity) = screens.get_single() {
|
if let Ok(entity) = screens.single() {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
} else {
|
} else {
|
||||||
spawn_home_screen(&mut commands, &game);
|
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
|
parent
|
||||||
.spawn(Node {
|
.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ pub struct HudSelection;
|
|||||||
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||||
const Z_HUD: i32 = 50;
|
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;
|
pub struct HudPlugin;
|
||||||
|
|
||||||
impl Plugin for HudPlugin {
|
impl Plugin for HudPlugin {
|
||||||
@@ -325,7 +326,7 @@ fn update_hud(
|
|||||||
if game.is_changed() {
|
if game.is_changed() {
|
||||||
let g = &game.0;
|
let g = &game.0;
|
||||||
let is_zen = g.mode == GameMode::Zen;
|
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").
|
// Zen mode suppresses score display per spec ("No score display").
|
||||||
**t = if is_zen {
|
**t = if is_zen {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -333,10 +334,10 @@ fn update_hud(
|
|||||||
format!("Score: {}", g.score)
|
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);
|
**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 {
|
**t = match g.mode {
|
||||||
GameMode::Classic => match g.draw_mode {
|
GameMode::Classic => match g.draw_mode {
|
||||||
DrawMode::DrawOne => String::new(),
|
DrawMode::DrawOne => String::new(),
|
||||||
@@ -349,7 +350,7 @@ fn update_hud(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Daily challenge constraint (with time-low colour warning) ---
|
// --- 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 {
|
if g.is_won {
|
||||||
**t = String::new();
|
**t = String::new();
|
||||||
} else if let Some(dc) = daily.as_deref() {
|
} else if let Some(dc) = daily.as_deref() {
|
||||||
@@ -364,7 +365,7 @@ fn update_hud(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Undo count ---
|
// --- 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;
|
let count = g.undo_count;
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
**t = String::new();
|
**t = String::new();
|
||||||
@@ -377,7 +378,7 @@ fn update_hud(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Recycle counter (both modes, hidden until first recycle) ---
|
// --- 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 {
|
**t = if g.recycle_count > 0 {
|
||||||
format!("Recycles: {}", g.recycle_count)
|
format!("Recycles: {}", g.recycle_count)
|
||||||
} else {
|
} else {
|
||||||
@@ -386,7 +387,7 @@ fn update_hud(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Draw-cycle indicator (Draw-Three mode only) ---
|
// --- 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 {
|
**t = if g.is_won || g.draw_mode != DrawMode::DrawThree {
|
||||||
// Hide when not in Draw-Three or after the game is won.
|
// Hide when not in Draw-Three or after the game is won.
|
||||||
String::new()
|
String::new()
|
||||||
@@ -405,7 +406,7 @@ fn update_hud(
|
|||||||
let is_zen = game.0.mode == GameMode::Zen;
|
let is_zen = game.0.mode == GameMode::Zen;
|
||||||
let update_time = (ta_active || game.is_changed()) && !is_zen;
|
let update_time = (ta_active || game.is_changed()) && !is_zen;
|
||||||
if update_time {
|
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) {
|
if let Some(ta) = time_attack.as_ref().filter(|ta| ta.active) {
|
||||||
let remaining = ta.remaining_secs.max(0.0) as u64;
|
let remaining = ta.remaining_secs.max(0.0) as u64;
|
||||||
let m = remaining / 60;
|
let m = remaining / 60;
|
||||||
@@ -422,7 +423,7 @@ fn update_hud(
|
|||||||
// Clear the time display immediately whenever Zen mode is active —
|
// Clear the time display immediately whenever Zen mode is active —
|
||||||
// do not guard on game.is_changed() so it clears on the same frame
|
// do not guard on game.is_changed() so it clears on the same frame
|
||||||
// the player presses Z, before any move is made.
|
// 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();
|
**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_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());
|
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
|
||||||
if ac_changed || game.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 {
|
**t = if ac_active {
|
||||||
"AUTO".to_string()
|
"AUTO".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -451,7 +452,7 @@ fn update_selection_hud(
|
|||||||
selection: Option<Res<SelectionState>>,
|
selection: Option<Res<SelectionState>>,
|
||||||
mut q: Query<&mut Text, With<HudSelection>>,
|
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()) {
|
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
Some(PileType::Waste) => "▶ Waste".to_string(),
|
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.
|
/// to debounce so the toast only appears on the leading edge.
|
||||||
fn announce_auto_complete(
|
fn announce_auto_complete(
|
||||||
auto_complete: Option<Res<AutoCompleteState>>,
|
auto_complete: Option<Res<AutoCompleteState>>,
|
||||||
mut toast: EventWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
mut was_active: Local<bool>,
|
mut was_active: Local<bool>,
|
||||||
) {
|
) {
|
||||||
let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
||||||
if now_active && !*was_active {
|
if now_active && !*was_active {
|
||||||
toast.send(InfoToastEvent("Auto-completing...".to_string()));
|
toast.write(InfoToastEvent("Auto-completing...".to_string()));
|
||||||
}
|
}
|
||||||
*was_active = now_active;
|
*was_active = now_active;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -27,9 +27,16 @@ pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
|||||||
/// Computed board layout for a given window size.
|
/// Computed board layout for a given window size.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Layout {
|
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,
|
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>,
|
pub pile_positions: HashMap<PileType, Vec2>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ struct OptOutTask(Option<Task<Result<(), String>>>);
|
|||||||
// Plugin
|
// 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;
|
pub struct LeaderboardPlugin;
|
||||||
|
|
||||||
impl Plugin for LeaderboardPlugin {
|
impl Plugin for LeaderboardPlugin {
|
||||||
@@ -112,8 +113,8 @@ fn toggle_leaderboard_screen(
|
|||||||
if !keys.just_pressed(KeyCode::KeyL) {
|
if !keys.just_pressed(KeyCode::KeyL) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(entity) = screens.get_single() {
|
if let Ok(entity) = screens.single() {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
closed_flag.0 = true;
|
closed_flag.0 = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -174,7 +175,7 @@ fn update_leaderboard_panel(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
spawn_leaderboard_screen(&mut commands, data.0.as_deref());
|
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.
|
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure.
|
||||||
fn poll_opt_in_task(
|
fn poll_opt_in_task(
|
||||||
mut task_res: ResMut<OptInTask>,
|
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(task) = task_res.0.as_mut() else { return };
|
||||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
task_res.0 = None;
|
task_res.0 = None;
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
toast.send(InfoToastEvent("Opted in to leaderboard".to_string()));
|
toast.write(InfoToastEvent("Opted in to leaderboard".to_string()));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("leaderboard opt-in failed: {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.
|
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure.
|
||||||
fn poll_opt_out_task(
|
fn poll_opt_out_task(
|
||||||
mut task_res: ResMut<OptOutTask>,
|
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(task) = task_res.0.as_mut() else { return };
|
||||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
task_res.0 = None;
|
task_res.0 = None;
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
toast.send(InfoToastEvent("Opted out of leaderboard".to_string()));
|
toast.write(InfoToastEvent("Opted out of leaderboard".to_string()));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("leaderboard opt-out failed: {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),
|
min_width: Val::Px(420.0),
|
||||||
max_height: Val::Percent(80.0),
|
max_height: Val::Percent(80.0),
|
||||||
overflow: Overflow::clip_y(),
|
overflow: Overflow::clip_y(),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(8.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
|
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
|
||||||
BorderRadius::all(Val::Px(8.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|card| {
|
.with_children(|card| {
|
||||||
// Header
|
// Header
|
||||||
@@ -347,10 +348,10 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
|
|||||||
Node {
|
Node {
|
||||||
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(4.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.18, 0.35, 0.50)),
|
BackgroundColor(Color::srgb(0.18, 0.35, 0.50)),
|
||||||
BorderRadius::all(Val::Px(4.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -366,10 +367,10 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa
|
|||||||
Node {
|
Node {
|
||||||
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(4.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.42, 0.15, 0.15)),
|
BackgroundColor(Color::srgb(0.42, 0.15, 0.15)),
|
||||||
BorderRadius::all(Val::Px(4.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
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((
|
parent.spawn((
|
||||||
Text::new(text.to_string()),
|
Text::new(text.to_string()),
|
||||||
TextFont { font_size: 13.0, ..default() },
|
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((
|
parent.spawn((
|
||||||
Text::new(text.to_string()),
|
Text::new(text.to_string()),
|
||||||
TextFont { font_size: 15.0, ..default() },
|
TextFont { font_size: 15.0, ..default() },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Bevy integration layer for Solitaire Quest.
|
//! Bevy integration layer for Solitaire Quest.
|
||||||
|
|
||||||
|
pub mod card_animation;
|
||||||
pub mod achievement_plugin;
|
pub mod achievement_plugin;
|
||||||
pub mod animation_plugin;
|
pub mod animation_plugin;
|
||||||
pub mod auto_complete_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 progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
|
||||||
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||||
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
|
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::{
|
pub use feedback_anim_plugin::{
|
||||||
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
|
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
|
||||||
FeedbackAnimPlugin, SettleAnim, ShakeAnim,
|
FeedbackAnimPlugin, SettleAnim, ShakeAnim,
|
||||||
};
|
};
|
||||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
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 cursor_plugin::CursorPlugin;
|
||||||
pub use events::{
|
pub use events::{
|
||||||
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, ForfeitEvent, GameWonEvent,
|
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||||
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
ForfeitEvent, GameWonEvent, HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent,
|
||||||
NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
|
MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent,
|
||||||
|
StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
|
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
|
||||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
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 selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
|
||||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
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::{
|
pub use time_attack_plugin::{
|
||||||
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
|
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
/// 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);
|
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;
|
pub struct OnboardingPlugin;
|
||||||
|
|
||||||
impl Plugin for OnboardingPlugin {
|
impl Plugin for OnboardingPlugin {
|
||||||
@@ -59,7 +61,7 @@ fn dismiss_on_any_input(
|
|||||||
path: Option<Res<SettingsStoragePath>>,
|
path: Option<Res<SettingsStoragePath>>,
|
||||||
screens: Query<Entity, With<OnboardingScreen>>,
|
screens: Query<Entity, With<OnboardingScreen>>,
|
||||||
) {
|
) {
|
||||||
let Ok(entity) = screens.get_single() else {
|
let Ok(entity) = screens.single() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let pressed = keys.get_just_pressed().next().is_some()
|
let pressed = keys.get_just_pressed().next().is_some()
|
||||||
@@ -67,7 +69,7 @@ fn dismiss_on_any_input(
|
|||||||
if !pressed {
|
if !pressed {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
settings.0.first_run_complete = true;
|
settings.0.first_run_complete = true;
|
||||||
persist(path.as_deref().map(|p| &p.0), &settings.0);
|
persist(path.as_deref().map(|p| &p.0), &settings.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use crate::events::StateChangedEvent;
|
|||||||
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::{DragState, GameStateResource};
|
use crate::resources::{DragState, GameStateResource};
|
||||||
|
use crate::selection_plugin::{SelectionKeySet, SelectionState};
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||||
use crate::stats_plugin::StatsResource;
|
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;
|
pub struct PausePlugin;
|
||||||
|
|
||||||
impl Plugin for PausePlugin {
|
impl Plugin for PausePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Both add_event calls are idempotent — other plugins may register these
|
// Both add_event calls are idempotent — other plugins may register these
|
||||||
// events first, but calling add_event again is always safe.
|
// events first, but calling add_event again is always safe.
|
||||||
app.add_event::<SettingsChangedEvent>()
|
app.add_message::<SettingsChangedEvent>()
|
||||||
.add_event::<StateChangedEvent>()
|
.add_message::<StateChangedEvent>()
|
||||||
.init_resource::<PausedResource>()
|
.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>>,
|
stats: Option<Res<StatsResource>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
mut drag: Option<ResMut<DragState>>,
|
mut drag: Option<ResMut<DragState>>,
|
||||||
mut changed: EventWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
|
selection: Option<Res<SelectionState>>,
|
||||||
) {
|
) {
|
||||||
if !keys.just_pressed(KeyCode::Escape) {
|
if !keys.just_pressed(KeyCode::Escape) {
|
||||||
return;
|
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
|
// 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.
|
// the Escape key (to start a new game). Do not open the pause overlay.
|
||||||
if !game_over_screens.is_empty() {
|
if !game_over_screens.is_empty() {
|
||||||
@@ -90,12 +106,12 @@ fn toggle_pause(
|
|||||||
if let Some(ref mut d) = drag {
|
if let Some(ref mut d) = drag {
|
||||||
if !d.is_idle() {
|
if !d.is_idle() {
|
||||||
d.clear();
|
d.clear();
|
||||||
changed.send(StateChangedEvent);
|
changed.write(StateChangedEvent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Ok(entity) = screens.get_single() {
|
if let Ok(entity) = screens.single() {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
paused.0 = false;
|
paused.0 = false;
|
||||||
} else {
|
} else {
|
||||||
// Snapshot current level and streak at pause time.
|
// Snapshot current level and streak at pause time.
|
||||||
@@ -125,7 +141,7 @@ fn handle_pause_draw_toggle(
|
|||||||
paused: Res<PausedResource>,
|
paused: Res<PausedResource>,
|
||||||
settings: Option<ResMut<SettingsResource>>,
|
settings: Option<ResMut<SettingsResource>>,
|
||||||
path: Option<Res<SettingsStoragePath>>,
|
path: Option<Res<SettingsStoragePath>>,
|
||||||
mut changed: EventWriter<SettingsChangedEvent>,
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
) {
|
) {
|
||||||
if !paused.0 {
|
if !paused.0 {
|
||||||
return;
|
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)),
|
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(4.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
|
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
|
||||||
BorderRadius::all(Val::Px(4.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|btn| {
|
.with_children(|btn| {
|
||||||
btn.spawn((
|
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
|
// draw_mode_label (pure function) — Task #64
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -428,6 +454,17 @@ mod tests {
|
|||||||
assert_eq!(draw_mode_label(DrawMode::DrawThree), "Draw 3");
|
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
|
// pause_draw_toggle_flips_draw_mode — Task #64
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -496,7 +533,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Verify a SettingsChangedEvent was fired.
|
// 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 mut cursor = events.get_cursor();
|
||||||
let count = cursor.read(events).count();
|
let count = cursor.read(events).count();
|
||||||
assert!(count >= 1, "SettingsChangedEvent must be fired on toggle");
|
assert!(count >= 1, "SettingsChangedEvent must be fired on toggle");
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ fn toggle_profile_screen(
|
|||||||
if !keys.just_pressed(KeyCode::KeyP) {
|
if !keys.just_pressed(KeyCode::KeyP) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(entity) = screens.get_single() {
|
if let Ok(entity) = screens.single() {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
} else {
|
} else {
|
||||||
spawn_profile_screen(
|
spawn_profile_screen(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
@@ -246,7 +246,7 @@ fn spawn_profile_screen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a fixed-height vertical spacer node.
|
/// 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 {
|
parent.spawn(Node {
|
||||||
height: Val::Px(height_px),
|
height: Val::Px(height_px),
|
||||||
..default()
|
..default()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ pub struct ProgressResource(pub PlayerProgress);
|
|||||||
pub struct ProgressStoragePath(pub Option<PathBuf>);
|
pub struct ProgressStoragePath(pub Option<PathBuf>);
|
||||||
|
|
||||||
/// Fired when a win pushes the player to a new level.
|
/// 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 struct LevelUpEvent {
|
||||||
pub previous_level: u32,
|
pub previous_level: u32,
|
||||||
pub new_level: u32,
|
pub new_level: u32,
|
||||||
@@ -37,6 +37,11 @@ pub struct LevelUpEvent {
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct ProgressUpdate;
|
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 struct ProgressPlugin {
|
||||||
pub storage_path: Option<PathBuf>,
|
pub storage_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
@@ -64,9 +69,9 @@ impl Plugin for ProgressPlugin {
|
|||||||
};
|
};
|
||||||
app.insert_resource(ProgressResource(loaded))
|
app.insert_resource(ProgressResource(loaded))
|
||||||
.insert_resource(ProgressStoragePath(self.storage_path.clone()))
|
.insert_resource(ProgressStoragePath(self.storage_path.clone()))
|
||||||
.add_event::<LevelUpEvent>()
|
.add_message::<LevelUpEvent>()
|
||||||
.add_event::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
award_xp_on_win
|
award_xp_on_win
|
||||||
@@ -77,9 +82,9 @@ impl Plugin for ProgressPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn award_xp_on_win(
|
fn award_xp_on_win(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: MessageReader<GameWonEvent>,
|
||||||
mut levelups: EventWriter<LevelUpEvent>,
|
mut levelups: MessageWriter<LevelUpEvent>,
|
||||||
mut xp_awarded: EventWriter<XpAwardedEvent>,
|
mut xp_awarded: MessageWriter<XpAwardedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
path: Res<ProgressStoragePath>,
|
path: Res<ProgressStoragePath>,
|
||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
@@ -88,9 +93,9 @@ fn award_xp_on_win(
|
|||||||
let used_undo = game.0.undo_count > 0;
|
let used_undo = game.0.undo_count > 0;
|
||||||
let amount = xp_for_win(ev.time_seconds, used_undo);
|
let amount = xp_for_win(ev.time_seconds, used_undo);
|
||||||
let prev_level = progress.0.add_xp(amount);
|
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) {
|
if progress.0.leveled_up_from(prev_level) {
|
||||||
levelups.send(LevelUpEvent {
|
levelups.write(LevelUpEvent {
|
||||||
previous_level: prev_level,
|
previous_level: prev_level,
|
||||||
new_level: progress.0.level,
|
new_level: progress.0.level,
|
||||||
total_xp: progress.0.total_xp,
|
total_xp: progress.0.total_xp,
|
||||||
@@ -131,7 +136,7 @@ mod tests {
|
|||||||
fn win_awards_base_xp() {
|
fn win_awards_base_xp() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Game starts with undo_count = 0, so the no-undo bonus applies.
|
// 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,
|
score: 500,
|
||||||
time_seconds: 300, // no speed bonus
|
time_seconds: 300, // no speed bonus
|
||||||
});
|
});
|
||||||
@@ -150,7 +155,7 @@ mod tests {
|
|||||||
.0
|
.0
|
||||||
.undo_count = 1;
|
.undo_count = 1;
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 300,
|
time_seconds: 300,
|
||||||
});
|
});
|
||||||
@@ -164,7 +169,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn fast_win_includes_speed_bonus() {
|
fn fast_win_includes_speed_bonus() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 0,
|
time_seconds: 0,
|
||||||
});
|
});
|
||||||
@@ -181,13 +186,13 @@ mod tests {
|
|||||||
// Pre-load 480 XP so a 75-XP win pushes us over the 500 boundary.
|
// 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().resource_mut::<ProgressResource>().0.total_xp = 480;
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 300,
|
time_seconds: 300,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<LevelUpEvent>>();
|
let events = app.world().resource::<Messages<LevelUpEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
assert_eq!(fired.len(), 1, "exactly one level-up");
|
assert_eq!(fired.len(), 1, "exactly one level-up");
|
||||||
@@ -198,13 +203,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn win_without_level_change_does_not_fire_levelup() {
|
fn win_without_level_change_does_not_fire_levelup() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 300,
|
time_seconds: 300,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<LevelUpEvent>>();
|
let events = app.world().resource::<Messages<LevelUpEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
assert!(cursor.read(events).next().is_none());
|
assert!(cursor.read(events).next().is_none());
|
||||||
}
|
}
|
||||||
@@ -213,13 +218,13 @@ mod tests {
|
|||||||
fn xp_awarded_event_fired_with_correct_amount() {
|
fn xp_awarded_event_fired_with_correct_amount() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Slow win, no undo → base 50 + no_undo 25 = 75
|
// Slow win, no undo → base 50 + no_undo 25 = 75
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 300,
|
time_seconds: 300,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<XpAwardedEvent>>();
|
let events = app.world().resource::<Messages<XpAwardedEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
@@ -231,14 +236,14 @@ mod tests {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<ProgressResource>().0.total_xp = 480;
|
app.world_mut().resource_mut::<ProgressResource>().0.total_xp = 480;
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 300,
|
time_seconds: 300,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let total_xp = app.world().resource::<ProgressResource>().0.total_xp;
|
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 mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
@@ -256,7 +261,7 @@ mod tests {
|
|||||||
.0
|
.0
|
||||||
.mode = solitaire_core::game_state::GameMode::Zen;
|
.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
|
score: 0, // Zen mode keeps score at 0
|
||||||
time_seconds: 300,
|
time_seconds: 300,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,28 +12,72 @@ pub struct GameStateResource(pub GameState);
|
|||||||
|
|
||||||
/// Tracks an in-progress drag operation.
|
/// Tracks an in-progress drag operation.
|
||||||
///
|
///
|
||||||
/// When `cards` is empty there is no active drag. When non-empty, the listed cards
|
/// When `cards` is empty there is no active drag. When non-empty, the listed
|
||||||
/// are being moved by the user and should be rendered at the cursor position.
|
/// cards are being moved by the user and should be rendered at the cursor or
|
||||||
#[derive(Resource, Debug, Clone, Default)]
|
/// 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 {
|
pub struct DragState {
|
||||||
|
/// IDs of the cards being dragged (bottom-to-top stacking order).
|
||||||
pub cards: Vec<u32>,
|
pub cards: Vec<u32>,
|
||||||
|
/// Pile the drag originated from.
|
||||||
pub origin_pile: Option<PileType>,
|
pub origin_pile: Option<PileType>,
|
||||||
|
/// World-space offset from the cursor/touch to the bottom card's centre.
|
||||||
pub cursor_offset: Vec2,
|
pub cursor_offset: Vec2,
|
||||||
|
/// Z coordinate used for the dragged cards.
|
||||||
pub origin_z: f32,
|
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 {
|
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 {
|
pub fn is_idle(&self) -> bool {
|
||||||
self.cards.is_empty()
|
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) {
|
pub fn clear(&mut self) {
|
||||||
self.cards.clear();
|
self.cards.clear();
|
||||||
self.origin_pile = None;
|
self.origin_pile = None;
|
||||||
self.cursor_offset = Vec2::ZERO;
|
self.cursor_offset = Vec2::ZERO;
|
||||||
self.origin_z = 0.0;
|
self.origin_z = 0.0;
|
||||||
|
self.press_pos = Vec2::ZERO;
|
||||||
|
self.committed = false;
|
||||||
|
self.active_touch_id = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use solitaire_core::card::Suit;
|
|||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::{InfoToastEvent, MoveRequestEvent};
|
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
|
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
@@ -42,6 +42,13 @@ pub struct SelectionState {
|
|||||||
pub selected_pile: Option<PileType>,
|
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
|
/// Marker component placed on the outline sprite used as the keyboard-selection
|
||||||
/// highlight.
|
/// highlight.
|
||||||
///
|
///
|
||||||
@@ -59,7 +66,10 @@ impl Plugin for SelectionPlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
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),
|
update_selection_highlight.after(GameMutation),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -162,8 +172,8 @@ fn handle_selection_keys(
|
|||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut selection: ResMut<SelectionState>,
|
mut selection: ResMut<SelectionState>,
|
||||||
mut moves: EventWriter<MoveRequestEvent>,
|
mut moves: MessageWriter<MoveRequestEvent>,
|
||||||
mut info_toast: EventWriter<InfoToastEvent>,
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
@@ -200,11 +210,11 @@ fn handle_selection_keys(
|
|||||||
if keys.just_pressed(KeyCode::Tab) {
|
if keys.just_pressed(KeyCode::Tab) {
|
||||||
let next = cycle_next_pile(&available, selection.selected_pile.as_ref());
|
let next = cycle_next_pile(&available, selection.selected_pile.as_ref());
|
||||||
if next.is_none() {
|
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()
|
} else if selection.selected_pile.is_some()
|
||||||
&& did_wrap(&available, selection.selected_pile.as_ref(), next.as_ref())
|
&& 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;
|
selection.selected_pile = next;
|
||||||
return;
|
return;
|
||||||
@@ -236,7 +246,7 @@ fn handle_selection_keys(
|
|||||||
// --- Priority 1: foundation move (single card) ---
|
// --- Priority 1: foundation move (single card) ---
|
||||||
let foundation_dest = try_foundation_dest(card, &game.0);
|
let foundation_dest = try_foundation_dest(card, &game.0);
|
||||||
if let Some(dest) = foundation_dest {
|
if let Some(dest) = foundation_dest {
|
||||||
moves.send(MoveRequestEvent {
|
moves.write(MoveRequestEvent {
|
||||||
from: pile.clone(),
|
from: pile.clone(),
|
||||||
to: dest,
|
to: dest,
|
||||||
count: 1,
|
count: 1,
|
||||||
@@ -260,7 +270,7 @@ fn handle_selection_keys(
|
|||||||
if let Some((dest, count)) =
|
if let Some((dest, count)) =
|
||||||
best_tableau_destination_for_stack(bottom, pile, &game.0, run_len)
|
best_tableau_destination_for_stack(bottom, pile, &game.0, run_len)
|
||||||
{
|
{
|
||||||
moves.send(MoveRequestEvent {
|
moves.write(MoveRequestEvent {
|
||||||
from: pile.clone(),
|
from: pile.clone(),
|
||||||
to: dest,
|
to: dest,
|
||||||
count,
|
count,
|
||||||
@@ -274,7 +284,7 @@ fn handle_selection_keys(
|
|||||||
// Covers non-tableau sources (Waste, Foundation) that have no
|
// Covers non-tableau sources (Waste, Foundation) that have no
|
||||||
// stack-move logic.
|
// stack-move logic.
|
||||||
if let Some(dest) = best_destination(card, &game.0) {
|
if let Some(dest) = best_destination(card, &game.0) {
|
||||||
moves.send(MoveRequestEvent {
|
moves.write(MoveRequestEvent {
|
||||||
from: pile.clone(),
|
from: pile.clone(),
|
||||||
to: dest,
|
to: dest,
|
||||||
count: 1,
|
count: 1,
|
||||||
@@ -329,6 +339,20 @@ fn try_foundation_dest(
|
|||||||
None
|
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.
|
/// Maintains the `SelectionHighlight` outline sprite.
|
||||||
///
|
///
|
||||||
/// When a pile is selected, a cyan sprite is placed at the selected card's
|
/// 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.
|
// Always despawn any existing highlight first.
|
||||||
for entity in &highlights {
|
for entity in &highlights {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(ref pile) = selection.selected_pile else {
|
let Some(ref pile) = selection.selected_pile else {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ pub struct SettingsStoragePath(pub Option<PathBuf>);
|
|||||||
pub struct SettingsScreen(pub bool);
|
pub struct SettingsScreen(pub bool);
|
||||||
|
|
||||||
/// Fired whenever settings change so consumers (audio, UI) can react.
|
/// Fired whenever settings change so consumers (audio, UI) can react.
|
||||||
#[derive(Event, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct SettingsChangedEvent(pub Settings);
|
pub struct SettingsChangedEvent(pub Settings);
|
||||||
|
|
||||||
/// Marker on the root Settings panel entity.
|
/// Marker on the root Settings panel entity.
|
||||||
@@ -144,9 +144,9 @@ impl Plugin for SettingsPlugin {
|
|||||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||||
.init_resource::<SettingsScreen>()
|
.init_resource::<SettingsScreen>()
|
||||||
.init_resource::<SettingsScrollPos>()
|
.init_resource::<SettingsScrollPos>()
|
||||||
.add_event::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_event::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
.add_event::<bevy::input::mouse::MouseWheel>()
|
.add_message::<bevy::input::mouse::MouseWheel>()
|
||||||
.add_systems(Update, (handle_volume_keys, toggle_settings_screen, scroll_settings_panel));
|
.add_systems(Update, (handle_volume_keys, toggle_settings_screen, scroll_settings_panel));
|
||||||
|
|
||||||
if self.ui_enabled {
|
if self.ui_enabled {
|
||||||
@@ -185,7 +185,7 @@ fn handle_volume_keys(
|
|||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
mut settings: ResMut<SettingsResource>,
|
mut settings: ResMut<SettingsResource>,
|
||||||
path: Res<SettingsStoragePath>,
|
path: Res<SettingsStoragePath>,
|
||||||
mut changed: EventWriter<SettingsChangedEvent>,
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
) {
|
) {
|
||||||
let mut delta = 0.0_f32;
|
let mut delta = 0.0_f32;
|
||||||
if keys.just_pressed(KeyCode::BracketLeft) {
|
if keys.just_pressed(KeyCode::BracketLeft) {
|
||||||
@@ -203,7 +203,7 @@ fn handle_volume_keys(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
persist(&path, &settings.0);
|
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.
|
/// Opens or closes the Settings panel when `O` is pressed.
|
||||||
@@ -256,11 +256,11 @@ fn sync_settings_panel_visibility(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Save the current scroll offset before despawning the panel.
|
// Save the current scroll offset before despawning the panel.
|
||||||
if let Ok(sp) = scroll_nodes.get_single() {
|
if let Ok(sp) = scroll_nodes.single() {
|
||||||
scroll_pos.0 = sp.offset_y;
|
scroll_pos.0 = sp.0.y;
|
||||||
}
|
}
|
||||||
for entity in &panels {
|
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 settings: ResMut<SettingsResource>,
|
||||||
mut screen: ResMut<SettingsScreen>,
|
mut screen: ResMut<SettingsScreen>,
|
||||||
path: Res<SettingsStoragePath>,
|
path: Res<SettingsStoragePath>,
|
||||||
mut changed: EventWriter<SettingsChangedEvent>,
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
mut manual_sync: EventWriter<ManualSyncRequestEvent>,
|
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||||
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
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 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>)>,
|
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);
|
let after = settings.0.adjust_sfx_volume(-SFX_STEP);
|
||||||
if (before - after).abs() > f32::EPSILON {
|
if (before - after).abs() > f32::EPSILON {
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut t) = sfx_text.get_single_mut() {
|
if let Ok(mut t) = sfx_text.single_mut() {
|
||||||
**t = format!("{:.2}", after);
|
**t = format!("{:.2}", after);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,8 +413,8 @@ fn handle_settings_buttons(
|
|||||||
let after = settings.0.adjust_sfx_volume(SFX_STEP);
|
let after = settings.0.adjust_sfx_volume(SFX_STEP);
|
||||||
if (before - after).abs() > f32::EPSILON {
|
if (before - after).abs() > f32::EPSILON {
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut t) = sfx_text.get_single_mut() {
|
if let Ok(mut t) = sfx_text.single_mut() {
|
||||||
**t = format!("{:.2}", after);
|
**t = format!("{:.2}", after);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,8 +424,8 @@ fn handle_settings_buttons(
|
|||||||
let after = settings.0.adjust_music_volume(-SFX_STEP);
|
let after = settings.0.adjust_music_volume(-SFX_STEP);
|
||||||
if (before - after).abs() > f32::EPSILON {
|
if (before - after).abs() > f32::EPSILON {
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut t) = music_text.get_single_mut() {
|
if let Ok(mut t) = music_text.single_mut() {
|
||||||
**t = format!("{:.2}", after);
|
**t = format!("{:.2}", after);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,8 +435,8 @@ fn handle_settings_buttons(
|
|||||||
let after = settings.0.adjust_music_volume(SFX_STEP);
|
let after = settings.0.adjust_music_volume(SFX_STEP);
|
||||||
if (before - after).abs() > f32::EPSILON {
|
if (before - after).abs() > f32::EPSILON {
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut t) = music_text.get_single_mut() {
|
if let Ok(mut t) = music_text.single_mut() {
|
||||||
**t = format!("{:.2}", after);
|
**t = format!("{:.2}", after);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,8 +447,8 @@ fn handle_settings_buttons(
|
|||||||
DrawMode::DrawThree => DrawMode::DrawOne,
|
DrawMode::DrawThree => DrawMode::DrawOne,
|
||||||
};
|
};
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut t) = draw_text.get_single_mut() {
|
if let Ok(mut t) = draw_text.single_mut() {
|
||||||
**t = draw_mode_label(&settings.0.draw_mode);
|
**t = draw_mode_label(&settings.0.draw_mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,8 +459,8 @@ fn handle_settings_buttons(
|
|||||||
AnimSpeed::Instant => AnimSpeed::Normal,
|
AnimSpeed::Instant => AnimSpeed::Normal,
|
||||||
};
|
};
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut t) = anim_speed_text.get_single_mut() {
|
if let Ok(mut t) = anim_speed_text.single_mut() {
|
||||||
**t = anim_speed_label(&settings.0.animation_speed);
|
**t = anim_speed_label(&settings.0.animation_speed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,31 +471,31 @@ fn handle_settings_buttons(
|
|||||||
Theme::Dark => Theme::Green,
|
Theme::Dark => Theme::Green,
|
||||||
};
|
};
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut t) = theme_text.get_single_mut() {
|
if let Ok(mut t) = theme_text.single_mut() {
|
||||||
**t = theme_label(&settings.0.theme);
|
**t = theme_label(&settings.0.theme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsButton::ToggleColorBlind => {
|
SettingsButton::ToggleColorBlind => {
|
||||||
settings.0.color_blind_mode = !settings.0.color_blind_mode;
|
settings.0.color_blind_mode = !settings.0.color_blind_mode;
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
if let Ok(mut t) = color_blind_text.get_single_mut() {
|
if let Ok(mut t) = color_blind_text.single_mut() {
|
||||||
**t = color_blind_label(settings.0.color_blind_mode);
|
**t = color_blind_label(settings.0.color_blind_mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsButton::SelectCardBack(idx) => {
|
SettingsButton::SelectCardBack(idx) => {
|
||||||
settings.0.selected_card_back = *idx;
|
settings.0.selected_card_back = *idx;
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
}
|
}
|
||||||
SettingsButton::SelectBackground(idx) => {
|
SettingsButton::SelectBackground(idx) => {
|
||||||
settings.0.selected_background = *idx;
|
settings.0.selected_background = *idx;
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
}
|
}
|
||||||
SettingsButton::SyncNow => {
|
SettingsButton::SyncNow => {
|
||||||
manual_sync.send(ManualSyncRequestEvent);
|
manual_sync.write(ManualSyncRequestEvent);
|
||||||
}
|
}
|
||||||
SettingsButton::Done => {
|
SettingsButton::Done => {
|
||||||
screen.0 = false;
|
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
|
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
|
||||||
/// scrolls past the top.
|
/// scrolls past the top.
|
||||||
fn scroll_settings_panel(
|
fn scroll_settings_panel(
|
||||||
mut scroll_evr: EventReader<MouseWheel>,
|
mut scroll_evr: MessageReader<MouseWheel>,
|
||||||
screen: Res<SettingsScreen>,
|
screen: Res<SettingsScreen>,
|
||||||
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
|
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
|
||||||
) {
|
) {
|
||||||
@@ -556,7 +556,7 @@ fn scroll_settings_panel(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for mut sp in scrollables.iter_mut() {
|
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((
|
root.spawn((
|
||||||
SettingsPanelScrollable,
|
SettingsPanelScrollable,
|
||||||
SettingsScrollNode,
|
SettingsScrollNode,
|
||||||
ScrollPosition { offset_y: scroll_offset, ..default() },
|
ScrollPosition(Vec2::new(0.0, scroll_offset)),
|
||||||
Node {
|
Node {
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
padding: UiRect::all(Val::Px(28.0)),
|
padding: UiRect::all(Val::Px(28.0)),
|
||||||
@@ -603,10 +603,10 @@ fn spawn_settings_panel(
|
|||||||
min_width: Val::Px(340.0),
|
min_width: Val::Px(340.0),
|
||||||
max_height: Val::Percent(88.0),
|
max_height: Val::Percent(88.0),
|
||||||
overflow: Overflow::scroll_y(),
|
overflow: Overflow::scroll_y(),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(8.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
|
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
|
||||||
BorderRadius::all(Val::Px(8.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|card| {
|
.with_children(|card| {
|
||||||
// Title
|
// Title
|
||||||
@@ -755,10 +755,10 @@ fn spawn_settings_panel(
|
|||||||
height: Val::Px(40.0),
|
height: Val::Px(40.0),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(4.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(bg_color),
|
BackgroundColor(bg_color),
|
||||||
BorderRadius::all(Val::Px(4.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -801,10 +801,10 @@ fn spawn_settings_panel(
|
|||||||
height: Val::Px(40.0),
|
height: Val::Px(40.0),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(4.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(bg_color),
|
BackgroundColor(bg_color),
|
||||||
BorderRadius::all(Val::Px(4.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -839,10 +839,10 @@ fn spawn_settings_panel(
|
|||||||
Node {
|
Node {
|
||||||
padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
|
padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(4.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
|
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
|
||||||
BorderRadius::all(Val::Px(4.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -861,10 +861,10 @@ fn spawn_settings_panel(
|
|||||||
padding: UiRect::axes(Val::Px(20.0), Val::Px(8.0)),
|
padding: UiRect::axes(Val::Px(20.0), Val::Px(8.0)),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
margin: UiRect::top(Val::Px(6.0)),
|
margin: UiRect::top(Val::Px(6.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(4.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
|
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
|
||||||
BorderRadius::all(Val::Px(4.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
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((
|
parent.spawn((
|
||||||
Text::new(title),
|
Text::new(title),
|
||||||
TextFont {
|
TextFont {
|
||||||
@@ -893,7 +893,7 @@ fn section_label(parent: &mut ChildBuilder, title: &str) {
|
|||||||
|
|
||||||
/// Generic volume row: `Label 0.80 [−] [+]`
|
/// Generic volume row: `Label 0.80 [−] [+]`
|
||||||
fn volume_row<Marker: Component>(
|
fn volume_row<Marker: Component>(
|
||||||
parent: &mut ChildBuilder,
|
parent: &mut ChildSpawnerCommands,
|
||||||
label: &str,
|
label: &str,
|
||||||
value: f32,
|
value: f32,
|
||||||
marker: Marker,
|
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
|
parent
|
||||||
.spawn((
|
.spawn((
|
||||||
action,
|
action,
|
||||||
@@ -934,10 +934,10 @@ fn icon_button(parent: &mut ChildBuilder, label: &str, action: SettingsButton) {
|
|||||||
height: Val::Px(28.0),
|
height: Val::Px(28.0),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(4.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
|
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
|
||||||
BorderRadius::all(Val::Px(4.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -995,7 +995,7 @@ mod tests {
|
|||||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||||
assert!(after < before);
|
assert!(after < before);
|
||||||
|
|
||||||
let events = app.world().resource::<Events<SettingsChangedEvent>>();
|
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
assert_eq!(cursor.read(events).count(), 1);
|
assert_eq!(cursor.read(events).count(), 1);
|
||||||
}
|
}
|
||||||
@@ -1020,7 +1020,7 @@ mod tests {
|
|||||||
press(&mut app, KeyCode::BracketRight);
|
press(&mut app, KeyCode::BracketRight);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<SettingsChangedEvent>>();
|
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
assert_eq!(cursor.read(events).count(), 0);
|
assert_eq!(cursor.read(events).count(), 0);
|
||||||
}
|
}
|
||||||
@@ -1036,7 +1036,7 @@ mod tests {
|
|||||||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||||
assert!(after >= 0.0, "volume must not go below zero");
|
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();
|
let mut cursor = events.get_cursor();
|
||||||
assert_eq!(cursor.read(events).count(), 0, "no event when clamped at floor");
|
assert_eq!(cursor.read(events).count(), 0, "no event when clamped at floor");
|
||||||
}
|
}
|
||||||
@@ -1095,7 +1095,7 @@ mod tests {
|
|||||||
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
|
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
|
||||||
.id();
|
.id();
|
||||||
// Send a downward scroll event while the panel is closed.
|
// 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,
|
unit: MouseScrollUnit::Line,
|
||||||
x: 0.0,
|
x: 0.0,
|
||||||
y: -3.0,
|
y: -3.0,
|
||||||
@@ -1108,7 +1108,7 @@ mod tests {
|
|||||||
.entity(entity)
|
.entity(entity)
|
||||||
.get::<ScrollPosition>()
|
.get::<ScrollPosition>()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.offset_y;
|
.0.y;
|
||||||
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
|
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1123,11 +1123,11 @@ mod tests {
|
|||||||
.world_mut()
|
.world_mut()
|
||||||
.spawn((
|
.spawn((
|
||||||
SettingsPanelScrollable,
|
SettingsPanelScrollable,
|
||||||
ScrollPosition { offset_y: 100.0, ..default() },
|
ScrollPosition(Vec2::new(0.0, 100.0)),
|
||||||
))
|
))
|
||||||
.id();
|
.id();
|
||||||
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
|
// 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,
|
unit: MouseScrollUnit::Line,
|
||||||
x: 0.0,
|
x: 0.0,
|
||||||
y: -2.0,
|
y: -2.0,
|
||||||
@@ -1139,7 +1139,7 @@ mod tests {
|
|||||||
.entity(entity)
|
.entity(entity)
|
||||||
.get::<ScrollPosition>()
|
.get::<ScrollPosition>()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.offset_y;
|
.0.y;
|
||||||
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
|
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1153,11 +1153,11 @@ mod tests {
|
|||||||
.world_mut()
|
.world_mut()
|
||||||
.spawn((
|
.spawn((
|
||||||
SettingsPanelScrollable,
|
SettingsPanelScrollable,
|
||||||
ScrollPosition { offset_y: 10.0, ..default() },
|
ScrollPosition(Vec2::new(0.0, 10.0)),
|
||||||
))
|
))
|
||||||
.id();
|
.id();
|
||||||
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
|
// 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,
|
unit: MouseScrollUnit::Line,
|
||||||
x: 0.0,
|
x: 0.0,
|
||||||
y: 5.0,
|
y: 5.0,
|
||||||
@@ -1169,7 +1169,7 @@ mod tests {
|
|||||||
.entity(entity)
|
.entity(entity)
|
||||||
.get::<ScrollPosition>()
|
.get::<ScrollPosition>()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.offset_y;
|
.0.y;
|
||||||
assert_eq!(offset, 0.0, "scrolling past top must clamp to 0, got {offset}");
|
assert_eq!(offset, 0.0, "scrolling past top must clamp to 0, got {offset}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ impl Plugin for StatsPlugin {
|
|||||||
};
|
};
|
||||||
app.insert_resource(StatsResource(loaded))
|
app.insert_resource(StatsResource(loaded))
|
||||||
.insert_resource(StatsStoragePath(self.storage_path.clone()))
|
.insert_resource(StatsStoragePath(self.storage_path.clone()))
|
||||||
.add_event::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_event::<ForfeitEvent>()
|
.add_message::<ForfeitEvent>()
|
||||||
.add_event::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
// record_abandoned must read `move_count` BEFORE handle_new_game
|
// record_abandoned must read `move_count` BEFORE handle_new_game
|
||||||
// clobbers it with a fresh game.
|
// clobbers it with a fresh game.
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@@ -111,7 +111,7 @@ fn persist(path: &StatsStoragePath, stats: &StatsSnapshot, context: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update_stats_on_win(
|
fn update_stats_on_win(
|
||||||
mut events: EventReader<GameWonEvent>,
|
mut events: MessageReader<GameWonEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut stats: ResMut<StatsResource>,
|
mut stats: ResMut<StatsResource>,
|
||||||
path: Res<StatsStoragePath>,
|
path: Res<StatsStoragePath>,
|
||||||
@@ -125,11 +125,11 @@ fn update_stats_on_win(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update_stats_on_new_game(
|
fn update_stats_on_new_game(
|
||||||
mut events: EventReader<NewGameRequestEvent>,
|
mut events: MessageReader<NewGameRequestEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut stats: ResMut<StatsResource>,
|
mut stats: ResMut<StatsResource>,
|
||||||
path: Res<StatsStoragePath>,
|
path: Res<StatsStoragePath>,
|
||||||
mut toast: EventWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
for _ in events.read() {
|
for _ in events.read() {
|
||||||
if game.0.move_count > 0 && !game.0.is_won {
|
if game.0.move_count > 0 && !game.0.is_won {
|
||||||
@@ -137,7 +137,7 @@ fn update_stats_on_new_game(
|
|||||||
stats.0.record_abandoned();
|
stats.0.record_abandoned();
|
||||||
persist(&path, &stats.0, "abandoned game");
|
persist(&path, &stats.0, "abandoned game");
|
||||||
if streak > 1 {
|
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
|
/// `AutoCompleteState` is reset here so the "AUTO" badge and chime do not bleed
|
||||||
/// into the new deal (task #41).
|
/// into the new deal (task #41).
|
||||||
fn handle_forfeit(
|
fn handle_forfeit(
|
||||||
mut events: EventReader<ForfeitEvent>,
|
mut events: MessageReader<ForfeitEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut stats: ResMut<StatsResource>,
|
mut stats: ResMut<StatsResource>,
|
||||||
path: Res<StatsStoragePath>,
|
path: Res<StatsStoragePath>,
|
||||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
mut toast: EventWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
mut auto_complete: Option<ResMut<AutoCompleteState>>,
|
mut auto_complete: Option<ResMut<AutoCompleteState>>,
|
||||||
) {
|
) {
|
||||||
for _ in events.read() {
|
for _ in events.read() {
|
||||||
@@ -163,7 +163,7 @@ fn handle_forfeit(
|
|||||||
stats.0.record_abandoned();
|
stats.0.record_abandoned();
|
||||||
persist(&path, &stats.0, "forfeit");
|
persist(&path, &stats.0, "forfeit");
|
||||||
if streak > 1 {
|
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
|
// 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 {
|
if let Some(ref mut ac) = auto_complete {
|
||||||
**ac = AutoCompleteState::default();
|
**ac = AutoCompleteState::default();
|
||||||
}
|
}
|
||||||
toast.send(InfoToastEvent("Game forfeited".to_string()));
|
toast.write(InfoToastEvent("Game forfeited".to_string()));
|
||||||
new_game.send(NewGameRequestEvent::default());
|
new_game.write(NewGameRequestEvent::default());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +187,8 @@ fn toggle_stats_screen(
|
|||||||
if !keys.just_pressed(KeyCode::KeyS) {
|
if !keys.just_pressed(KeyCode::KeyS) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(entity) = screens.get_single() {
|
if let Ok(entity) = screens.single() {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn();
|
||||||
} else {
|
} else {
|
||||||
spawn_stats_screen(
|
spawn_stats_screen(
|
||||||
&mut commands,
|
&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
|
/// 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.
|
/// 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
|
parent
|
||||||
.spawn((
|
.spawn((
|
||||||
StatsCell,
|
StatsCell,
|
||||||
@@ -513,7 +513,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn win_event_increments_games_won() {
|
fn win_event_increments_games_won() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 1000,
|
score: 1000,
|
||||||
time_seconds: 120,
|
time_seconds: 120,
|
||||||
});
|
});
|
||||||
@@ -532,7 +532,7 @@ mod tests {
|
|||||||
.0
|
.0
|
||||||
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 200,
|
time_seconds: 200,
|
||||||
});
|
});
|
||||||
@@ -553,7 +553,7 @@ mod tests {
|
|||||||
.move_count = 3;
|
.move_count = 3;
|
||||||
|
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(NewGameRequestEvent { seed: Some(999), mode: None });
|
.write_message(NewGameRequestEvent { seed: Some(999), mode: None });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let stats = &app.world().resource::<StatsResource>().0;
|
let stats = &app.world().resource::<StatsResource>().0;
|
||||||
@@ -566,7 +566,7 @@ mod tests {
|
|||||||
fn new_game_without_moves_does_not_record_abandoned() {
|
fn new_game_without_moves_does_not_record_abandoned() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(NewGameRequestEvent { seed: Some(42), mode: None });
|
.write_message(NewGameRequestEvent { seed: Some(42), mode: None });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let stats = &app.world().resource::<StatsResource>().0;
|
let stats = &app.world().resource::<StatsResource>().0;
|
||||||
@@ -781,10 +781,10 @@ mod tests {
|
|||||||
.0
|
.0
|
||||||
.move_count = 1;
|
.move_count = 1;
|
||||||
|
|
||||||
app.world_mut().send_event(ForfeitEvent);
|
app.world_mut().write_message(ForfeitEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
||||||
let mut reader = events.get_cursor();
|
let mut reader = events.get_cursor();
|
||||||
let messages: Vec<&str> = reader
|
let messages: Vec<&str> = reader
|
||||||
.read(events)
|
.read(events)
|
||||||
@@ -792,7 +792,7 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert!(
|
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:?}"
|
"expected 'Streak of 3 broken!' in toasts, got: {messages:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -810,10 +810,10 @@ mod tests {
|
|||||||
.0
|
.0
|
||||||
.move_count = 1;
|
.move_count = 1;
|
||||||
|
|
||||||
app.world_mut().send_event(ForfeitEvent);
|
app.world_mut().write_message(ForfeitEvent);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
||||||
let mut reader = events.get_cursor();
|
let mut reader = events.get_cursor();
|
||||||
let messages: Vec<&str> = reader
|
let messages: Vec<&str> = reader
|
||||||
.read(events)
|
.read(events)
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ impl Plugin for SyncPlugin {
|
|||||||
.init_resource::<SyncStatusResource>()
|
.init_resource::<SyncStatusResource>()
|
||||||
.init_resource::<PullTaskResult>()
|
.init_resource::<PullTaskResult>()
|
||||||
.init_resource::<PullTask>()
|
.init_resource::<PullTask>()
|
||||||
.add_event::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
.add_systems(Startup, start_pull)
|
.add_systems(Startup, start_pull)
|
||||||
.add_systems(Update, (poll_pull_result, handle_manual_sync_request))
|
.add_systems(Update, (poll_pull_result, handle_manual_sync_request))
|
||||||
.add_systems(Last, push_on_exit);
|
.add_systems(Last, push_on_exit);
|
||||||
@@ -121,7 +121,7 @@ fn start_pull(
|
|||||||
/// Update system: starts a new pull task when `ManualSyncRequestEvent` is
|
/// Update system: starts a new pull task when `ManualSyncRequestEvent` is
|
||||||
/// received, but only if no pull is already in flight.
|
/// received, but only if no pull is already in flight.
|
||||||
fn handle_manual_sync_request(
|
fn handle_manual_sync_request(
|
||||||
mut events: EventReader<ManualSyncRequestEvent>,
|
mut events: MessageReader<ManualSyncRequestEvent>,
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
mut task_res: ResMut<PullTask>,
|
mut task_res: ResMut<PullTask>,
|
||||||
mut status: ResMut<SyncStatusResource>,
|
mut status: ResMut<SyncStatusResource>,
|
||||||
@@ -217,7 +217,7 @@ fn poll_pull_result(
|
|||||||
/// that blocking on exit is permitted because the game loop is already
|
/// that blocking on exit is permitted because the game loop is already
|
||||||
/// shutting down.
|
/// shutting down.
|
||||||
fn push_on_exit(
|
fn push_on_exit(
|
||||||
mut exit_events: EventReader<AppExit>,
|
mut exit_events: MessageReader<AppExit>,
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
stats: Res<StatsResource>,
|
stats: Res<StatsResource>,
|
||||||
achievements: Res<AchievementsResource>,
|
achievements: Res<AchievementsResource>,
|
||||||
@@ -403,8 +403,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_payload_clones_stats() {
|
fn build_payload_clones_stats() {
|
||||||
let mut stats = StatsSnapshot::default();
|
let stats = StatsSnapshot { games_played: 42, ..Default::default() };
|
||||||
stats.games_played = 42;
|
|
||||||
let payload = build_payload(&stats, &[], &PlayerProgress::default());
|
let payload = build_payload(&stats, &[], &PlayerProgress::default());
|
||||||
assert_eq!(payload.stats.games_played, 42);
|
assert_eq!(payload.stats.games_played, 42);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use solitaire_core::card::Suit;
|
|||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
use solitaire_data::settings::Theme;
|
use solitaire_data::settings::Theme;
|
||||||
|
|
||||||
|
use crate::events::HintVisualEvent;
|
||||||
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
|
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
|
|
||||||
@@ -27,6 +28,17 @@ pub struct TableBackground;
|
|||||||
#[derive(Component, Debug, Clone)]
|
#[derive(Component, Debug, Clone)]
|
||||||
pub struct PileMarker(pub PileType);
|
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.
|
/// Registers the table background and pile-marker rendering.
|
||||||
pub struct TablePlugin;
|
pub struct TablePlugin;
|
||||||
|
|
||||||
@@ -35,10 +47,19 @@ impl Plugin for TablePlugin {
|
|||||||
// Register WindowResized so the plugin works under MinimalPlugins in
|
// Register WindowResized so the plugin works under MinimalPlugins in
|
||||||
// tests. Under DefaultPlugins, bevy_window has already registered it
|
// tests. Under DefaultPlugins, bevy_window has already registered it
|
||||||
// and this call is a no-op.
|
// and this call is a no-op.
|
||||||
app.add_event::<WindowResized>()
|
app.add_message::<WindowResized>()
|
||||||
.add_event::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
|
.add_message::<HintVisualEvent>()
|
||||||
.add_systems(Startup, setup_table)
|
.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(
|
fn apply_theme_on_settings_change(
|
||||||
mut events: EventReader<SettingsChangedEvent>,
|
mut events: MessageReader<SettingsChangedEvent>,
|
||||||
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
|
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
|
||||||
) {
|
) {
|
||||||
let Some(ev) = events.read().last() else {
|
let Some(ev) = events.read().last() else {
|
||||||
@@ -192,7 +213,7 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
|||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
fn on_window_resized(
|
fn on_window_resized(
|
||||||
mut events: EventReader<WindowResized>,
|
mut events: MessageReader<WindowResized>,
|
||||||
mut layout_res: Option<ResMut<LayoutResource>>,
|
mut layout_res: Option<ResMut<LayoutResource>>,
|
||||||
mut backgrounds: Query<
|
mut backgrounds: Query<
|
||||||
(&mut Sprite, &mut Transform),
|
(&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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -342,6 +416,76 @@ mod tests {
|
|||||||
assert_eq!(suit_symbol(&Suit::Clubs), "C");
|
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]
|
#[test]
|
||||||
fn suit_symbol_all_four_are_distinct() {
|
fn suit_symbol_all_four_are_distinct() {
|
||||||
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
|
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
|
||||||
|
|||||||
@@ -26,20 +26,21 @@ pub struct TimeAttackResource {
|
|||||||
|
|
||||||
/// Fired when the Time Attack timer expires. The summary toast in
|
/// Fired when the Time Attack timer expires. The summary toast in
|
||||||
/// `AnimationPlugin` consumes this; UI/stats consumers can also subscribe.
|
/// `AnimationPlugin` consumes this; UI/stats consumers can also subscribe.
|
||||||
#[derive(Event, Debug, Clone, Copy)]
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
pub struct TimeAttackEndedEvent {
|
pub struct TimeAttackEndedEvent {
|
||||||
pub wins: u32,
|
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;
|
pub struct TimeAttackPlugin;
|
||||||
|
|
||||||
impl Plugin for TimeAttackPlugin {
|
impl Plugin for TimeAttackPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<TimeAttackResource>()
|
app.init_resource::<TimeAttackResource>()
|
||||||
.add_event::<TimeAttackEndedEvent>()
|
.add_message::<TimeAttackEndedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_event::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
handle_start_time_attack_request.before(GameMutation),
|
handle_start_time_attack_request.before(GameMutation),
|
||||||
@@ -53,14 +54,14 @@ fn handle_start_time_attack_request(
|
|||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
progress: Res<ProgressResource>,
|
progress: Res<ProgressResource>,
|
||||||
mut session: ResMut<TimeAttackResource>,
|
mut session: ResMut<TimeAttackResource>,
|
||||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
mut info_toast: EventWriter<InfoToastEvent>,
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
if !keys.just_pressed(KeyCode::KeyT) {
|
if !keys.just_pressed(KeyCode::KeyT) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if progress.0.level < CHALLENGE_UNLOCK_LEVEL {
|
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}"
|
"Time Attack unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
|
||||||
)));
|
)));
|
||||||
return;
|
return;
|
||||||
@@ -70,7 +71,7 @@ fn handle_start_time_attack_request(
|
|||||||
remaining_secs: TIME_ATTACK_DURATION_SECS,
|
remaining_secs: TIME_ATTACK_DURATION_SECS,
|
||||||
wins: 0,
|
wins: 0,
|
||||||
};
|
};
|
||||||
new_game.send(NewGameRequestEvent {
|
new_game.write(NewGameRequestEvent {
|
||||||
seed: None,
|
seed: None,
|
||||||
mode: Some(GameMode::TimeAttack),
|
mode: Some(GameMode::TimeAttack),
|
||||||
});
|
});
|
||||||
@@ -79,7 +80,7 @@ fn handle_start_time_attack_request(
|
|||||||
fn advance_time_attack(
|
fn advance_time_attack(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
mut session: ResMut<TimeAttackResource>,
|
mut session: ResMut<TimeAttackResource>,
|
||||||
mut ended: EventWriter<TimeAttackEndedEvent>,
|
mut ended: MessageWriter<TimeAttackEndedEvent>,
|
||||||
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||||
) {
|
) {
|
||||||
if !session.active {
|
if !session.active {
|
||||||
@@ -93,22 +94,22 @@ fn advance_time_attack(
|
|||||||
let wins = session.wins;
|
let wins = session.wins;
|
||||||
session.active = false;
|
session.active = false;
|
||||||
session.remaining_secs = 0.0;
|
session.remaining_secs = 0.0;
|
||||||
ended.send(TimeAttackEndedEvent { wins });
|
ended.write(TimeAttackEndedEvent { wins });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auto_deal_on_time_attack_win(
|
fn auto_deal_on_time_attack_win(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: MessageReader<GameWonEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut session: ResMut<TimeAttackResource>,
|
mut session: ResMut<TimeAttackResource>,
|
||||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
) {
|
) {
|
||||||
for _ in wins.read() {
|
for _ in wins.read() {
|
||||||
if !session.active || game.0.mode != GameMode::TimeAttack {
|
if !session.active || game.0.mode != GameMode::TimeAttack {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
session.wins = session.wins.saturating_add(1);
|
session.wins = session.wins.saturating_add(1);
|
||||||
new_game.send(NewGameRequestEvent {
|
new_game.write(NewGameRequestEvent {
|
||||||
seed: None,
|
seed: None,
|
||||||
mode: Some(GameMode::TimeAttack),
|
mode: Some(GameMode::TimeAttack),
|
||||||
});
|
});
|
||||||
@@ -151,7 +152,7 @@ mod tests {
|
|||||||
let session = app.world().resource::<TimeAttackResource>();
|
let session = app.world().resource::<TimeAttackResource>();
|
||||||
assert!(!session.active);
|
assert!(!session.active);
|
||||||
|
|
||||||
let events = app.world().resource::<Events<NewGameRequestEvent>>();
|
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
assert!(cursor.read(events).next().is_none());
|
assert!(cursor.read(events).next().is_none());
|
||||||
}
|
}
|
||||||
@@ -169,7 +170,7 @@ mod tests {
|
|||||||
assert_eq!(session.wins, 0);
|
assert_eq!(session.wins, 0);
|
||||||
assert!((session.remaining_secs - TIME_ATTACK_DURATION_SECS).abs() < 1.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 mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
@@ -193,7 +194,7 @@ mod tests {
|
|||||||
assert!(!session.active);
|
assert!(!session.active);
|
||||||
assert_eq!(session.remaining_secs, 0.0);
|
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 mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
@@ -213,7 +214,7 @@ mod tests {
|
|||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
|
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 60,
|
time_seconds: 60,
|
||||||
});
|
});
|
||||||
@@ -222,7 +223,7 @@ mod tests {
|
|||||||
let session = app.world().resource::<TimeAttackResource>();
|
let session = app.world().resource::<TimeAttackResource>();
|
||||||
assert_eq!(session.wins, 1);
|
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 mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
@@ -237,7 +238,7 @@ mod tests {
|
|||||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
|
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 60,
|
time_seconds: 60,
|
||||||
});
|
});
|
||||||
@@ -256,7 +257,7 @@ mod tests {
|
|||||||
wins: 0,
|
wins: 0,
|
||||||
};
|
};
|
||||||
// GameStateResource defaults to Classic mode.
|
// GameStateResource defaults to Classic mode.
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 60,
|
time_seconds: 60,
|
||||||
});
|
});
|
||||||
@@ -286,7 +287,7 @@ mod tests {
|
|||||||
assert!(session.remaining_secs < 0.0, "remaining_secs must not change while paused");
|
assert!(session.remaining_secs < 0.0, "remaining_secs must not change while paused");
|
||||||
|
|
||||||
// No ended event must have been emitted.
|
// 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();
|
let mut cursor = events.get_cursor();
|
||||||
assert!(
|
assert!(
|
||||||
cursor.read(events).next().is_none(),
|
cursor.read(events).next().is_none(),
|
||||||
|
|||||||
@@ -15,19 +15,21 @@ use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath
|
|||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
|
||||||
/// Fired when the player has just completed a weekly goal.
|
/// Fired when the player has just completed a weekly goal.
|
||||||
#[derive(Event, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct WeeklyGoalCompletedEvent {
|
pub struct WeeklyGoalCompletedEvent {
|
||||||
pub goal_id: String,
|
pub goal_id: String,
|
||||||
pub description: 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;
|
pub struct WeeklyGoalsPlugin;
|
||||||
|
|
||||||
impl Plugin for WeeklyGoalsPlugin {
|
impl Plugin for WeeklyGoalsPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_event::<WeeklyGoalCompletedEvent>()
|
app.add_message::<WeeklyGoalCompletedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_event::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.add_systems(Startup, roll_weekly_goals_on_startup)
|
.add_systems(Startup, roll_weekly_goals_on_startup)
|
||||||
// Run after GameMutation (so GameWonEvent is available) and
|
// Run after GameMutation (so GameWonEvent is available) and
|
||||||
// ProgressUpdate (so we don't fight ProgressPlugin's add_xp).
|
// ProgressUpdate (so we don't fight ProgressPlugin's add_xp).
|
||||||
@@ -57,13 +59,13 @@ fn roll_weekly_goals_on_startup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn evaluate_weekly_goals(
|
fn evaluate_weekly_goals(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: MessageReader<GameWonEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
path: Res<ProgressStoragePath>,
|
path: Res<ProgressStoragePath>,
|
||||||
mut completions: EventWriter<WeeklyGoalCompletedEvent>,
|
mut completions: MessageWriter<WeeklyGoalCompletedEvent>,
|
||||||
mut levelups: EventWriter<LevelUpEvent>,
|
mut levelups: MessageWriter<LevelUpEvent>,
|
||||||
mut xp_awarded: EventWriter<XpAwardedEvent>,
|
mut xp_awarded: MessageWriter<XpAwardedEvent>,
|
||||||
) {
|
) {
|
||||||
let mut events: Vec<&GameWonEvent> = wins.read().collect();
|
let mut events: Vec<&GameWonEvent> = wins.read().collect();
|
||||||
if events.is_empty() {
|
if events.is_empty() {
|
||||||
@@ -92,7 +94,7 @@ fn evaluate_weekly_goals(
|
|||||||
any_change = true;
|
any_change = true;
|
||||||
if just_completed {
|
if just_completed {
|
||||||
bonus_xp = bonus_xp.saturating_add(WEEKLY_GOAL_XP);
|
bonus_xp = bonus_xp.saturating_add(WEEKLY_GOAL_XP);
|
||||||
completions.send(WeeklyGoalCompletedEvent {
|
completions.write(WeeklyGoalCompletedEvent {
|
||||||
goal_id: def.id.to_string(),
|
goal_id: def.id.to_string(),
|
||||||
description: def.description.to_string(),
|
description: def.description.to_string(),
|
||||||
});
|
});
|
||||||
@@ -101,10 +103,10 @@ fn evaluate_weekly_goals(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if bonus_xp > 0 {
|
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);
|
let prev_level = progress.0.add_xp(bonus_xp);
|
||||||
if progress.0.leveled_up_from(prev_level) {
|
if progress.0.leveled_up_from(prev_level) {
|
||||||
levelups.send(LevelUpEvent {
|
levelups.write(LevelUpEvent {
|
||||||
previous_level: prev_level,
|
previous_level: prev_level,
|
||||||
new_level: progress.0.level,
|
new_level: progress.0.level,
|
||||||
total_xp: progress.0.total_xp,
|
total_xp: progress.0.total_xp,
|
||||||
@@ -149,7 +151,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn first_win_increments_win_game_goal() {
|
fn first_win_increments_win_game_goal() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 200,
|
time_seconds: 200,
|
||||||
});
|
});
|
||||||
@@ -158,13 +160,13 @@ mod tests {
|
|||||||
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
|
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.
|
// 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_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]
|
#[test]
|
||||||
fn fast_win_ticks_fast_goal_too() {
|
fn fast_win_ticks_fast_goal_too() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 60,
|
time_seconds: 60,
|
||||||
});
|
});
|
||||||
@@ -181,14 +183,14 @@ mod tests {
|
|||||||
.0
|
.0
|
||||||
.undo_count = 1;
|
.undo_count = 1;
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 200,
|
time_seconds: 200,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
let p = &app.world().resource::<ProgressResource>().0;
|
let p = &app.world().resource::<ProgressResource>().0;
|
||||||
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
|
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]
|
#[test]
|
||||||
@@ -214,7 +216,7 @@ mod tests {
|
|||||||
|
|
||||||
let xp_before = app.world().resource::<ProgressResource>().0.total_xp;
|
let xp_before = app.world().resource::<ProgressResource>().0.total_xp;
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 60,
|
time_seconds: 60,
|
||||||
});
|
});
|
||||||
@@ -228,7 +230,7 @@ mod tests {
|
|||||||
let base_win_xp = solitaire_data::xp_for_win(60, false);
|
let base_win_xp = solitaire_data::xp_for_win(60, false);
|
||||||
assert_eq!(p.total_xp - xp_before, base_win_xp + WEEKLY_GOAL_XP);
|
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 mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).cloned().collect();
|
let fired: Vec<_> = cursor.read(events).cloned().collect();
|
||||||
assert!(fired.iter().any(|e| e.goal_id == "weekly_3_fast"));
|
assert!(fired.iter().any(|e| e.goal_id == "weekly_3_fast"));
|
||||||
@@ -280,13 +282,13 @@ mod tests {
|
|||||||
.0
|
.0
|
||||||
.weekly_goal_week_iso = Some(key);
|
.weekly_goal_week_iso = Some(key);
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 500,
|
score: 500,
|
||||||
time_seconds: 60,
|
time_seconds: 60,
|
||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app.world().resource::<Events<LevelUpEvent>>();
|
let events = app.world().resource::<Messages<LevelUpEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
assert!(!fired.is_empty(), "LevelUpEvent must fire when weekly bonus pushes past a level threshold");
|
assert!(!fired.is_empty(), "LevelUpEvent must fire when weekly bonus pushes past a level threshold");
|
||||||
|
|||||||
@@ -159,11 +159,11 @@ impl Plugin for WinSummaryPlugin {
|
|||||||
app.init_resource::<WinSummaryPending>()
|
app.init_resource::<WinSummaryPending>()
|
||||||
.init_resource::<ScreenShakeResource>()
|
.init_resource::<ScreenShakeResource>()
|
||||||
.init_resource::<SessionAchievements>()
|
.init_resource::<SessionAchievements>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_event::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_event::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_event::<AchievementUnlockedEvent>()
|
.add_message::<AchievementUnlockedEvent>()
|
||||||
// `cache_win_data` must run BEFORE `StatsUpdate` so it can compare
|
// `cache_win_data` must run BEFORE `StatsUpdate` so it can compare
|
||||||
// the player's old personal-best values before `StatsPlugin` overwrites them.
|
// the player's old personal-best values before `StatsPlugin` overwrites them.
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@@ -221,13 +221,13 @@ pub fn format_win_time(seconds: u64) -> String {
|
|||||||
/// This system is scheduled `.before(StatsUpdate)` so the comparison always
|
/// This system is scheduled `.before(StatsUpdate)` so the comparison always
|
||||||
/// sees the old best values.
|
/// sees the old best values.
|
||||||
fn cache_win_data(
|
fn cache_win_data(
|
||||||
mut won: EventReader<GameWonEvent>,
|
mut won: MessageReader<GameWonEvent>,
|
||||||
mut xp: EventReader<XpAwardedEvent>,
|
mut xp: MessageReader<XpAwardedEvent>,
|
||||||
mut pending: ResMut<WinSummaryPending>,
|
mut pending: ResMut<WinSummaryPending>,
|
||||||
stats: Res<StatsResource>,
|
stats: Res<StatsResource>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
progress: Res<ProgressResource>,
|
progress: Res<ProgressResource>,
|
||||||
mut toast: EventWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
for ev in won.read() {
|
for ev in won.read() {
|
||||||
// Compare against old personal bests BEFORE StatsPlugin updates them.
|
// Compare against old personal bests BEFORE StatsPlugin updates them.
|
||||||
@@ -255,7 +255,7 @@ fn cache_win_data(
|
|||||||
pending.challenge_level = challenge_level;
|
pending.challenge_level = challenge_level;
|
||||||
|
|
||||||
if is_new_record {
|
if is_new_record {
|
||||||
toast.send(InfoToastEvent("New Record!".to_string()));
|
toast.write(InfoToastEvent("New Record!".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for ev in xp.read() {
|
for ev in xp.read() {
|
||||||
@@ -274,8 +274,8 @@ fn cache_win_data(
|
|||||||
/// reader covers every implicit game-context reset in addition to the
|
/// reader covers every implicit game-context reset in addition to the
|
||||||
/// explicit N / "Play Again" new-game requests.
|
/// explicit N / "Play Again" new-game requests.
|
||||||
fn collect_session_achievements(
|
fn collect_session_achievements(
|
||||||
mut unlocks: EventReader<AchievementUnlockedEvent>,
|
mut unlocks: MessageReader<AchievementUnlockedEvent>,
|
||||||
mut new_games: EventReader<NewGameRequestEvent>,
|
mut new_games: MessageReader<NewGameRequestEvent>,
|
||||||
mut session: ResMut<SessionAchievements>,
|
mut session: ResMut<SessionAchievements>,
|
||||||
) {
|
) {
|
||||||
// Reset on any new-game request (including mode switches via Z/X/C/T) so
|
// 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn spawn_win_summary_after_delay(
|
fn spawn_win_summary_after_delay(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut won: EventReader<GameWonEvent>,
|
mut won: MessageReader<GameWonEvent>,
|
||||||
mut xp_events: EventReader<XpAwardedEvent>,
|
mut xp_events: MessageReader<XpAwardedEvent>,
|
||||||
mut shake: ResMut<ScreenShakeResource>,
|
mut shake: ResMut<ScreenShakeResource>,
|
||||||
mut pending: ResMut<WinSummaryPending>,
|
mut pending: ResMut<WinSummaryPending>,
|
||||||
session: Res<SessionAchievements>,
|
session: Res<SessionAchievements>,
|
||||||
@@ -321,7 +321,7 @@ fn spawn_win_summary_after_delay(
|
|||||||
*delay = Some(WIN_SUMMARY_DELAY_SECS);
|
*delay = Some(WIN_SUMMARY_DELAY_SECS);
|
||||||
// Clear any stale overlay from a previous win.
|
// Clear any stale overlay from a previous win.
|
||||||
for entity in &overlays {
|
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>>,
|
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
|
||||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
) {
|
) {
|
||||||
for (interaction, button) in &interaction_query {
|
for (interaction, button) in &interaction_query {
|
||||||
if *interaction != Interaction::Pressed {
|
if *interaction != Interaction::Pressed {
|
||||||
@@ -362,9 +362,9 @@ fn handle_win_summary_buttons(
|
|||||||
WinSummaryButton::PlayAgain => {
|
WinSummaryButton::PlayAgain => {
|
||||||
// Despawn the modal.
|
// Despawn the modal.
|
||||||
for entity in &overlays {
|
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),
|
row_gap: Val::Px(18.0),
|
||||||
min_width: Val::Px(320.0),
|
min_width: Val::Px(320.0),
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(12.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.10, 0.12, 0.10)),
|
BackgroundColor(Color::srgb(0.10, 0.12, 0.10)),
|
||||||
BorderRadius::all(Val::Px(12.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|card| {
|
.with_children(|card| {
|
||||||
// Heading
|
// Heading
|
||||||
@@ -518,10 +518,10 @@ fn spawn_overlay(
|
|||||||
padding: UiRect::axes(Val::Px(28.0), Val::Px(12.0)),
|
padding: UiRect::axes(Val::Px(28.0), Val::Px(12.0)),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
margin: UiRect::top(Val::Px(8.0)),
|
margin: UiRect::top(Val::Px(8.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(6.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
|
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
|
||||||
BorderRadius::all(Val::Px(6.0)),
|
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -543,7 +543,7 @@ const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
|
|||||||
/// Shows at most [`MAX_ACHIEVEMENTS_SHOWN`] names. When more achievements were
|
/// 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
|
/// unlocked than the cap, appends a "...and N more" line so the player knows
|
||||||
/// there are additional unlocks visible on the achievements screen.
|
/// 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((
|
card.spawn((
|
||||||
Text::new("Achievements Unlocked"),
|
Text::new("Achievements Unlocked"),
|
||||||
TextFont { font_size: 18.0, ..default() },
|
TextFont { font_size: 18.0, ..default() },
|
||||||
@@ -677,7 +677,7 @@ mod tests {
|
|||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
let record = AchievementRecord::locked("first_win");
|
let record = AchievementRecord::locked("first_win");
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(AchievementUnlockedEvent(record));
|
.write_message(AchievementUnlockedEvent(record));
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let session = app.world().resource::<SessionAchievements>();
|
let session = app.world().resource::<SessionAchievements>();
|
||||||
@@ -693,7 +693,7 @@ mod tests {
|
|||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
let record = AchievementRecord::locked("first_win");
|
let record = AchievementRecord::locked("first_win");
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(AchievementUnlockedEvent(record));
|
.write_message(AchievementUnlockedEvent(record));
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// Confirm it was recorded.
|
// Confirm it was recorded.
|
||||||
@@ -703,7 +703,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Fire NewGameRequestEvent — should clear the list.
|
// Fire NewGameRequestEvent — should clear the list.
|
||||||
app.world_mut().send_event(NewGameRequestEvent::default());
|
app.world_mut().write_message(NewGameRequestEvent::default());
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -727,7 +727,7 @@ mod tests {
|
|||||||
// Simulate an achievement unlock during the current session.
|
// Simulate an achievement unlock during the current session.
|
||||||
let record = AchievementRecord::locked("first_win");
|
let record = AchievementRecord::locked("first_win");
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(AchievementUnlockedEvent(record));
|
.write_message(AchievementUnlockedEvent(record));
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -739,7 +739,7 @@ mod tests {
|
|||||||
// Simulate pressing Z (Zen mode switch) — fires NewGameRequestEvent
|
// Simulate pressing Z (Zen mode switch) — fires NewGameRequestEvent
|
||||||
// with mode = Some(Zen). Same event shape used by X (Challenge),
|
// with mode = Some(Zen). Same event shape used by X (Challenge),
|
||||||
// C (Daily Challenge), and T (Time Attack).
|
// C (Daily Challenge), and T (Time Attack).
|
||||||
app.world_mut().send_event(NewGameRequestEvent {
|
app.world_mut().write_message(NewGameRequestEvent {
|
||||||
seed: None,
|
seed: None,
|
||||||
mode: Some(GameMode::Zen),
|
mode: Some(GameMode::Zen),
|
||||||
});
|
});
|
||||||
@@ -756,7 +756,7 @@ mod tests {
|
|||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
|
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(GameWonEvent { score: 1234, time_seconds: 90 });
|
.write_message(GameWonEvent { score: 1234, time_seconds: 90 });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let pending = app.world().resource::<WinSummaryPending>();
|
let pending = app.world().resource::<WinSummaryPending>();
|
||||||
@@ -771,8 +771,8 @@ mod tests {
|
|||||||
fn cache_win_data_sets_xp_from_xp_awarded_event() {
|
fn cache_win_data_sets_xp_from_xp_awarded_event() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
app.world_mut().write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
app.world_mut().send_event(XpAwardedEvent { amount: 75 });
|
app.world_mut().write_message(XpAwardedEvent { amount: 75 });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let pending = app.world().resource::<WinSummaryPending>();
|
let pending = app.world().resource::<WinSummaryPending>();
|
||||||
@@ -784,7 +784,7 @@ mod tests {
|
|||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
|
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let shake = app.world().resource::<ScreenShakeResource>();
|
let shake = app.world().resource::<ScreenShakeResource>();
|
||||||
@@ -802,7 +802,7 @@ mod tests {
|
|||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
|
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(GameWonEvent { score: 500, time_seconds: 120 });
|
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let pending = app.world().resource::<WinSummaryPending>();
|
let pending = app.world().resource::<WinSummaryPending>();
|
||||||
@@ -820,7 +820,7 @@ mod tests {
|
|||||||
|
|
||||||
// Score 500 beats previous best of 400.
|
// Score 500 beats previous best of 400.
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(GameWonEvent { score: 500, time_seconds: 300 });
|
.write_message(GameWonEvent { score: 500, time_seconds: 300 });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let pending = app.world().resource::<WinSummaryPending>();
|
let pending = app.world().resource::<WinSummaryPending>();
|
||||||
@@ -838,7 +838,7 @@ mod tests {
|
|||||||
|
|
||||||
// Score 500 does not beat 800, but time 100 < 200.
|
// Score 500 does not beat 800, but time 100 < 200.
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(GameWonEvent { score: 500, time_seconds: 100 });
|
.write_message(GameWonEvent { score: 500, time_seconds: 100 });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let pending = app.world().resource::<WinSummaryPending>();
|
let pending = app.world().resource::<WinSummaryPending>();
|
||||||
@@ -856,7 +856,7 @@ mod tests {
|
|||||||
|
|
||||||
// Score 500 < 800 and time 120 > 60 — neither record broken.
|
// Score 500 < 800 and time 120 > 60 — neither record broken.
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(GameWonEvent { score: 500, time_seconds: 120 });
|
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let pending = app.world().resource::<WinSummaryPending>();
|
let pending = app.world().resource::<WinSummaryPending>();
|
||||||
@@ -887,7 +887,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let pending = app.world().resource::<WinSummaryPending>();
|
let pending = app.world().resource::<WinSummaryPending>();
|
||||||
@@ -903,7 +903,7 @@ mod tests {
|
|||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
// Default game mode is Classic — challenge_level should stay None.
|
// Default game mode is Classic — challenge_level should stay None.
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let pending = app.world().resource::<WinSummaryPending>();
|
let pending = app.world().resource::<WinSummaryPending>();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solitaire_gpgs"
|
name = "solitaire_gpgs"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use solitaire_data::{SyncError, SyncProvider};
|
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.
|
/// Google Play Games Services sync client — desktop/iOS stub.
|
||||||
///
|
///
|
||||||
@@ -38,4 +38,34 @@ impl SyncProvider for GpgsClient {
|
|||||||
fn is_authenticated(&self) -> bool {
|
fn is_authenticated(&self) -> bool {
|
||||||
false
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solitaire_server"
|
name = "solitaire_server"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -29,8 +30,4 @@ tracing-subscriber = { workspace = true }
|
|||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
solitaire_sync = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
chrono = { workspace = true }
|
|
||||||
jsonwebtoken = { workspace = true }
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ use bcrypt::{hash, verify};
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::AppError,
|
error::AppError,
|
||||||
middleware::{validate_refresh_token, AuthenticatedUser, Claims},
|
middleware::{validate_refresh_token, AuthenticatedUser, Claims},
|
||||||
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -59,7 +59,7 @@ struct UserRow {
|
|||||||
// bcrypt cost used for password hashing
|
// 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;
|
const BCRYPT_COST: u32 = 12;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -107,7 +107,7 @@ fn username_chars_ok(s: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<AuthRequest>,
|
Json(body): Json<AuthRequest>,
|
||||||
) -> Result<Json<AuthResponse>, AppError> {
|
) -> Result<Json<AuthResponse>, AppError> {
|
||||||
// Validate username: 3–32 characters, alphanumeric + underscores only.
|
// Validate username: 3–32 characters, alphanumeric + underscores only.
|
||||||
@@ -137,11 +137,12 @@ pub async fn register(
|
|||||||
"SELECT id FROM users WHERE username = ?",
|
"SELECT id FROM users WHERE username = ?",
|
||||||
username
|
username
|
||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
.flatten();
|
.flatten();
|
||||||
|
|
||||||
if existing.is_some() {
|
if existing.is_some() {
|
||||||
|
tracing::warn!(username = %username, "register: username already taken");
|
||||||
return Err(AppError::UsernameTaken);
|
return Err(AppError::UsernameTaken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,21 +157,18 @@ pub async fn register(
|
|||||||
password_hash,
|
password_hash,
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let secret = std::env::var("JWT_SECRET")
|
|
||||||
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
access_token: make_access_token(&user_id, &secret)?,
|
access_token: make_access_token(&user_id, &state.jwt_secret)?,
|
||||||
refresh_token: make_refresh_token(&user_id, &secret)?,
|
refresh_token: make_refresh_token(&user_id, &state.jwt_secret)?,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/auth/login` — verify credentials and return tokens.
|
/// `POST /api/auth/login` — verify credentials and return tokens.
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<AuthRequest>,
|
Json(body): Json<AuthRequest>,
|
||||||
) -> Result<Json<AuthResponse>, AppError> {
|
) -> Result<Json<AuthResponse>, AppError> {
|
||||||
let username = body.username.trim().to_string();
|
let username = body.username.trim().to_string();
|
||||||
@@ -179,7 +177,7 @@ pub async fn login(
|
|||||||
"SELECT id, password_hash FROM users WHERE username = ?",
|
"SELECT id, password_hash FROM users WHERE username = ?",
|
||||||
username
|
username
|
||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let row = row.ok_or(AppError::InvalidCredentials)?;
|
let row = row.ok_or(AppError::InvalidCredentials)?;
|
||||||
@@ -188,29 +186,25 @@ pub async fn login(
|
|||||||
|
|
||||||
let valid = verify(&body.password, &row_hash)?;
|
let valid = verify(&body.password, &row_hash)?;
|
||||||
if !valid {
|
if !valid {
|
||||||
|
tracing::warn!(username = %username, "login: invalid password");
|
||||||
return Err(AppError::InvalidCredentials);
|
return Err(AppError::InvalidCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
let secret = std::env::var("JWT_SECRET")
|
|
||||||
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
access_token: make_access_token(&row_id, &secret)?,
|
access_token: make_access_token(&row_id, &state.jwt_secret)?,
|
||||||
refresh_token: make_refresh_token(&row_id, &secret)?,
|
refresh_token: make_refresh_token(&row_id, &state.jwt_secret)?,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/auth/refresh` — exchange a refresh token for a new access token.
|
/// `POST /api/auth/refresh` — exchange a refresh token for a new access token.
|
||||||
pub async fn refresh(
|
pub async fn refresh(
|
||||||
|
State(state): State<AppState>,
|
||||||
Json(body): Json<RefreshRequest>,
|
Json(body): Json<RefreshRequest>,
|
||||||
) -> Result<Json<RefreshResponse>, AppError> {
|
) -> Result<Json<RefreshResponse>, AppError> {
|
||||||
let secret = std::env::var("JWT_SECRET")
|
let claims = validate_refresh_token(&body.refresh_token, &state.jwt_secret)?;
|
||||||
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
|
||||||
|
|
||||||
let claims = validate_refresh_token(&body.refresh_token, &secret)?;
|
|
||||||
|
|
||||||
Ok(Json(RefreshResponse {
|
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.
|
/// All related rows are removed via `ON DELETE CASCADE` in the schema.
|
||||||
pub async fn delete_account(
|
pub async fn delete_account(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
) -> Result<Json<serde_json::Value>, AppError> {
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
sqlx::query!("DELETE FROM users WHERE id = ?", user.user_id)
|
sqlx::query!("DELETE FROM users WHERE id = ?", user.user_id)
|
||||||
.execute(&pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
|
|||||||
@@ -8,11 +8,10 @@
|
|||||||
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sqlx::SqlitePool;
|
|
||||||
|
|
||||||
use solitaire_sync::ChallengeGoal;
|
use solitaire_sync::ChallengeGoal;
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::{error::AppError, AppState};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Seed generation
|
// Seed generation
|
||||||
@@ -97,18 +96,22 @@ struct ChallengeRow {
|
|||||||
///
|
///
|
||||||
/// Looks up today's challenge in the database. If none exists yet, generates
|
/// Looks up today's challenge in the database. If none exists yet, generates
|
||||||
/// one deterministically and stores it before returning.
|
/// 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(
|
pub async fn daily_challenge(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<ChallengeGoal>, AppError> {
|
) -> Result<Json<ChallengeGoal>, AppError> {
|
||||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
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!(
|
let row = sqlx::query_as!(
|
||||||
ChallengeRow,
|
ChallengeRow,
|
||||||
"SELECT goal_json FROM daily_challenges WHERE date = ?",
|
"SELECT goal_json FROM daily_challenges WHERE date = ?",
|
||||||
today
|
today
|
||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(r) = row {
|
if let Some(r) = row {
|
||||||
@@ -117,7 +120,10 @@ pub async fn daily_challenge(
|
|||||||
return Ok(Json(goal));
|
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 seed = hash_date_to_u64(&today);
|
||||||
let goal = generate_goal(&today, seed);
|
let goal = generate_goal(&today, seed);
|
||||||
let goal_json = serde_json::to_string(&goal)?;
|
let goal_json = serde_json::to_string(&goal)?;
|
||||||
@@ -129,10 +135,22 @@ pub async fn daily_challenge(
|
|||||||
seed_i64,
|
seed_i64,
|
||||||
goal_json
|
goal_json
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -6,11 +6,10 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::SqlitePool;
|
|
||||||
|
|
||||||
use solitaire_sync::LeaderboardEntry;
|
use solitaire_sync::LeaderboardEntry;
|
||||||
|
|
||||||
use crate::{error::AppError, middleware::AuthenticatedUser};
|
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Request shapes
|
// Request shapes
|
||||||
@@ -42,7 +41,7 @@ struct LeaderboardRow {
|
|||||||
///
|
///
|
||||||
/// Returns entries sorted by `best_score` descending (nulls last).
|
/// Returns entries sorted by `best_score` descending (nulls last).
|
||||||
pub async fn get_leaderboard(
|
pub async fn get_leaderboard(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
_user: AuthenticatedUser,
|
_user: AuthenticatedUser,
|
||||||
) -> Result<Json<Vec<LeaderboardEntry>>, AppError> {
|
) -> Result<Json<Vec<LeaderboardEntry>>, AppError> {
|
||||||
let rows = sqlx::query_as!(
|
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,
|
CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,
|
||||||
l.best_time_secs ASC"#
|
l.best_time_secs ASC"#
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let entries: Result<Vec<LeaderboardEntry>, AppError> = rows
|
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
|
/// appears in `GET /api/leaderboard`. The leaderboard row itself is kept
|
||||||
/// so scores are preserved if the player opts back in later.
|
/// so scores are preserved if the player opts back in later.
|
||||||
pub async fn opt_out(
|
pub async fn opt_out(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
) -> Result<Json<serde_json::Value>, AppError> {
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
|
"UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
|
||||||
user.user_id
|
user.user_id
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
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.
|
/// `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
|
/// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the
|
||||||
/// leaderboard entry with the supplied display name.
|
/// 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(
|
pub async fn opt_in(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
Json(body): Json<OptInRequest>,
|
Json(body): Json<OptInRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, AppError> {
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
@@ -131,7 +131,7 @@ pub async fn opt_in(
|
|||||||
"UPDATE users SET leaderboard_opt_in = 1 WHERE id = ?",
|
"UPDATE users SET leaderboard_opt_in = 1 WHERE id = ?",
|
||||||
user.user_id
|
user.user_id
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
@@ -147,8 +147,76 @@ pub async fn opt_in(
|
|||||||
display_name,
|
display_name,
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,23 +21,41 @@ use sqlx::SqlitePool;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
|
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`].
|
/// Construct the full Axum [`Router`].
|
||||||
///
|
///
|
||||||
/// Separated from `main` so it can be instantiated in integration tests without
|
/// Separated from `main` so it can be instantiated in integration tests without
|
||||||
/// starting a real TCP listener.
|
/// starting a real TCP listener.
|
||||||
pub fn build_router(pool: SqlitePool) -> Router {
|
pub fn build_router(state: AppState) -> Router {
|
||||||
build_router_inner(pool, true)
|
build_router_inner(state, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construct the router without rate limiting.
|
/// Construct the router without rate limiting.
|
||||||
///
|
///
|
||||||
/// Intended for integration tests only — do not use in production.
|
/// 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)]
|
#[doc(hidden)]
|
||||||
pub fn build_test_router(pool: SqlitePool) -> Router {
|
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).
|
// Protected routes require a valid JWT (injected by require_auth middleware).
|
||||||
let protected = Router::new()
|
let protected = Router::new()
|
||||||
.route("/api/sync/pull", get(sync::pull))
|
.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", post(leaderboard::opt_in))
|
||||||
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
|
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
|
||||||
.route("/api/account", delete(auth::delete_account))
|
.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.
|
// Auth endpoints — rate-limited in production, unrestricted in tests.
|
||||||
let auth_routes = Router::new()
|
let auth_routes = Router::new()
|
||||||
@@ -64,9 +85,7 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
|
|||||||
.finish()
|
.finish()
|
||||||
.expect("invalid governor config"),
|
.expect("invalid governor config"),
|
||||||
);
|
);
|
||||||
auth_routes.layer(GovernorLayer {
|
auth_routes.layer(GovernorLayer::new(governor_conf))
|
||||||
config: governor_conf,
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
auth_routes
|
auth_routes
|
||||||
};
|
};
|
||||||
@@ -82,7 +101,7 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
|
|||||||
.merge(public)
|
.merge(public)
|
||||||
// Reject request bodies larger than 1 MB.
|
// Reject request bodies larger than 1 MB.
|
||||||
.layer(DefaultBodyLimit::max(1024 * 1024))
|
.layer(DefaultBodyLimit::max(1024 * 1024))
|
||||||
.with_state(pool)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /health` — simple liveness probe, no auth required.
|
/// `GET /health` — simple liveness probe, no auth required.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
//! |---------------|---------|-------------------------------|
|
//! |---------------|---------|-------------------------------|
|
||||||
//! | `SERVER_PORT` | `8080` | TCP port to listen on |
|
//! | `SERVER_PORT` | `8080` | TCP port to listen on |
|
||||||
|
|
||||||
use solitaire_server::build_router;
|
use solitaire_server::{build_router, AppState};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
@@ -29,6 +29,8 @@ async fn main() {
|
|||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
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")
|
let port: u16 = std::env::var("SERVER_PORT")
|
||||||
.unwrap_or_else(|_| "8080".into())
|
.unwrap_or_else(|_| "8080".into())
|
||||||
.parse()
|
.parse()
|
||||||
@@ -46,7 +48,8 @@ async fn main() {
|
|||||||
|
|
||||||
tracing::info!("database ready at {db_url}");
|
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));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
tracing::info!("listening on {addr}");
|
tracing::info!("listening on {addr}");
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! can access it via `Extension<AuthenticatedUser>`.
|
//! can access it via `Extension<AuthenticatedUser>`.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{FromRequestParts, Request},
|
extract::{FromRequestParts, Request, State},
|
||||||
http::request::Parts,
|
http::request::Parts,
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::Response,
|
response::Response,
|
||||||
@@ -13,7 +13,7 @@ use axum::{
|
|||||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::{error::AppError, AppState};
|
||||||
|
|
||||||
/// The claims encoded in our JWT access tokens.
|
/// The claims encoded in our JWT access tokens.
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -37,18 +37,19 @@ pub struct AuthenticatedUser {
|
|||||||
/// Axum middleware function that validates the Bearer JWT and injects
|
/// Axum middleware function that validates the Bearer JWT and injects
|
||||||
/// [`AuthenticatedUser`] into request extensions.
|
/// [`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.
|
/// Returns `401 Unauthorized` if the token is missing, expired, or invalid.
|
||||||
pub async fn require_auth(
|
pub async fn require_auth(
|
||||||
|
State(state): State<AppState>,
|
||||||
mut req: Request,
|
mut req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, AppError> {
|
) -> 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())
|
let token = extract_bearer_token(req.headers())
|
||||||
.ok_or(AppError::Unauthorized)?;
|
.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 {
|
req.extensions_mut().insert(AuthenticatedUser {
|
||||||
user_id: claims.sub,
|
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
|
// 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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -221,19 +237,3 @@ mod tests {
|
|||||||
assert!(result.is_err(), "expired refresh token must be rejected");
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use solitaire_sync::{
|
|||||||
merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse,
|
merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{error::AppError, middleware::AuthenticatedUser};
|
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Database row helpers
|
// Database row helpers
|
||||||
@@ -99,10 +99,10 @@ async fn store_payload(
|
|||||||
///
|
///
|
||||||
/// If the user has never pushed any data, returns a default payload.
|
/// If the user has never pushed any data, returns a default payload.
|
||||||
pub async fn pull(
|
pub async fn pull(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
) -> Result<Json<SyncResponse>, AppError> {
|
) -> 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)?,
|
Some(row) => row_to_payload(&row, &user.user_id)?,
|
||||||
None => {
|
None => {
|
||||||
// First pull — no server data yet; return an empty default payload.
|
// 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
|
/// updated with the merged `best_single_score` and `fastest_win_seconds` so
|
||||||
/// scores stay in sync without a separate submission step.
|
/// scores stay in sync without a separate submission step.
|
||||||
pub async fn push(
|
pub async fn push(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
Json(client_payload): Json<SyncPayload>,
|
Json(client_payload): Json<SyncPayload>,
|
||||||
) -> Result<Json<SyncResponse>, AppError> {
|
) -> Result<Json<SyncResponse>, AppError> {
|
||||||
@@ -143,12 +143,12 @@ pub async fn push(
|
|||||||
return Err(AppError::BadRequest("user_id mismatch".into()));
|
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)?,
|
Some(row) => row_to_payload(&row, &user.user_id)?,
|
||||||
None => {
|
None => {
|
||||||
// First push — nothing to merge against; store directly.
|
// First push — nothing to merge against; store directly.
|
||||||
store_payload(&pool, &user.user_id, &client_payload).await?;
|
store_payload(&state.pool, &user.user_id, &client_payload).await?;
|
||||||
update_leaderboard_if_opted_in(&pool, &user.user_id, &client_payload).await?;
|
update_leaderboard_if_opted_in(&state.pool, &user.user_id, &client_payload).await?;
|
||||||
return Ok(Json(SyncResponse {
|
return Ok(Json(SyncResponse {
|
||||||
merged: client_payload,
|
merged: client_payload,
|
||||||
server_time: Utc::now(),
|
server_time: Utc::now(),
|
||||||
@@ -159,8 +159,8 @@ pub async fn push(
|
|||||||
|
|
||||||
let (merged, conflicts) = merge(&client_payload, &server_payload);
|
let (merged, conflicts) = merge(&client_payload, &server_payload);
|
||||||
|
|
||||||
store_payload(&pool, &user.user_id, &merged).await?;
|
store_payload(&state.pool, &user.user_id, &merged).await?;
|
||||||
update_leaderboard_if_opted_in(&pool, &user.user_id, &merged).await?;
|
update_leaderboard_if_opted_in(&state.pool, &user.user_id, &merged).await?;
|
||||||
|
|
||||||
Ok(Json(SyncResponse {
|
Ok(Json(SyncResponse {
|
||||||
merged,
|
merged,
|
||||||
@@ -220,3 +220,118 @@ async fn update_leaderboard_if_opted_in(
|
|||||||
|
|
||||||
Ok(())
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
//!
|
//!
|
||||||
//! # JWT secret
|
//! # JWT secret
|
||||||
//!
|
//!
|
||||||
//! Each test calls [`set_jwt_secret`] before touching any endpoint that reads
|
//! [`build_test_router`] injects a fixed test secret into [`AppState`] so
|
||||||
//! `JWT_SECRET` from the environment. This is safe because `cargo test` runs
|
//! tests do not need to set `JWT_SECRET` in the environment. The constant
|
||||||
//! integration-test binaries single-threaded by default.
|
//! [`TEST_SECRET`] must match the value used by [`build_test_router`] so that
|
||||||
|
//! test-side token decoding works correctly.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
@@ -28,7 +29,9 @@ use tower::ServiceExt;
|
|||||||
// Constants
|
// 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!";
|
const TEST_SECRET: &str = "test_secret_32_chars_minimum_ok!";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -53,15 +56,6 @@ async fn test_pool() -> SqlitePool {
|
|||||||
pool
|
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
|
/// Fake client IP injected by all test requests so `tower_governor`'s
|
||||||
/// `SmartIpKeyExtractor` can extract a key without a real peer address.
|
/// `SmartIpKeyExtractor` can extract a key without a real peer address.
|
||||||
const TEST_CLIENT_IP: &str = "127.0.0.1";
|
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.
|
/// `POST /api/auth/register` must return 200 with both tokens.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn register_creates_account_and_returns_tokens() {
|
async fn register_creates_account_and_returns_tokens() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let resp = post_json(
|
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.
|
/// Registering the same username twice must return 409 Conflict on the second attempt.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn register_duplicate_username_returns_conflict() {
|
async fn register_duplicate_username_returns_conflict() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let creds = serde_json::json!({ "username": "bob", "password": "s3cr3t!!" });
|
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.
|
/// Short username (< 3 chars) is rejected with 400.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn register_rejects_short_username() {
|
async fn register_rejects_short_username() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let resp = post_json(
|
let resp = post_json(
|
||||||
app,
|
app,
|
||||||
@@ -261,7 +255,7 @@ async fn register_rejects_short_username() {
|
|||||||
/// Username with disallowed characters is rejected with 400.
|
/// Username with disallowed characters is rejected with 400.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn register_rejects_invalid_username_chars() {
|
async fn register_rejects_invalid_username_chars() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let resp = post_json(
|
let resp = post_json(
|
||||||
app,
|
app,
|
||||||
@@ -275,7 +269,7 @@ async fn register_rejects_invalid_username_chars() {
|
|||||||
/// Password shorter than 8 characters is rejected with 400.
|
/// Password shorter than 8 characters is rejected with 400.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn register_rejects_short_password() {
|
async fn register_rejects_short_password() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let resp = post_json(
|
let resp = post_json(
|
||||||
app,
|
app,
|
||||||
@@ -289,7 +283,7 @@ async fn register_rejects_short_password() {
|
|||||||
/// `POST /api/auth/login` with correct credentials returns 200 with both tokens.
|
/// `POST /api/auth/login` with correct credentials returns 200 with both tokens.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_with_correct_credentials_returns_tokens() {
|
async fn login_with_correct_credentials_returns_tokens() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
// Register first.
|
// 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.
|
/// `POST /api/auth/login` with a wrong password must return 401.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_with_wrong_password_returns_401() {
|
async fn login_with_wrong_password_returns_401() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
// Register a user.
|
// 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.
|
/// `POST /api/auth/login` for a username that does not exist must return 401.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_with_unknown_username_returns_401() {
|
async fn login_with_unknown_username_returns_401() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let resp = post_json(
|
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.
|
/// `POST /api/auth/refresh` with a valid refresh token returns 200 with a new access token.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn refresh_returns_new_access_token() {
|
async fn refresh_returns_new_access_token() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (_access, refresh) = register_user(app.clone(), "eve", "refresh_me").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"`.
|
/// the `kind` claim will be `"access"`, not `"refresh"`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn refresh_with_access_token_returns_401() {
|
async fn refresh_with_access_token_returns_401() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _refresh) = register_user(app.clone(), "frank", "bad_refresh").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.
|
/// Push a payload, then pull — the pulled data must reflect the pushed values.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn push_then_pull_returns_pushed_data() {
|
async fn push_then_pull_returns_pushed_data() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "grace", "sync_pass").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");
|
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.
|
/// Pushing a payload whose `user_id` does not match the JWT `sub` must return 400.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn push_with_wrong_user_id_returns_400() {
|
async fn push_with_wrong_user_id_returns_400() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "heidi", "sync_pass").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).
|
/// A pull before any push returns a default empty payload (200, not 404).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pull_before_push_returns_default_payload() {
|
async fn pull_before_push_returns_default_payload() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "ivan", "nopush!!").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.
|
/// Accessing `/api/sync/pull` without a token must return 401.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pull_without_token_returns_401() {
|
async fn pull_without_token_returns_401() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
@@ -517,7 +628,7 @@ async fn pull_without_token_returns_401() {
|
|||||||
/// return 200.
|
/// return 200.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delete_account_succeeds_and_data_is_gone() {
|
async fn delete_account_succeeds_and_data_is_gone() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "judy", "delete_me").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]
|
#[tokio::test]
|
||||||
async fn health_returns_ok() {
|
async fn health_returns_ok() {
|
||||||
// No JWT needed; set it anyway for consistency.
|
// No JWT needed; set it anyway for consistency.
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let req = Request::builder()
|
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.
|
/// `GET /api/daily-challenge` must return 200 with today's UTC date.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn daily_challenge_returns_goal_for_today() {
|
async fn daily_challenge_returns_goal_for_today() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
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).
|
/// Calling `GET /api/daily-challenge` twice returns the same seed (deterministic).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn daily_challenge_is_deterministic() {
|
async fn daily_challenge_is_deterministic() {
|
||||||
set_jwt_secret();
|
|
||||||
// Use the same pool so the second call hits the stored row.
|
// Use the same pool so the second call hits the stored row.
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
|
|
||||||
@@ -668,7 +779,7 @@ async fn daily_challenge_is_deterministic() {
|
|||||||
/// `GET /api/leaderboard` requires authentication — no token returns 401.
|
/// `GET /api/leaderboard` requires authentication — no token returns 401.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn leaderboard_without_token_returns_401() {
|
async fn leaderboard_without_token_returns_401() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let req = Request::builder()
|
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.
|
/// Opting in and then fetching the leaderboard returns the opted-in entry.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_in_then_leaderboard_shows_entry() {
|
async fn opt_in_then_leaderboard_shows_entry() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "karen", "leaderpass").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.
|
/// Pushing sync data after opting in updates the leaderboard best_score.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn push_after_opt_in_updates_leaderboard_score() {
|
async fn push_after_opt_in_updates_leaderboard_score() {
|
||||||
set_jwt_secret();
|
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
let app = build_test_router(pool);
|
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.
|
/// Pushing a lower score after a higher one does not overwrite the best.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn push_lower_score_does_not_overwrite_leaderboard_best() {
|
async fn push_lower_score_does_not_overwrite_leaderboard_best() {
|
||||||
set_jwt_secret();
|
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
let app = build_test_router(pool);
|
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.
|
/// Opting out hides the player from the leaderboard; opting back in restores them.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_out_hides_then_opt_in_restores() {
|
async fn opt_out_hides_then_opt_in_restores() {
|
||||||
set_jwt_secret();
|
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
let app = build_test_router(pool);
|
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.
|
/// Opting in with an empty display name returns 400.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_in_empty_display_name_returns_400() {
|
async fn opt_in_empty_display_name_returns_400() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let (access, _) = register_user(app.clone(), "empty_name", "pass1234").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.
|
/// Opting in with a display name longer than 32 characters returns 400.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_in_too_long_display_name_returns_400() {
|
async fn opt_in_too_long_display_name_returns_400() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let (access, _) = register_user(app.clone(), "long_name", "pass1234").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.
|
/// Exactly 32 ASCII characters is accepted.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_in_exactly_32_char_display_name_succeeds() {
|
async fn opt_in_exactly_32_char_display_name_succeeds() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let (access, _) = register_user(app.clone(), "maxname", "pass1234").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.
|
/// accepted — the limit is character count, not byte count.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_in_32_unicode_chars_display_name_succeeds() {
|
async fn opt_in_32_unicode_chars_display_name_succeeds() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let (access, _) = register_user(app.clone(), "unicode_name", "pass1234").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.
|
/// A display name with 33 Unicode emoji is rejected.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn opt_in_33_unicode_chars_display_name_returns_400() {
|
async fn opt_in_33_unicode_chars_display_name_returns_400() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
let (access, _) = register_user(app.clone(), "unicode_long", "pass1234").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.
|
/// the server merges (max wins) rather than blindly replacing.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn second_push_with_lower_stats_preserves_higher_stored_values() {
|
async fn second_push_with_lower_stats_preserves_higher_stored_values() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "merge_test", "merge_pass").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.
|
/// Login with leading/trailing whitespace in the username still succeeds.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_trims_whitespace_from_username() {
|
async fn login_trims_whitespace_from_username() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let _ = register_user(app.clone(), "trimtest", "password1!").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.
|
/// `POST /api/sync/push` with a body exceeding the 1 MB limit must return 413.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn push_oversized_body_returns_413() {
|
async fn push_oversized_body_returns_413() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (access, _) = register_user(app.clone(), "sizetest", "password1!").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.
|
/// A JWT whose `exp` is in the past must be rejected with 401 on protected routes.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn expired_access_token_returns_401() {
|
async fn expired_access_token_returns_401() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
// Craft a token that expired 2 hours ago — well past jsonwebtoken's 60 s leeway.
|
// 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.
|
/// A refresh token must be rejected when used as a Bearer token on protected routes.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn refresh_token_rejected_on_protected_routes() {
|
async fn refresh_token_rejected_on_protected_routes() {
|
||||||
set_jwt_secret();
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
let (_, refresh) = register_user(app.clone(), "kindtest", "password1!").await;
|
let (_, refresh) = register_user(app.clone(), "kindtest", "password1!").await;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solitaire_sync"
|
name = "solitaire_sync"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -643,6 +643,95 @@ mod tests {
|
|||||||
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
|
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]
|
#[test]
|
||||||
fn fastest_win_both_max_sentinel_stays_max() {
|
fn fastest_win_both_max_sentinel_stays_max() {
|
||||||
// Both sides have u64::MAX (no wins recorded on either) — result must remain MAX,
|
// Both sides have u64::MAX (no wins recorded on either) — result must remain MAX,
|
||||||
|
|||||||
@@ -201,8 +201,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_xp_saturates_on_overflow() {
|
fn add_xp_saturates_on_overflow() {
|
||||||
let mut p = PlayerProgress::default();
|
let mut p = PlayerProgress { total_xp: u64::MAX, ..Default::default() };
|
||||||
p.total_xp = u64::MAX;
|
|
||||||
p.add_xp(1);
|
p.add_xp(1);
|
||||||
assert_eq!(p.total_xp, u64::MAX);
|
assert_eq!(p.total_xp, u64::MAX);
|
||||||
}
|
}
|
||||||
@@ -230,8 +229,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn roll_weekly_goals_clears_progress_for_new_week() {
|
fn roll_weekly_goals_clears_progress_for_new_week() {
|
||||||
let mut p = PlayerProgress::default();
|
let mut p = PlayerProgress { weekly_goal_week_iso: Some("2026-W16".to_string()), ..Default::default() };
|
||||||
p.weekly_goal_week_iso = Some("2026-W16".to_string());
|
|
||||||
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 3);
|
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 3);
|
||||||
|
|
||||||
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
|
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
|
||||||
@@ -242,8 +240,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn roll_weekly_goals_is_noop_for_same_week() {
|
fn roll_weekly_goals_is_noop_for_same_week() {
|
||||||
let mut p = PlayerProgress::default();
|
let mut p = PlayerProgress { weekly_goal_week_iso: Some("2026-W17".to_string()), ..Default::default() };
|
||||||
p.weekly_goal_week_iso = Some("2026-W17".to_string());
|
|
||||||
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 2);
|
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 2);
|
||||||
|
|
||||||
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
|
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
|
||||||
|
|||||||
@@ -135,17 +135,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn record_abandoned_resets_win_streak() {
|
fn record_abandoned_resets_win_streak() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot { win_streak_current: 5, ..Default::default() };
|
||||||
s.win_streak_current = 5;
|
|
||||||
s.record_abandoned();
|
s.record_abandoned();
|
||||||
assert_eq!(s.win_streak_current, 0, "abandoned game must break the win streak");
|
assert_eq!(s.win_streak_current, 0, "abandoned game must break the win streak");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn record_abandoned_preserves_best_streak() {
|
fn record_abandoned_preserves_best_streak() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot { win_streak_best: 7, win_streak_current: 7, ..Default::default() };
|
||||||
s.win_streak_best = 7;
|
|
||||||
s.win_streak_current = 7;
|
|
||||||
s.record_abandoned();
|
s.record_abandoned();
|
||||||
assert_eq!(s.win_streak_best, 7, "best streak must not be reduced on abandon");
|
assert_eq!(s.win_streak_best, 7, "best streak must not be reduced on abandon");
|
||||||
assert_eq!(s.win_streak_current, 0);
|
assert_eq!(s.win_streak_current, 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user