Compare commits
52 Commits
13b428b81c
...
2a01ecdbfd
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a01ecdbfd | |||
| bf150f11f1 | |||
| 3d4d834c58 | |||
| d605fd5536 | |||
| 96ac44fbef | |||
| 2dd5b1fc9c | |||
| d0b650e08b | |||
| 9e9ce2b752 | |||
| fe986ef4a1 | |||
| fd5d488361 | |||
| e624dd26b0 | |||
| cdb1145061 | |||
| e174ed93a4 | |||
| 3eb7901023 | |||
| 91b675f2f1 | |||
| 0b0e0180c0 | |||
| bc021acfd0 | |||
| cacacb00dc | |||
| 0a76c089d0 | |||
| de840fb006 | |||
| e3ac494e85 | |||
| 11cb53ab29 | |||
| 4a33cbdc22 | |||
| dfeaed6de2 | |||
| ed0aff4714 | |||
| 46dd9cdfab | |||
| 14ef19a396 | |||
| 3d5f34a650 | |||
| 314186d6f4 | |||
| c6a596299e | |||
| 07bf1977bd | |||
| 3363da2d1a | |||
| 648c5c18d9 | |||
| 15b9b5477b | |||
| fff8c66bf7 | |||
| 299e0c6a94 | |||
| f579b96d76 | |||
| bd48813900 | |||
| 9a38873891 | |||
| 9a4071c74e | |||
| 45ef3a2058 | |||
| 6728a4311f | |||
| b37fe5b49b | |||
| d56abcd7a9 | |||
| a7b781cd36 | |||
| f7850c0075 | |||
| 00f0383867 | |||
| 20db4b312a | |||
| f7f14efe07 | |||
| 303c78aa4c | |||
| 3c01cef5f3 | |||
| 34ba4dc6ed |
+15
-3
@@ -1,4 +1,16 @@
|
|||||||
DATABASE_URL=sqlite://solitaire.db
|
# Copy to .env and fill in the values before running docker compose up.
|
||||||
JWT_SECRET=replace_with_64_char_hex_from_openssl_rand_hex_32
|
|
||||||
|
# SQLite database path inside the container.
|
||||||
|
# When using docker-compose, leave as-is — the volume handles persistence.
|
||||||
|
DATABASE_URL=sqlite:///data/solitaire.db
|
||||||
|
|
||||||
|
# HS256 signing secret for JWT tokens.
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
JWT_SECRET=replace_me_with_a_64_char_hex_secret
|
||||||
|
|
||||||
|
# TCP port the server listens on inside the container.
|
||||||
SERVER_PORT=8080
|
SERVER_PORT=8080
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
|
# Public domain name used by Caddy for automatic HTTPS.
|
||||||
|
# Example: solitaire.example.com
|
||||||
|
SOLITAIRE_DOMAIN=solitaire.example.com
|
||||||
|
|||||||
@@ -4,3 +4,4 @@
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
.env
|
.env
|
||||||
*.tmp
|
*.tmp
|
||||||
|
data/
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT goal_json FROM daily_challenges WHERE date = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "goal_json",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "04d8dced0cc309bc0d968ab8f542a7f6c504ce7c2f682c0bef35dfe441ffb6e8"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id FROM users WHERE username = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2e61cd30a6cd3e0937dd096b4f94493e8bcb8c10687d0f8c0592fe38ed956fa6"
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, password_hash FROM users WHERE username = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "password_hash",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2f12541a6b99acf6d9e8f7ad13438df4ab92edcbfa01f38930aae63f1554b534"
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "display_name",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "best_score",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "best_time_secs",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recorded_at",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "57c93a6acd7eea44d00412e62f0d3fed7ffbe4cd759353d29f38a8eb37f69112"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE users SET leaderboard_opt_in = 1 WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "5e9e61cae887a08e4224fbea43a047c093bf5a4413499bfec858e302efa91bc3"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO sync_state (user_id, stats_json, achievements_json, progress_json, last_modified)\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT(user_id) DO UPDATE SET\n stats_json = excluded.stats_json,\n achievements_json = excluded.achievements_json,\n progress_json = excluded.progress_json,\n last_modified = excluded.last_modified",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 5
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "720edc3b85eec90744179f2c73ed9798814b734a2f3d57405ef95772ae66a24d"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM users WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "73ffdf5be39aa5c4c160c2f77d6634a6970eeb4e1d3395f045ded747f0ce9d2a"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT leaderboard_opt_in FROM users WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "leaderboard_opt_in",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "765c87463905b2edbf37f7416d5bc38e639c7e4c5feb0f5a9d8d6fb724e7a0a8"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO leaderboard (user_id, display_name, recorded_at)\n VALUES (?, ?, ?)\n ON CONFLICT(user_id) DO UPDATE SET\n display_name = excluded.display_name,\n recorded_at = excluded.recorded_at",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "8697100790ebcdf4058812cf6debad8f9a13ac91c2d0ef20cff3bd4bb9385360"
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT stats_json, achievements_json, progress_json FROM sync_state WHERE user_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "stats_json",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "achievements_json",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "progress_json",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a908d8d19647494f2ac2f801dc9c977f7ce23b44779da1567049856eab922645"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE leaderboard\n SET best_score = MAX(COALESCE(best_score, 0), ?),\n best_time_secs = CASE\n WHEN ? IS NULL THEN best_time_secs\n WHEN best_time_secs IS NULL THEN ?\n ELSE MIN(best_time_secs, ?)\n END,\n recorded_at = ?\n WHERE user_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 6
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "a931c228c46dc06a5657d419cd5dfa5a810bbce9501845c92c6619d29806d70c"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT OR IGNORE INTO daily_challenges (date, seed, goal_json) VALUES (?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ab537795ba39fdd28c502a8b9e615f53e7cb08f3eddd8f0574f4dc8156436de5"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 4
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "cc1e091bf5e46e6d98baf94bac6d9ac1ca1845eff7c4b8196dd2efb93fd9e0dd"
|
||||||
|
}
|
||||||
+5
-5
@@ -153,7 +153,7 @@ Owns:
|
|||||||
> **Note:** This crate contains only a trait stub and compile-time stub implementations until Android support is actively developed. Do not implement JNI bindings until Phase: Android.
|
> **Note:** This crate contains only a trait stub and compile-time stub implementations until Android support is actively developed. Do not implement JNI bindings until Phase: Android.
|
||||||
|
|
||||||
### `solitaire_engine`
|
### `solitaire_engine`
|
||||||
**Dependencies:** `bevy`, `bevy_egui`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`.
|
**Dependencies:** `bevy`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`.
|
||||||
|
|
||||||
All Bevy-specific code. Structured as a collection of Plugins that `solitaire_app` registers.
|
All Bevy-specific code. Structured as a collection of Plugins that `solitaire_app` registers.
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ Owns:
|
|||||||
- Rendering systems (card sprites, table, backgrounds)
|
- Rendering systems (card sprites, table, backgrounds)
|
||||||
- Drag-and-drop input handling
|
- Drag-and-drop input handling
|
||||||
- Animation systems (slide, flip, win cascade, toast)
|
- Animation systems (slide, flip, win cascade, toast)
|
||||||
- All egui screens (Home, Stats, Achievements, Settings, Profile)
|
- All Bevy UI screens (Home, Stats, Achievements, Settings, Profile)
|
||||||
- Audio playback systems
|
- Audio playback systems
|
||||||
- Sync status display
|
- Sync status display
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ RenderSystem ScoreSystem AchievementSystem
|
|||||||
│
|
│
|
||||||
│ fires AchievementUnlockedEvent
|
│ fires AchievementUnlockedEvent
|
||||||
▼
|
▼
|
||||||
ToastSystem (egui popup)
|
ToastSystem (Bevy UI popup)
|
||||||
PersistenceSystem (write to disk)
|
PersistenceSystem (write to disk)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ Done
|
|||||||
| `TablePlugin` | Pile markers, background, layout calculation |
|
| `TablePlugin` | Pile markers, background, layout calculation |
|
||||||
| `AnimationPlugin` | Slide, flip, win cascade, toast animations |
|
| `AnimationPlugin` | Slide, flip, win cascade, toast animations |
|
||||||
| `AudioPlugin` | Sound effect and music playback via bevy_kira_audio |
|
| `AudioPlugin` | Sound effect and music playback via bevy_kira_audio |
|
||||||
| `UIPlugin` | All egui screens: Home, Stats, Achievements, Settings, Profile |
|
| `UIPlugin` | All Bevy UI screens: Home, Stats, Achievements, Settings, Profile |
|
||||||
| `AchievementPlugin` | Listens for game events, evaluates unlock conditions, fires toasts |
|
| `AchievementPlugin` | Listens for game events, evaluates unlock conditions, fires toasts |
|
||||||
| `SyncPlugin` | Manages sync lifecycle (pull on start, push on exit, status display) |
|
| `SyncPlugin` | Manages sync lifecycle (pull on start, push on exit, status display) |
|
||||||
| `GamePlugin` | Core game state resource, input routing, win detection |
|
| `GamePlugin` | Core game state resource, input routing, win detection |
|
||||||
@@ -861,7 +861,7 @@ Card backs: `assets/cards/backs/back_0.png` through `back_4.png`. Additional bac
|
|||||||
|
|
||||||
### Fonts
|
### Fonts
|
||||||
|
|
||||||
`assets/fonts/main.ttf` — used for card rank/suit text and all egui overrides.
|
`assets/fonts/main.ttf` — used for card rank/suit text in Bevy UI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{$SOLITAIRE_DOMAIN} {
|
||||||
|
reverse_proxy server:{$SERVER_PORT:-8080}
|
||||||
|
}
|
||||||
Generated
+7
@@ -5669,6 +5669,7 @@ name = "solitaire_app"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bevy",
|
"bevy",
|
||||||
|
"solitaire_data",
|
||||||
"solitaire_engine",
|
"solitaire_engine",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5707,11 +5708,15 @@ dependencies = [
|
|||||||
name = "solitaire_engine"
|
name = "solitaire_engine"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"bevy",
|
"bevy",
|
||||||
"chrono",
|
"chrono",
|
||||||
"kira",
|
"kira",
|
||||||
"solitaire_core",
|
"solitaire_core",
|
||||||
"solitaire_data",
|
"solitaire_data",
|
||||||
|
"solitaire_sync",
|
||||||
|
"tokio",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5738,6 +5743,7 @@ dependencies = [
|
|||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
"tower_governor",
|
"tower_governor",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -5751,6 +5757,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"thiserror 1.0.69",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Stage 1 — builder
|
||||||
|
# Compiles the solitaire_server binary in release mode.
|
||||||
|
# Requires a pre-generated .sqlx/ query cache (run `cargo sqlx prepare --workspace`
|
||||||
|
# before building the image so sqlx macros work without a live database).
|
||||||
|
FROM rust:slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y pkg-config libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Tell sqlx to use the cached query metadata instead of a live database.
|
||||||
|
ENV SQLX_OFFLINE=true
|
||||||
|
|
||||||
|
RUN cargo build --release -p solitaire_server
|
||||||
|
|
||||||
|
# Stage 2 — runtime
|
||||||
|
# Minimal image that only contains the compiled binary and its runtime deps.
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y libssl3 ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/release/solitaire_server /usr/local/bin/solitaire_server
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/solitaire_server"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Solitaire Quest — Self-Hosting Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- `openssl` for generating a JWT secret
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. Clone the repo and enter it.
|
||||||
|
2. Copy the example environment file and fill in your values:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env: set JWT_SECRET and SOLITAIRE_DOMAIN
|
||||||
|
```
|
||||||
|
3. (First time only) Generate the sqlx query cache so the server builds without a live database:
|
||||||
|
```bash
|
||||||
|
cargo install sqlx-cli --no-default-features --features rustls,sqlite
|
||||||
|
export DATABASE_URL=sqlite://solitaire.db
|
||||||
|
sqlx database create
|
||||||
|
sqlx migrate run --source solitaire_server/migrations
|
||||||
|
cargo sqlx prepare --workspace
|
||||||
|
rm solitaire.db # the real DB lives in ./data/ at runtime
|
||||||
|
```
|
||||||
|
4. Start everything:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
5. The server is now reachable at `https://<SOLITAIRE_DOMAIN>`.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
The entire server state is one SQLite file at `./data/solitaire.db`. Back it up with:
|
||||||
|
```bash
|
||||||
|
sqlite3 ./data/solitaire.db ".backup backup_$(date +%Y%m%d).db"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
server:
|
||||||
|
build: .
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
# Override DATABASE_URL so the DB always lands in the persistent volume,
|
||||||
|
# regardless of what .env contains.
|
||||||
|
DATABASE_URL: sqlite:///data/solitaire.db
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "${SERVER_PORT:-8080}"
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
@@ -10,3 +10,4 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
bevy = { workspace = true }
|
bevy = { workspace = true }
|
||||||
solitaire_engine = { workspace = true }
|
solitaire_engine = { workspace = true }
|
||||||
|
solitaire_data = { workspace = true }
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
AchievementPlugin, AnimationPlugin, AudioPlugin, CardPlugin, ChallengePlugin,
|
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin,
|
||||||
DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, OnboardingPlugin, PausePlugin,
|
ChallengePlugin, DailyChallengePlugin, GamePlugin, HelpPlugin, HudPlugin, InputPlugin,
|
||||||
ProgressPlugin, SettingsPlugin, StatsPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
LeaderboardPlugin, OnboardingPlugin, PausePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin,
|
||||||
|
SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// Load settings before building the app so we can construct the right
|
||||||
|
// sync provider. Falls back to defaults if no settings file exists yet.
|
||||||
|
let settings: Settings = settings_file_path()
|
||||||
|
.map(|p| load_settings_from(&p))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let sync_provider = provider_for_backend(&settings.sync_backend);
|
||||||
|
|
||||||
App::new()
|
App::new()
|
||||||
.add_plugins(
|
.add_plugins(
|
||||||
DefaultPlugins.set(WindowPlugin {
|
DefaultPlugins.set(WindowPlugin {
|
||||||
@@ -22,6 +31,7 @@ fn main() {
|
|||||||
.add_plugins(CardPlugin)
|
.add_plugins(CardPlugin)
|
||||||
.add_plugins(InputPlugin)
|
.add_plugins(InputPlugin)
|
||||||
.add_plugins(AnimationPlugin)
|
.add_plugins(AnimationPlugin)
|
||||||
|
.add_plugins(AutoCompletePlugin)
|
||||||
.add_plugins(StatsPlugin::default())
|
.add_plugins(StatsPlugin::default())
|
||||||
.add_plugins(ProgressPlugin::default())
|
.add_plugins(ProgressPlugin::default())
|
||||||
.add_plugins(AchievementPlugin::default())
|
.add_plugins(AchievementPlugin::default())
|
||||||
@@ -29,10 +39,13 @@ fn main() {
|
|||||||
.add_plugins(WeeklyGoalsPlugin)
|
.add_plugins(WeeklyGoalsPlugin)
|
||||||
.add_plugins(ChallengePlugin)
|
.add_plugins(ChallengePlugin)
|
||||||
.add_plugins(TimeAttackPlugin)
|
.add_plugins(TimeAttackPlugin)
|
||||||
|
.add_plugins(HudPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
.add_plugins(PausePlugin)
|
.add_plugins(PausePlugin)
|
||||||
.add_plugins(SettingsPlugin::default())
|
.add_plugins(SettingsPlugin::default())
|
||||||
.add_plugins(AudioPlugin)
|
.add_plugins(AudioPlugin)
|
||||||
.add_plugins(OnboardingPlugin)
|
.add_plugins(OnboardingPlugin)
|
||||||
|
.add_plugins(SyncPlugin::new(sync_provider))
|
||||||
|
.add_plugins(LeaderboardPlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,24 @@ pub struct AchievementContext {
|
|||||||
|
|
||||||
/// Local hour (0–23) at the time of win. `None` if unknown.
|
/// Local hour (0–23) at the time of win. `None` if unknown.
|
||||||
pub wall_clock_hour: Option<u32>,
|
pub wall_clock_hour: Option<u32>,
|
||||||
|
|
||||||
|
/// Number of times waste was recycled back to stock during the won game.
|
||||||
|
pub last_win_recycle_count: u32,
|
||||||
|
/// `true` if the game was played in Zen mode.
|
||||||
|
pub last_win_is_zen: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reward granted when an achievement is first unlocked.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Reward {
|
||||||
|
/// Unlocks a card-back design at the given index (0 is always unlocked).
|
||||||
|
CardBack(usize),
|
||||||
|
/// Unlocks a background design at the given index (0 is always unlocked).
|
||||||
|
Background(usize),
|
||||||
|
/// Awards bonus XP on top of the standard win XP.
|
||||||
|
BonusXp(u64),
|
||||||
|
/// A visual badge — no gameplay effect.
|
||||||
|
Badge,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single achievement's static metadata + unlock condition.
|
/// A single achievement's static metadata + unlock condition.
|
||||||
@@ -42,6 +60,8 @@ pub struct AchievementDef {
|
|||||||
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.
|
||||||
|
pub reward: Option<Reward>,
|
||||||
pub condition: fn(&AchievementContext) -> bool,
|
pub condition: fn(&AchievementContext) -> bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,13 +109,12 @@ fn draw_three_master(c: &AchievementContext) -> bool {
|
|||||||
c.draw_three_wins >= 10
|
c.draw_three_wins >= 10
|
||||||
}
|
}
|
||||||
fn night_owl(c: &AchievementContext) -> bool {
|
fn night_owl(c: &AchievementContext) -> bool {
|
||||||
// "Play after midnight" — 00:00 through 05:59 local time.
|
// Late-night session: 22:00–02:59 local time.
|
||||||
matches!(c.wall_clock_hour, Some(h) if h < 6)
|
matches!(c.wall_clock_hour, Some(h) if !(3..22).contains(&h))
|
||||||
}
|
}
|
||||||
fn early_bird(c: &AchievementContext) -> bool {
|
fn early_bird(c: &AchievementContext) -> bool {
|
||||||
// "Play before 6am" — same window as night_owl; both unlock together
|
// Early-morning session: 05:00–06:59 local time.
|
||||||
// when someone wins in the small hours. Retained for progression variety.
|
matches!(c.wall_clock_hour, Some(h) if (5..7).contains(&h))
|
||||||
matches!(c.wall_clock_hour, Some(h) if h < 6)
|
|
||||||
}
|
}
|
||||||
fn speed_and_skill(c: &AchievementContext) -> bool {
|
fn speed_and_skill(c: &AchievementContext) -> bool {
|
||||||
c.last_win_time_seconds < 90 && !c.last_win_used_undo
|
c.last_win_time_seconds < 90 && !c.last_win_used_undo
|
||||||
@@ -103,6 +122,15 @@ fn speed_and_skill(c: &AchievementContext) -> bool {
|
|||||||
fn daily_devotee(c: &AchievementContext) -> bool {
|
fn daily_devotee(c: &AchievementContext) -> bool {
|
||||||
c.daily_challenge_streak >= 7
|
c.daily_challenge_streak >= 7
|
||||||
}
|
}
|
||||||
|
fn perfectionist(c: &AchievementContext) -> bool {
|
||||||
|
!c.last_win_used_undo && c.last_win_score >= 5_000
|
||||||
|
}
|
||||||
|
fn comeback(c: &AchievementContext) -> bool {
|
||||||
|
c.last_win_recycle_count >= 3
|
||||||
|
}
|
||||||
|
fn zen_winner(c: &AchievementContext) -> bool {
|
||||||
|
c.last_win_is_zen
|
||||||
|
}
|
||||||
|
|
||||||
/// All currently-evaluable achievements. Order is stable so persistence files
|
/// All currently-evaluable achievements. Order is stable so persistence files
|
||||||
/// remain readable across versions (new achievements append).
|
/// remain readable across versions (new achievements append).
|
||||||
@@ -112,6 +140,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
name: "First Win",
|
name: "First Win",
|
||||||
description: "Win your first game",
|
description: "Win your first game",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: None,
|
||||||
condition: first_win,
|
condition: first_win,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
@@ -119,6 +148,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
name: "On a Roll",
|
name: "On a Roll",
|
||||||
description: "Win 3 games in a row",
|
description: "Win 3 games in a row",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: Some(Reward::CardBack(1)),
|
||||||
condition: on_a_roll,
|
condition: on_a_roll,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
@@ -126,6 +156,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
name: "Unstoppable",
|
name: "Unstoppable",
|
||||||
description: "Win 10 games in a row",
|
description: "Win 10 games in a row",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: Some(Reward::Background(1)),
|
||||||
condition: unstoppable,
|
condition: unstoppable,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
@@ -133,6 +164,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
name: "Century",
|
name: "Century",
|
||||||
description: "Play 100 games",
|
description: "Play 100 games",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: None,
|
||||||
condition: century,
|
condition: century,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
@@ -140,6 +172,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
name: "Veteran",
|
name: "Veteran",
|
||||||
description: "Play 500 games",
|
description: "Play 500 games",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: Some(Reward::Badge),
|
||||||
condition: veteran,
|
condition: veteran,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
@@ -147,6 +180,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
name: "Speed Demon",
|
name: "Speed Demon",
|
||||||
description: "Win in under 3 minutes",
|
description: "Win in under 3 minutes",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: None,
|
||||||
condition: speed_demon,
|
condition: speed_demon,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
@@ -154,6 +188,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
name: "Lightning",
|
name: "Lightning",
|
||||||
description: "Win in under 90 seconds",
|
description: "Win in under 90 seconds",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: Some(Reward::CardBack(2)),
|
||||||
condition: lightning,
|
condition: lightning,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
@@ -161,6 +196,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
name: "High Scorer",
|
name: "High Scorer",
|
||||||
description: "Score at least 5,000 in one game",
|
description: "Score at least 5,000 in one game",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: None,
|
||||||
condition: high_scorer,
|
condition: high_scorer,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
@@ -168,6 +204,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
name: "Point Machine",
|
name: "Point Machine",
|
||||||
description: "Accumulate 50,000 lifetime points",
|
description: "Accumulate 50,000 lifetime points",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: Some(Reward::Background(2)),
|
||||||
condition: point_machine,
|
condition: point_machine,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
@@ -175,6 +212,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
name: "No Undo",
|
name: "No Undo",
|
||||||
description: "Win a game without using undo",
|
description: "Win a game without using undo",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: Some(Reward::BonusXp(25)),
|
||||||
condition: no_undo,
|
condition: no_undo,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
@@ -182,20 +220,23 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
name: "Draw 3 Master",
|
name: "Draw 3 Master",
|
||||||
description: "Win 10 games in Draw 3 mode",
|
description: "Win 10 games in Draw 3 mode",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: Some(Reward::CardBack(3)),
|
||||||
condition: draw_three_master,
|
condition: draw_three_master,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
id: "night_owl",
|
id: "night_owl",
|
||||||
name: "Night Owl",
|
name: "Night Owl",
|
||||||
description: "Win a game after midnight",
|
description: "Win a game between 10pm and 3am",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: None,
|
||||||
condition: night_owl,
|
condition: night_owl,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
id: "early_bird",
|
id: "early_bird",
|
||||||
name: "Early Bird",
|
name: "Early Bird",
|
||||||
description: "Win a game before 6am",
|
description: "Win a game between 5am and 7am",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: None,
|
||||||
condition: early_bird,
|
condition: early_bird,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
@@ -203,6 +244,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
name: "???",
|
name: "???",
|
||||||
description: "A secret achievement",
|
description: "A secret achievement",
|
||||||
secret: true,
|
secret: true,
|
||||||
|
reward: Some(Reward::CardBack(4)),
|
||||||
condition: speed_and_skill,
|
condition: speed_and_skill,
|
||||||
},
|
},
|
||||||
AchievementDef {
|
AchievementDef {
|
||||||
@@ -210,8 +252,33 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
name: "Daily Devotee",
|
name: "Daily Devotee",
|
||||||
description: "Complete the daily challenge 7 days in a row",
|
description: "Complete the daily challenge 7 days in a row",
|
||||||
secret: false,
|
secret: false,
|
||||||
|
reward: Some(Reward::Background(3)),
|
||||||
condition: daily_devotee,
|
condition: daily_devotee,
|
||||||
},
|
},
|
||||||
|
AchievementDef {
|
||||||
|
id: "perfectionist",
|
||||||
|
name: "Perfectionist",
|
||||||
|
description: "Win without undo and score at least 5,000",
|
||||||
|
secret: false,
|
||||||
|
reward: Some(Reward::Badge),
|
||||||
|
condition: perfectionist,
|
||||||
|
},
|
||||||
|
AchievementDef {
|
||||||
|
id: "comeback",
|
||||||
|
name: "???",
|
||||||
|
description: "A secret achievement",
|
||||||
|
secret: true,
|
||||||
|
reward: Some(Reward::Background(4)),
|
||||||
|
condition: comeback,
|
||||||
|
},
|
||||||
|
AchievementDef {
|
||||||
|
id: "zen_winner",
|
||||||
|
name: "???",
|
||||||
|
description: "A secret achievement",
|
||||||
|
secret: true,
|
||||||
|
reward: Some(Reward::Badge),
|
||||||
|
condition: zen_winner,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
|
/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
|
||||||
@@ -244,6 +311,8 @@ mod tests {
|
|||||||
last_win_time_seconds: u64::MAX,
|
last_win_time_seconds: u64::MAX,
|
||||||
last_win_used_undo: true,
|
last_win_used_undo: true,
|
||||||
wall_clock_hour: None,
|
wall_clock_hour: None,
|
||||||
|
last_win_recycle_count: 0,
|
||||||
|
last_win_is_zen: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,16 +382,39 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn night_owl_requires_early_hours() {
|
fn night_owl_triggers_in_late_night_window() {
|
||||||
let mut c = ctx();
|
let mut c = ctx();
|
||||||
c.games_won = 1;
|
c.games_won = 1;
|
||||||
c.wall_clock_hour = Some(2);
|
// Late night: 22:00–02:59
|
||||||
|
for hour in [22u32, 23, 0, 1, 2] {
|
||||||
|
c.wall_clock_hour = Some(hour);
|
||||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
assert!(ids.contains(&"night_owl"));
|
assert!(ids.contains(&"night_owl"), "expected night_owl at hour {hour}");
|
||||||
|
}
|
||||||
|
// Daytime hours must not trigger.
|
||||||
|
for hour in [3u32, 7, 12, 20, 21] {
|
||||||
|
c.wall_clock_hour = Some(hour);
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"night_owl"), "unexpected night_owl at hour {hour}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.wall_clock_hour = Some(12);
|
#[test]
|
||||||
|
fn early_bird_triggers_in_morning_window() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.games_won = 1;
|
||||||
|
// Early morning: 05:00–06:59
|
||||||
|
for hour in [5u32, 6] {
|
||||||
|
c.wall_clock_hour = Some(hour);
|
||||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
assert!(!ids.contains(&"night_owl"));
|
assert!(ids.contains(&"early_bird"), "expected early_bird at hour {hour}");
|
||||||
|
}
|
||||||
|
// Outside the window must not trigger.
|
||||||
|
for hour in [0u32, 3, 4, 7, 12, 23] {
|
||||||
|
c.wall_clock_hour = Some(hour);
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"early_bird"), "unexpected early_bird at hour {hour}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -337,9 +429,152 @@ mod tests {
|
|||||||
assert!(ids.contains(&"daily_devotee"));
|
assert!(ids.contains(&"daily_devotee"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn perfectionist_requires_no_undo_and_high_score() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.last_win_used_undo = false;
|
||||||
|
c.last_win_score = 5_000;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"perfectionist"));
|
||||||
|
|
||||||
|
c.last_win_used_undo = true;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"perfectionist"));
|
||||||
|
|
||||||
|
c.last_win_used_undo = false;
|
||||||
|
c.last_win_score = 4_999;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"perfectionist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comeback_requires_at_least_three_recycles() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.last_win_recycle_count = 2;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"comeback"));
|
||||||
|
|
||||||
|
c.last_win_recycle_count = 3;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"comeback"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zen_winner_requires_zen_mode() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.last_win_is_zen = false;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"zen_winner"));
|
||||||
|
|
||||||
|
c.last_win_is_zen = true;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"zen_winner"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn achievement_by_id_finds_known_and_returns_none_for_unknown() {
|
fn achievement_by_id_finds_known_and_returns_none_for_unknown() {
|
||||||
assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win"));
|
assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win"));
|
||||||
assert!(achievement_by_id("nonexistent").is_none());
|
assert!(achievement_by_id("nonexistent").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn on_a_roll_requires_streak_of_3() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.win_streak_current = 2;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"on_a_roll"));
|
||||||
|
|
||||||
|
c.win_streak_current = 3;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"on_a_roll"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unstoppable_requires_streak_of_10() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.win_streak_current = 9;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"unstoppable"));
|
||||||
|
assert!(ids.contains(&"on_a_roll"), "streak 9 must still satisfy on_a_roll");
|
||||||
|
|
||||||
|
c.win_streak_current = 10;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"unstoppable"));
|
||||||
|
assert!(ids.contains(&"on_a_roll"), "streak 10 must also satisfy on_a_roll");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn century_requires_100_games_played() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.games_played = 99;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"century"));
|
||||||
|
|
||||||
|
c.games_played = 100;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"century"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn veteran_requires_500_games_played() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.games_played = 499;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"veteran"));
|
||||||
|
assert!(ids.contains(&"century"), "499 games must also satisfy century");
|
||||||
|
|
||||||
|
c.games_played = 500;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"veteran"));
|
||||||
|
assert!(ids.contains(&"century"), "500 games must also satisfy century");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn high_scorer_requires_best_single_score_of_5000() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.best_single_score = 4_999;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"high_scorer"));
|
||||||
|
|
||||||
|
c.best_single_score = 5_000;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"high_scorer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_machine_requires_50000_lifetime_score() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.lifetime_score = 49_999;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"point_machine"));
|
||||||
|
|
||||||
|
c.lifetime_score = 50_000;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"point_machine"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_three_master_requires_10_draw_three_wins() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.draw_three_wins = 9;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"draw_three_master"));
|
||||||
|
|
||||||
|
c.draw_three_wins = 10;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"draw_three_master"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speed_demon_boundary_at_180_seconds() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.games_won = 1;
|
||||||
|
c.last_win_time_seconds = 179;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"speed_demon"));
|
||||||
|
|
||||||
|
c.last_win_time_seconds = 180;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"speed_demon"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::card::{Card, Suit};
|
use crate::card::{Card, Suit};
|
||||||
use crate::deck::{deal_klondike, Deck};
|
use crate::deck::{deal_klondike, Deck};
|
||||||
@@ -9,6 +9,24 @@ use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score
|
|||||||
|
|
||||||
const MAX_UNDO_STACK: usize = 64;
|
const MAX_UNDO_STACK: usize = 64;
|
||||||
|
|
||||||
|
/// Serialize `HashMap<PileType, Pile>` as a `Vec` of `(key, value)` pairs so
|
||||||
|
/// that JSON (which requires string map keys) round-trips correctly.
|
||||||
|
mod pile_map_serde {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
use crate::pile::{Pile, PileType};
|
||||||
|
|
||||||
|
pub fn serialize<S: Serializer>(map: &HashMap<PileType, Pile>, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let entries: Vec<(&PileType, &Pile)> = map.iter().collect();
|
||||||
|
entries.serialize(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<HashMap<PileType, Pile>, D::Error> {
|
||||||
|
let entries: Vec<(PileType, Pile)> = Vec::deserialize(d)?;
|
||||||
|
Ok(entries.into_iter().collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 {
|
||||||
@@ -37,6 +55,7 @@ pub enum GameMode {
|
|||||||
/// Snapshot of game state used for undo.
|
/// Snapshot of game state used for undo.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
struct StateSnapshot {
|
struct StateSnapshot {
|
||||||
|
#[serde(with = "pile_map_serde")]
|
||||||
piles: HashMap<PileType, Pile>,
|
piles: HashMap<PileType, Pile>,
|
||||||
score: i32,
|
score: i32,
|
||||||
move_count: u32,
|
move_count: u32,
|
||||||
@@ -45,6 +64,7 @@ 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 {
|
||||||
|
#[serde(with = "pile_map_serde")]
|
||||||
pub piles: HashMap<PileType, Pile>,
|
pub piles: HashMap<PileType, Pile>,
|
||||||
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
|
||||||
@@ -60,7 +80,11 @@ pub struct GameState {
|
|||||||
/// 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`.
|
||||||
pub undo_count: u32,
|
pub undo_count: u32,
|
||||||
undo_stack: Vec<StateSnapshot>,
|
/// Number of times the waste pile has been recycled back to stock this game.
|
||||||
|
/// Used by the `comeback` achievement condition.
|
||||||
|
#[serde(default)]
|
||||||
|
pub recycle_count: u32,
|
||||||
|
undo_stack: VecDeque<StateSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GameState {
|
impl GameState {
|
||||||
@@ -96,7 +120,8 @@ impl GameState {
|
|||||||
is_won: false,
|
is_won: false,
|
||||||
is_auto_completable: false,
|
is_auto_completable: false,
|
||||||
undo_count: 0,
|
undo_count: 0,
|
||||||
undo_stack: Vec::new(),
|
recycle_count: 0,
|
||||||
|
undo_stack: VecDeque::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,9 +140,9 @@ impl GameState {
|
|||||||
|
|
||||||
fn push_snapshot(&mut self) {
|
fn push_snapshot(&mut self) {
|
||||||
if self.undo_stack.len() >= MAX_UNDO_STACK {
|
if self.undo_stack.len() >= MAX_UNDO_STACK {
|
||||||
self.undo_stack.remove(0);
|
self.undo_stack.pop_front(); // O(1)
|
||||||
}
|
}
|
||||||
self.undo_stack.push(self.take_snapshot());
|
self.undo_stack.push_back(self.take_snapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
|
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
|
||||||
@@ -147,6 +172,7 @@ impl GameState {
|
|||||||
card.face_up = false;
|
card.face_up = false;
|
||||||
stock.cards.push(card);
|
stock.cards.push(card);
|
||||||
}
|
}
|
||||||
|
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +304,7 @@ impl GameState {
|
|||||||
"undo is disabled in Challenge mode".into(),
|
"undo is disabled in Challenge mode".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let snapshot = self.undo_stack.pop().ok_or(MoveError::UndoStackEmpty)?;
|
let snapshot = self.undo_stack.pop_back().ok_or(MoveError::UndoStackEmpty)?;
|
||||||
self.piles = snapshot.piles;
|
self.piles = snapshot.piles;
|
||||||
self.score = if self.mode == GameMode::Zen {
|
self.score = if self.mode == GameMode::Zen {
|
||||||
0
|
0
|
||||||
@@ -320,6 +346,31 @@ impl GameState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the next `(from, to)` move that advances auto-complete, or
|
||||||
|
/// `None` if no such move exists (or `is_auto_completable` is not set).
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// resolved before higher ranks that depend on them.
|
||||||
|
pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> {
|
||||||
|
if !self.is_auto_completable || self.is_won {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
|
for i in 0..7 {
|
||||||
|
let tableau = PileType::Tableau(i);
|
||||||
|
if let Some(card) = self.piles[&tableau].cards.last() {
|
||||||
|
for &suit in &suits {
|
||||||
|
let foundation = PileType::Foundation(suit);
|
||||||
|
if can_place_on_foundation(card, &self.piles[&foundation], suit) {
|
||||||
|
return Some((tableau, foundation));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
||||||
pub fn compute_time_bonus(&self) -> i32 {
|
pub fn compute_time_bonus(&self) -> i32 {
|
||||||
scoring_time_bonus(self.elapsed_seconds)
|
scoring_time_bonus(self.elapsed_seconds)
|
||||||
@@ -436,6 +487,24 @@ mod tests {
|
|||||||
assert!(g.piles[&PileType::Waste].cards.is_empty());
|
assert!(g.piles[&PileType::Waste].cards.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recycle_count_increments_on_each_waste_recycle() {
|
||||||
|
let mut g = new_game();
|
||||||
|
assert_eq!(g.recycle_count, 0);
|
||||||
|
// Drain entire stock to waste.
|
||||||
|
while !g.piles[&PileType::Stock].cards.is_empty() {
|
||||||
|
g.draw().unwrap();
|
||||||
|
}
|
||||||
|
g.draw().unwrap(); // first recycle
|
||||||
|
assert_eq!(g.recycle_count, 1);
|
||||||
|
// Drain again and recycle a second time.
|
||||||
|
while !g.piles[&PileType::Stock].cards.is_empty() {
|
||||||
|
g.draw().unwrap();
|
||||||
|
}
|
||||||
|
g.draw().unwrap(); // second recycle
|
||||||
|
assert_eq!(g.recycle_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[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
|
||||||
@@ -476,6 +545,64 @@ mod tests {
|
|||||||
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_face_down_card_returns_rule_violation() {
|
||||||
|
let mut g = new_game();
|
||||||
|
// Tableau(6) has 7 cards; card 0 is always face-down.
|
||||||
|
// Attempt to move 7 cards (the whole pile including face-down ones).
|
||||||
|
let result = g.move_cards(PileType::Tableau(6), PileType::Tableau(5), 7);
|
||||||
|
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_multiple_cards_to_foundation_returns_rule_violation() {
|
||||||
|
let mut g = new_game();
|
||||||
|
// Inject two face-up cards into tableau(0) so count=2 is a valid count.
|
||||||
|
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
|
||||||
|
Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true },
|
||||||
|
Card { id: 2, suit: Suit::Clubs, rank: Rank::Two, face_up: true },
|
||||||
|
];
|
||||||
|
let result = g.move_cards(
|
||||||
|
PileType::Tableau(0),
|
||||||
|
PileType::Foundation(Suit::Clubs),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(MoveError::RuleViolation(_))),
|
||||||
|
"moving 2 cards to foundation must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_count_exceeding_pile_size_returns_rule_violation() {
|
||||||
|
let mut g = new_game();
|
||||||
|
// Tableau(0) has exactly 1 card; asking for 2 should fail.
|
||||||
|
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 2);
|
||||||
|
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_multi_card_sequence_tableau_to_tableau_succeeds() {
|
||||||
|
let mut g = new_game();
|
||||||
|
// Clear both piles and construct a known valid sequence.
|
||||||
|
let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||||
|
t0.cards = vec![
|
||||||
|
Card { id: 10, suit: Suit::Spades, rank: Rank::King, face_up: true },
|
||||||
|
Card { id: 11, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
|
||||||
|
Card { id: 12, suit: Suit::Spades, rank: Rank::Jack, face_up: true },
|
||||||
|
];
|
||||||
|
// Tableau(1) needs an Ace so we can check empty pile correctly — use a red King target.
|
||||||
|
let t1 = g.piles.get_mut(&PileType::Tableau(1)).unwrap();
|
||||||
|
t1.cards.clear(); // empty accepts a King
|
||||||
|
|
||||||
|
// Move the whole 3-card sequence to the empty pile.
|
||||||
|
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 3);
|
||||||
|
assert!(result.is_ok(), "valid multi-card move must succeed: {result:?}");
|
||||||
|
assert!(g.piles[&PileType::Tableau(0)].cards.is_empty());
|
||||||
|
assert_eq!(g.piles[&PileType::Tableau(1)].cards.len(), 3);
|
||||||
|
assert_eq!(g.move_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Win detection ---
|
// --- Win detection ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -616,6 +743,45 @@ mod tests {
|
|||||||
assert!(!g.check_auto_complete());
|
assert!(!g.check_auto_complete());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_complete_false_when_waste_not_empty() {
|
||||||
|
let mut g = new_game();
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
// Leave the waste pile untouched (it may be empty after clearing stock,
|
||||||
|
// so add a card explicitly to ensure the waste guard is exercised).
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||||||
|
id: 99,
|
||||||
|
suit: Suit::Clubs,
|
||||||
|
rank: Rank::Ace,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
// Make all tableau cards face-up so only the waste guard is the blocker.
|
||||||
|
for i in 0..7 {
|
||||||
|
for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() {
|
||||||
|
c.face_up = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(!g.check_auto_complete());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_complete_true_when_all_prerequisites_met() {
|
||||||
|
let mut g = new_game();
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
// Clear all tableau and put a single face-up card — all face-up guard passes.
|
||||||
|
for i in 0..7 {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||||
|
id: 1,
|
||||||
|
suit: Suit::Clubs,
|
||||||
|
rank: Rank::Ace,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
assert!(g.check_auto_complete());
|
||||||
|
}
|
||||||
|
|
||||||
// --- Time bonus ---
|
// --- Time bonus ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -631,4 +797,46 @@ mod tests {
|
|||||||
g.elapsed_seconds = 100;
|
g.elapsed_seconds = 100;
|
||||||
assert_eq!(g.compute_time_bonus(), 7000);
|
assert_eq!(g.compute_time_bonus(), 7000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- next_auto_complete_move ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_auto_complete_move_returns_none_on_fresh_game() {
|
||||||
|
// A fresh game has stock and face-down cards — not auto-completable.
|
||||||
|
assert!(new_game().next_auto_complete_move().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_auto_complete_move_finds_ace_on_auto_completable_board() {
|
||||||
|
use crate::card::{Card, Rank};
|
||||||
|
|
||||||
|
let mut g = new_game();
|
||||||
|
// Clear stock and waste to satisfy auto-complete precondition.
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
// Clear all tableau piles and put a single face-up Ace of Clubs
|
||||||
|
// into Tableau(0); all other piles empty.
|
||||||
|
for i in 0..7 {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||||
|
id: 99,
|
||||||
|
suit: Suit::Clubs,
|
||||||
|
rank: Rank::Ace,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
g.is_auto_completable = true;
|
||||||
|
|
||||||
|
let mv = g.next_auto_complete_move().expect("should find a move");
|
||||||
|
assert_eq!(mv.0, PileType::Tableau(0));
|
||||||
|
assert_eq!(mv.1, PileType::Foundation(Suit::Clubs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_auto_complete_move_returns_none_when_already_won() {
|
||||||
|
let mut g = new_game();
|
||||||
|
g.is_auto_completable = true;
|
||||||
|
g.is_won = true;
|
||||||
|
assert!(g.next_auto_complete_move().is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,4 +119,37 @@ mod tests {
|
|||||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Hearts, Rank::Seven)]);
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Hearts, Rank::Seven)]);
|
||||||
assert!(can_place_on_tableau(&c, &p));
|
assert!(can_place_on_tableau(&c, &p));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foundation_king_on_queen_completes_suit() {
|
||||||
|
// The last card placed to complete a foundation is always King on Queen.
|
||||||
|
let c = card(Suit::Spades, Rank::King);
|
||||||
|
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
||||||
|
assert!(can_place_on_foundation(&c, &p, Suit::Spades));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foundation_king_wrong_suit_is_invalid() {
|
||||||
|
// King of Hearts cannot go on a Spades foundation even if rank matches.
|
||||||
|
let c = card(Suit::Hearts, Rank::King);
|
||||||
|
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
||||||
|
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableau_ace_on_two_different_color_is_valid() {
|
||||||
|
// Ace (rank 1) can be placed on a Two of the opposite colour in the tableau.
|
||||||
|
// rank check: Ace.value() + 1 = 2 == Two.value() — passes.
|
||||||
|
let c = card(Suit::Hearts, Rank::Ace);
|
||||||
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Two)]);
|
||||||
|
assert!(can_place_on_tableau(&c, &p));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableau_same_rank_different_color_is_invalid() {
|
||||||
|
// Two cards of the same rank cannot be stacked regardless of colour.
|
||||||
|
let c = card(Suit::Hearts, Rank::Nine);
|
||||||
|
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Nine)]);
|
||||||
|
assert!(!can_place_on_tableau(&c, &p));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,18 @@
|
|||||||
//! Persistence for per-player achievement unlock records.
|
//! Persistence for per-player achievement unlock records.
|
||||||
|
//!
|
||||||
|
//! The [`AchievementRecord`] struct is defined in `solitaire_sync` so the
|
||||||
|
//! server can use the same type. This module re-exports it and provides
|
||||||
|
//! file I/O helpers.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
pub use solitaire_sync::AchievementRecord;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||||
const FILE_NAME: &str = "achievements.json";
|
const FILE_NAME: &str = "achievements.json";
|
||||||
|
|
||||||
/// One player's unlock state for a single achievement.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct AchievementRecord {
|
|
||||||
pub id: String,
|
|
||||||
pub unlocked: bool,
|
|
||||||
pub unlock_date: Option<DateTime<Utc>>,
|
|
||||||
pub reward_granted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AchievementRecord {
|
|
||||||
/// Construct an initial record for an achievement that is not yet unlocked.
|
|
||||||
pub fn locked(id: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
id: id.into(),
|
|
||||||
unlocked: false,
|
|
||||||
unlock_date: None,
|
|
||||||
reward_granted: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark this record unlocked at the given timestamp. No-op if already unlocked
|
|
||||||
/// (preserves earliest `unlock_date`).
|
|
||||||
pub fn unlock(&mut self, at: DateTime<Utc>) {
|
|
||||||
if self.unlocked {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.unlocked = true;
|
|
||||||
self.unlock_date = Some(at);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Platform-specific default path for `achievements.json`.
|
/// Platform-specific default path for `achievements.json`.
|
||||||
pub fn achievements_file_path() -> Option<PathBuf> {
|
pub fn achievements_file_path() -> Option<PathBuf> {
|
||||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
||||||
@@ -70,6 +42,7 @@ pub fn save_achievements_to(path: &Path, records: &[AchievementRecord]) -> io::R
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
fn tmp_path(name: &str) -> PathBuf {
|
fn tmp_path(name: &str) -> PathBuf {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! Secure storage for JWT access and refresh tokens using the OS keychain.
|
||||||
|
//!
|
||||||
|
//! Tokens are stored under service name `"solitaire_quest_server"` with entry
|
||||||
|
//! keys `"{username}_access"` and `"{username}_refresh"`.
|
||||||
|
//!
|
||||||
|
//! On Linux this requires a running secret service (GNOME Keyring / KWallet).
|
||||||
|
//! If the keychain is unavailable, operations return
|
||||||
|
//! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting
|
||||||
|
//! the user to log in again.
|
||||||
|
//!
|
||||||
|
//! # Note: no unit tests — requires live OS keychain.
|
||||||
|
|
||||||
|
use keyring::Entry;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors that can occur when reading or writing tokens in the OS keychain.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum TokenError {
|
||||||
|
/// The OS keychain (secret service / keychain daemon) is not available.
|
||||||
|
#[error("keychain unavailable: {0}")]
|
||||||
|
KeychainUnavailable(String),
|
||||||
|
/// No token was found in the keychain for the given username.
|
||||||
|
#[error("token not found for user {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
/// An unexpected keychain error occurred.
|
||||||
|
#[error("keychain error: {0}")]
|
||||||
|
Keyring(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service name used to namespace all keychain entries for this application.
|
||||||
|
const SERVICE: &str = "solitaire_quest_server";
|
||||||
|
|
||||||
|
/// Map a `keyring::Error` to the appropriate `TokenError`.
|
||||||
|
fn map_keyring_err(err: keyring::Error, username: &str) -> TokenError {
|
||||||
|
let msg = err.to_string();
|
||||||
|
match err {
|
||||||
|
keyring::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
|
||||||
|
keyring::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
||||||
|
_ => TokenError::Keyring(msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store the access and refresh tokens for `username` in the OS keychain.
|
||||||
|
///
|
||||||
|
/// Any previously stored tokens for that username are overwritten.
|
||||||
|
pub fn store_tokens(
|
||||||
|
username: &str,
|
||||||
|
access_token: &str,
|
||||||
|
refresh_token: &str,
|
||||||
|
) -> Result<(), TokenError> {
|
||||||
|
Entry::new(SERVICE, &format!("{username}_access"))
|
||||||
|
.map_err(|e| map_keyring_err(e, username))?
|
||||||
|
.set_password(access_token)
|
||||||
|
.map_err(|e| map_keyring_err(e, username))?;
|
||||||
|
|
||||||
|
Entry::new(SERVICE, &format!("{username}_refresh"))
|
||||||
|
.map_err(|e| map_keyring_err(e, username))?
|
||||||
|
.set_password(refresh_token)
|
||||||
|
.map_err(|e| map_keyring_err(e, username))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the stored access token for `username` from the OS keychain.
|
||||||
|
///
|
||||||
|
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||||
|
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
||||||
|
Entry::new(SERVICE, &format!("{username}_access"))
|
||||||
|
.map_err(|e| map_keyring_err(e, username))?
|
||||||
|
.get_password()
|
||||||
|
.map_err(|e| map_keyring_err(e, username))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the stored refresh token for `username` from the OS keychain.
|
||||||
|
///
|
||||||
|
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||||
|
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||||
|
Entry::new(SERVICE, &format!("{username}_refresh"))
|
||||||
|
.map_err(|e| map_keyring_err(e, username))?
|
||||||
|
.get_password()
|
||||||
|
.map_err(|e| map_keyring_err(e, username))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the stored access and refresh tokens for `username`.
|
||||||
|
///
|
||||||
|
/// Intended to be called on logout or account deletion. Missing entries are
|
||||||
|
/// silently ignored (the tokens are already gone, which is the desired state).
|
||||||
|
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||||
|
match Entry::new(SERVICE, &format!("{username}_access"))
|
||||||
|
.map_err(|e| map_keyring_err(e, username))?
|
||||||
|
.delete_password()
|
||||||
|
{
|
||||||
|
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
||||||
|
Err(e) => return Err(map_keyring_err(e, username)),
|
||||||
|
}
|
||||||
|
|
||||||
|
match Entry::new(SERVICE, &format!("{username}_refresh"))
|
||||||
|
.map_err(|e| map_keyring_err(e, username))?
|
||||||
|
.delete_password()
|
||||||
|
{
|
||||||
|
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
||||||
|
Err(e) => return Err(map_keyring_err(e, username)),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -10,11 +10,36 @@
|
|||||||
/// Curated Challenge-mode seeds. Order is stable across versions; add new
|
/// Curated Challenge-mode seeds. Order is stable across versions; add new
|
||||||
/// seeds at the end.
|
/// seeds at the end.
|
||||||
pub const CHALLENGE_SEEDS: &[u64] = &[
|
pub const CHALLENGE_SEEDS: &[u64] = &[
|
||||||
|
// Round 1 (original 5)
|
||||||
0xDEAD_BEEF_CAFE_F00D,
|
0xDEAD_BEEF_CAFE_F00D,
|
||||||
0xC0DE_FACE_8BAD_F00D,
|
0xC0DE_FACE_8BAD_F00D,
|
||||||
0xFEE1_DEAD_DEAD_BEEF,
|
0xFEE1_DEAD_DEAD_BEEF,
|
||||||
0xBAAD_F00D_BAAD_F00D,
|
0xBAAD_F00D_BAAD_F00D,
|
||||||
0x1337_C0DE_4242_BABE,
|
0x1337_C0DE_4242_BABE,
|
||||||
|
// Round 2
|
||||||
|
0xACED_CAFE_B0BA_1234,
|
||||||
|
0x5A1D_F00D_BEEF_CAFE,
|
||||||
|
0xF01D_BABE_CAFE_BEEF,
|
||||||
|
0xD00D_1E42_ABCD_EF01,
|
||||||
|
0x7EA5_ED01_2345_6789,
|
||||||
|
// Round 3
|
||||||
|
0xC1A5_51C0_F00D_CAFE,
|
||||||
|
0xBA5E_BA11_DEAD_BEEF,
|
||||||
|
0xFACE_B00C_C0DE_1010,
|
||||||
|
0x5EED_CAFE_5EED_CAFE,
|
||||||
|
0xABBA_ABBA_CAFE_BABE,
|
||||||
|
// Round 4
|
||||||
|
0x1234_5678_9ABC_DEF0,
|
||||||
|
0xFEDC_BA98_7654_3210,
|
||||||
|
0x0011_2233_4455_6677,
|
||||||
|
0x8899_AABB_CCDD_EEFF,
|
||||||
|
0x1111_2222_3333_4444,
|
||||||
|
// Round 5
|
||||||
|
0x5555_6666_7777_8888,
|
||||||
|
0x9999_AAAA_BBBB_CCCC,
|
||||||
|
0xDDDD_EEEE_FFFF_0000,
|
||||||
|
0x0101_0101_0101_0101,
|
||||||
|
0xA1B2_C3D4_E5F6_0718,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Resolve a `challenge_index` to its corresponding seed, wrapping when
|
/// Resolve a `challenge_index` to its corresponding seed, wrapping when
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use solitaire_sync::{SyncPayload, SyncResponse};
|
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// All errors that can arise during sync operations.
|
/// All errors that can arise during sync operations.
|
||||||
@@ -7,8 +7,6 @@ use thiserror::Error;
|
|||||||
pub enum SyncError {
|
pub enum SyncError {
|
||||||
#[error("unsupported platform for this sync backend")]
|
#[error("unsupported platform for this sync backend")]
|
||||||
UnsupportedPlatform,
|
UnsupportedPlatform,
|
||||||
// TODO: Replace String with concrete source error types (e.g. reqwest::Error,
|
|
||||||
// serde_json::Error) when real implementations are added in Phase 8.
|
|
||||||
#[error("network error: {0}")]
|
#[error("network error: {0}")]
|
||||||
Network(String),
|
Network(String),
|
||||||
#[error("authentication error: {0}")]
|
#[error("authentication error: {0}")]
|
||||||
@@ -33,13 +31,77 @@ pub trait SyncProvider: Send + Sync {
|
|||||||
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError> {
|
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
/// Fetch the global leaderboard from this backend. Returns an empty list
|
||||||
|
/// for backends that do not support leaderboards (e.g. `LocalOnlyProvider`).
|
||||||
|
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
/// Fetch today's daily challenge from the server. Returns `None` for
|
||||||
|
/// backends that don't support it, or on any non-fatal network failure.
|
||||||
|
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
/// Opt the authenticated player into the leaderboard with the given
|
||||||
|
/// display name. No-op for backends that don't support leaderboards.
|
||||||
|
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
/// Remove the authenticated player from the leaderboard.
|
||||||
|
/// No-op for backends that don't support leaderboards.
|
||||||
|
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
/// Permanently delete the authenticated player's account and all server
|
||||||
|
/// data. No-op for backends that don't support account management.
|
||||||
|
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
|
||||||
|
/// `provider_for_backend`) can be passed directly to `SyncPlugin::new`.
|
||||||
|
#[async_trait]
|
||||||
|
impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
||||||
|
async fn pull(&self) -> Result<SyncPayload, SyncError> {
|
||||||
|
(**self).pull().await
|
||||||
|
}
|
||||||
|
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
|
||||||
|
(**self).push(payload).await
|
||||||
|
}
|
||||||
|
fn backend_name(&self) -> &'static str {
|
||||||
|
(**self).backend_name()
|
||||||
|
}
|
||||||
|
fn is_authenticated(&self) -> bool {
|
||||||
|
(**self).is_authenticated()
|
||||||
|
}
|
||||||
|
async fn mirror_achievement(&self, id: &str) -> Result<(), SyncError> {
|
||||||
|
(**self).mirror_achievement(id).await
|
||||||
|
}
|
||||||
|
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||||
|
(**self).fetch_leaderboard().await
|
||||||
|
}
|
||||||
|
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError> {
|
||||||
|
(**self).fetch_daily_challenge().await
|
||||||
|
}
|
||||||
|
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
|
||||||
|
(**self).opt_in_leaderboard(display_name).await
|
||||||
|
}
|
||||||
|
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
|
||||||
|
(**self).opt_out_leaderboard().await
|
||||||
|
}
|
||||||
|
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||||
|
(**self).delete_account().await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub use stats::StatsSnapshot;
|
pub use stats::{StatsExt, StatsSnapshot};
|
||||||
|
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub use storage::{load_stats, load_stats_from, save_stats, save_stats_to, stats_file_path};
|
pub use storage::{
|
||||||
|
cleanup_orphaned_tmp_files, delete_game_state_at, game_state_file_path, load_game_state_from,
|
||||||
|
load_stats, load_stats_from, save_game_state_to, save_stats, save_stats_to, stats_file_path,
|
||||||
|
};
|
||||||
|
|
||||||
pub mod achievements;
|
pub mod achievements;
|
||||||
pub use achievements::{
|
pub use achievements::{
|
||||||
@@ -62,4 +124,15 @@ pub mod challenge;
|
|||||||
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
||||||
|
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub use settings::{load_settings_from, save_settings_to, settings_file_path, Settings};
|
pub use settings::{
|
||||||
|
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||||
|
Theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod auth_tokens;
|
||||||
|
pub use auth_tokens::{
|
||||||
|
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod sync_client;
|
||||||
|
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
|
||||||
|
|||||||
@@ -1,30 +1,22 @@
|
|||||||
//! Player progression — XP, level, unlocks, daily/weekly progress.
|
//! Player progression — XP, level, unlocks, daily/weekly progress.
|
||||||
//!
|
//!
|
||||||
//! Persisted to `progress.json` next to `stats.json` and `achievements.json`.
|
//! Persisted to `progress.json` next to `stats.json` and `achievements.json`.
|
||||||
|
//!
|
||||||
|
//! [`PlayerProgress`] is defined in `solitaire_sync` (so the server can use
|
||||||
|
//! the same type) and re-exported here along with file I/O helpers.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc};
|
use chrono::{Datelike, NaiveDate};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
pub use solitaire_sync::progress::level_for_xp;
|
||||||
|
pub use solitaire_sync::PlayerProgress;
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||||
const FILE_NAME: &str = "progress.json";
|
const FILE_NAME: &str = "progress.json";
|
||||||
|
|
||||||
/// XP-to-level lookup. Matches ARCHITECTURE.md §13.
|
|
||||||
///
|
|
||||||
/// Levels 1–10: `level = floor(total_xp / 500)`
|
|
||||||
/// Levels 11+: `level = 10 + floor((total_xp - 5_000) / 1_000)`
|
|
||||||
pub fn level_for_xp(xp: u64) -> u32 {
|
|
||||||
if xp < 5_000 {
|
|
||||||
(xp / 500) as u32
|
|
||||||
} else {
|
|
||||||
10 + ((xp - 5_000) / 1_000) as u32
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deterministic seed derived from a date, identical for all players globally.
|
/// Deterministic seed derived from a date, identical for all players globally.
|
||||||
/// Used as the RNG seed for the daily-challenge deal.
|
/// Used as the RNG seed for the daily-challenge deal.
|
||||||
pub fn daily_seed_for(date: NaiveDate) -> u64 {
|
pub fn daily_seed_for(date: NaiveDate) -> u64 {
|
||||||
@@ -52,112 +44,6 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
|||||||
base + speed_bonus + no_undo_bonus
|
base + speed_bonus + no_undo_bonus
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persisted player progression state.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct PlayerProgress {
|
|
||||||
pub total_xp: u64,
|
|
||||||
pub level: u32,
|
|
||||||
pub daily_challenge_last_completed: Option<NaiveDate>,
|
|
||||||
pub daily_challenge_streak: u32,
|
|
||||||
pub weekly_goal_progress: HashMap<String, u32>,
|
|
||||||
/// ISO week key (e.g. `"2026-W17"`) the current `weekly_goal_progress`
|
|
||||||
/// counters belong to. When the engine sees a different week it clears
|
|
||||||
/// progress and updates this field.
|
|
||||||
#[serde(default)]
|
|
||||||
pub weekly_goal_week_iso: Option<String>,
|
|
||||||
pub unlocked_card_backs: Vec<usize>,
|
|
||||||
pub unlocked_backgrounds: Vec<usize>,
|
|
||||||
/// Index of the next Challenge-mode seed the player will be served.
|
|
||||||
/// Increments on each Challenge-mode win. Out-of-range values wrap modulo
|
|
||||||
/// `CHALLENGE_SEEDS.len()` at lookup time.
|
|
||||||
#[serde(default)]
|
|
||||||
pub challenge_index: u32,
|
|
||||||
pub last_modified: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for PlayerProgress {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
total_xp: 0,
|
|
||||||
level: 0,
|
|
||||||
daily_challenge_last_completed: None,
|
|
||||||
daily_challenge_streak: 0,
|
|
||||||
weekly_goal_progress: HashMap::new(),
|
|
||||||
weekly_goal_week_iso: None,
|
|
||||||
unlocked_card_backs: vec![0], // back #0 always available
|
|
||||||
unlocked_backgrounds: vec![0], // background #0 always available
|
|
||||||
challenge_index: 0,
|
|
||||||
last_modified: DateTime::UNIX_EPOCH,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlayerProgress {
|
|
||||||
/// Add XP and recompute level. Returns the previous level so callers can
|
|
||||||
/// detect level-up events.
|
|
||||||
pub fn add_xp(&mut self, amount: u64) -> u32 {
|
|
||||||
let prev_level = self.level;
|
|
||||||
self.total_xp = self.total_xp.saturating_add(amount);
|
|
||||||
self.level = level_for_xp(self.total_xp);
|
|
||||||
self.last_modified = Utc::now();
|
|
||||||
prev_level
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `true` if a level-up just occurred (current level > `prev_level`).
|
|
||||||
pub fn leveled_up_from(&self, prev_level: u32) -> bool {
|
|
||||||
self.level > prev_level
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset weekly-goal progress when the ISO week has rolled over.
|
|
||||||
/// No-op if the stored week key already matches `current`.
|
|
||||||
pub fn roll_weekly_goals_if_new_week(&mut self, current: &str) -> bool {
|
|
||||||
if self.weekly_goal_week_iso.as_deref() == Some(current) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
self.weekly_goal_progress.clear();
|
|
||||||
self.weekly_goal_week_iso = Some(current.to_string());
|
|
||||||
self.last_modified = Utc::now();
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Increment progress for `goal_id` by 1, capped at `target`.
|
|
||||||
/// Returns `true` if this call brought the counter from below `target`
|
|
||||||
/// to at-or-above `target` (i.e. just completed the goal).
|
|
||||||
pub fn record_weekly_progress(&mut self, goal_id: &str, target: u32) -> bool {
|
|
||||||
let entry = self.weekly_goal_progress.entry(goal_id.to_string()).or_insert(0);
|
|
||||||
if *entry >= target {
|
|
||||||
// Already complete — do not over-count.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
*entry = entry.saturating_add(1);
|
|
||||||
self.last_modified = Utc::now();
|
|
||||||
*entry >= target
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a daily-challenge completion for `date`.
|
|
||||||
///
|
|
||||||
/// - First completion ever, or a gap of more than one day: streak resets to 1.
|
|
||||||
/// - Completion the day after the previous: streak increments.
|
|
||||||
/// - Same day as the previous: no-op (idempotent — a player can't double-count).
|
|
||||||
///
|
|
||||||
/// Returns `true` if this call recorded a fresh completion (i.e. it wasn't
|
|
||||||
/// the same-day no-op case).
|
|
||||||
pub fn record_daily_completion(&mut self, date: NaiveDate) -> bool {
|
|
||||||
match self.daily_challenge_last_completed {
|
|
||||||
Some(last) if last == date => return false,
|
|
||||||
Some(last) if last + Duration::days(1) == date => {
|
|
||||||
self.daily_challenge_streak = self.daily_challenge_streak.saturating_add(1);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
self.daily_challenge_streak = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.daily_challenge_last_completed = Some(date);
|
|
||||||
self.last_modified = Utc::now();
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Platform-specific default path for `progress.json`.
|
/// Platform-specific default path for `progress.json`.
|
||||||
pub fn progress_file_path() -> Option<PathBuf> {
|
pub fn progress_file_path() -> Option<PathBuf> {
|
||||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
||||||
@@ -186,6 +72,7 @@ pub fn save_progress_to(path: &Path, progress: &PlayerProgress) -> io::Result<()
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use chrono::Duration;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
fn tmp_path(name: &str) -> PathBuf {
|
fn tmp_path(name: &str) -> PathBuf {
|
||||||
|
|||||||
@@ -1,42 +1,137 @@
|
|||||||
//! User settings (persistent).
|
//! User settings (persistent).
|
||||||
//!
|
//!
|
||||||
//! Currently tracks SFX volume and the first-run flag. Other fields from
|
//! Tracks draw mode, volumes, animation speed, visual theme, sync backend, and
|
||||||
//! ARCHITECTURE.md §9 (`draw_mode`, `music_volume`, `theme`, `sync_backend`)
|
//! the first-run flag. All fields use `#[serde(default)]` so settings files
|
||||||
//! will land alongside the systems that need them.
|
//! written by older versions of the game still deserialize correctly.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
|
|
||||||
|
/// Animation playback speed for card transitions.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub enum AnimSpeed {
|
||||||
|
/// Standard animation timing (default).
|
||||||
|
#[default]
|
||||||
|
Normal,
|
||||||
|
/// Roughly 2× faster than Normal.
|
||||||
|
Fast,
|
||||||
|
/// Skip animations entirely — cards teleport to their destinations.
|
||||||
|
Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visual theme applied to the table background and UI chrome.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub enum Theme {
|
||||||
|
/// Classic green felt (default).
|
||||||
|
#[default]
|
||||||
|
Green,
|
||||||
|
/// Blue felt variant.
|
||||||
|
Blue,
|
||||||
|
/// Dark / night-mode variant.
|
||||||
|
Dark,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which sync backend the player has configured.
|
||||||
|
///
|
||||||
|
/// JWT tokens for `SolitaireServer` are stored in the OS keychain via
|
||||||
|
/// `solitaire_data::auth_tokens` — **never** in this struct.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
pub enum SyncBackend {
|
||||||
|
/// No sync — all progress stays on the local device (default).
|
||||||
|
#[default]
|
||||||
|
#[serde(rename = "local")]
|
||||||
|
Local,
|
||||||
|
/// Sync with a self-hosted Solitaire Quest server.
|
||||||
|
#[serde(rename = "solitaire_server")]
|
||||||
|
SolitaireServer {
|
||||||
|
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
|
||||||
|
url: String,
|
||||||
|
/// The player's username on that server.
|
||||||
|
username: String,
|
||||||
|
// JWT tokens are stored in the OS keychain — not here.
|
||||||
|
},
|
||||||
|
/// Google Play Games Services (Android only). Selecting this on non-Android
|
||||||
|
/// platforms silently falls back to `Local` at runtime.
|
||||||
|
#[serde(rename = "google_play_games")]
|
||||||
|
GooglePlayGames,
|
||||||
|
}
|
||||||
|
|
||||||
/// Persistent user settings.
|
/// Persistent user settings.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's main track gain.
|
/// Draw mode selected for new games.
|
||||||
|
#[serde(default = "default_draw_mode")]
|
||||||
|
pub draw_mode: DrawMode,
|
||||||
|
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain.
|
||||||
|
#[serde(default = "default_sfx_volume")]
|
||||||
pub sfx_volume: f32,
|
pub sfx_volume: f32,
|
||||||
|
/// Linear music volume in `[0.0, 1.0]`. Applied to kira's music channel gain.
|
||||||
|
#[serde(default = "default_music_volume")]
|
||||||
|
pub music_volume: f32,
|
||||||
|
/// Speed at which card animations play.
|
||||||
|
#[serde(default)]
|
||||||
|
pub animation_speed: AnimSpeed,
|
||||||
|
/// Visual theme for the table and UI.
|
||||||
|
#[serde(default)]
|
||||||
|
pub theme: Theme,
|
||||||
|
/// Which sync backend is active.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_backend: SyncBackend,
|
||||||
|
/// Index of the card-back design currently in use (0 = default).
|
||||||
|
/// Only indices present in `PlayerProgress::unlocked_card_backs` are valid.
|
||||||
|
#[serde(default)]
|
||||||
|
pub selected_card_back: usize,
|
||||||
|
/// Index of the background design currently in use (0 = default).
|
||||||
|
/// Only indices present in `PlayerProgress::unlocked_backgrounds` are valid.
|
||||||
|
#[serde(default)]
|
||||||
|
pub selected_background: usize,
|
||||||
/// Set to `true` once the player has dismissed the first-run banner.
|
/// Set to `true` once the player has dismissed the first-run banner.
|
||||||
|
#[serde(default)]
|
||||||
pub first_run_complete: bool,
|
pub first_run_complete: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_draw_mode() -> DrawMode {
|
||||||
|
DrawMode::DrawOne
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_sfx_volume() -> f32 {
|
||||||
|
0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_music_volume() -> f32 {
|
||||||
|
0.5
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
sfx_volume: 0.8,
|
draw_mode: DrawMode::DrawOne,
|
||||||
|
sfx_volume: default_sfx_volume(),
|
||||||
|
music_volume: default_music_volume(),
|
||||||
|
animation_speed: AnimSpeed::Normal,
|
||||||
|
theme: Theme::Green,
|
||||||
|
sync_backend: SyncBackend::Local,
|
||||||
|
selected_card_back: 0,
|
||||||
|
selected_background: 0,
|
||||||
first_run_complete: false,
|
first_run_complete: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// Clamps `sfx_volume` into `[0.0, 1.0]` after deserialization or
|
/// Clamps both `sfx_volume` and `music_volume` into `[0.0, 1.0]` after
|
||||||
/// hand-editing of `settings.json`.
|
/// deserialization or hand-editing of `settings.json`.
|
||||||
pub fn sanitized(self) -> Self {
|
pub fn sanitized(self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
||||||
|
music_volume: self.music_volume.clamp(0.0, 1.0),
|
||||||
..self
|
..self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,6 +141,12 @@ impl Settings {
|
|||||||
self.sfx_volume = (self.sfx_volume + delta).clamp(0.0, 1.0);
|
self.sfx_volume = (self.sfx_volume + delta).clamp(0.0, 1.0);
|
||||||
self.sfx_volume
|
self.sfx_volume
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adjust music volume by `delta`, clamped to `[0.0, 1.0]`. Returns the new value.
|
||||||
|
pub fn adjust_music_volume(&mut self, delta: f32) -> f32 {
|
||||||
|
self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0);
|
||||||
|
self.music_volume
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||||
@@ -90,7 +191,12 @@ mod tests {
|
|||||||
fn defaults_are_reasonable() {
|
fn defaults_are_reasonable() {
|
||||||
let s = Settings::default();
|
let s = Settings::default();
|
||||||
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
|
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
|
||||||
|
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
||||||
assert!(!s.first_run_complete);
|
assert!(!s.first_run_complete);
|
||||||
|
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
||||||
|
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
||||||
|
assert_eq!(s.theme, Theme::Green);
|
||||||
|
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -103,17 +209,43 @@ mod tests {
|
|||||||
assert!((s.adjust_sfx_volume(-1.0) - 0.0).abs() < 1e-6);
|
assert!((s.adjust_sfx_volume(-1.0) - 0.0).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjust_music_volume_clamps() {
|
||||||
|
let mut s = Settings::default();
|
||||||
|
s.music_volume = 0.5;
|
||||||
|
assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6);
|
||||||
|
assert!((s.adjust_music_volume(0.5) - 1.0).abs() < 1e-6);
|
||||||
|
assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6);
|
||||||
|
assert!((s.adjust_music_volume(-1.0) - 0.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitized_clamps_out_of_range_volume() {
|
fn sanitized_clamps_out_of_range_volume() {
|
||||||
let s = Settings {
|
let s = Settings {
|
||||||
sfx_volume: 5.0,
|
sfx_volume: 5.0,
|
||||||
|
music_volume: -1.5,
|
||||||
first_run_complete: true,
|
first_run_complete: true,
|
||||||
|
..Settings::default()
|
||||||
}
|
}
|
||||||
.sanitized();
|
.sanitized();
|
||||||
assert_eq!(s.sfx_volume, 1.0);
|
assert_eq!(s.sfx_volume, 1.0);
|
||||||
|
assert_eq!(s.music_volume, 0.0);
|
||||||
assert!(s.first_run_complete);
|
assert!(s.first_run_complete);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitized_clamps_music_volume() {
|
||||||
|
let mut s = Settings::default();
|
||||||
|
s.music_volume = 2.0;
|
||||||
|
let s = s.sanitized();
|
||||||
|
assert_eq!(s.music_volume, 1.0);
|
||||||
|
|
||||||
|
let mut s2 = Settings::default();
|
||||||
|
s2.music_volume = -0.5;
|
||||||
|
let s2 = s2.sanitized();
|
||||||
|
assert_eq!(s2.music_volume, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn round_trip_save_and_load() {
|
fn round_trip_save_and_load() {
|
||||||
let path = tmp_path("round_trip");
|
let path = tmp_path("round_trip");
|
||||||
@@ -121,6 +253,30 @@ mod tests {
|
|||||||
let s = Settings {
|
let s = Settings {
|
||||||
sfx_volume: 0.42,
|
sfx_volume: 0.42,
|
||||||
first_run_complete: true,
|
first_run_complete: true,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert_eq!(loaded, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_save_and_load_full_settings() {
|
||||||
|
let path = tmp_path("round_trip_full");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let s = Settings {
|
||||||
|
draw_mode: DrawMode::DrawThree,
|
||||||
|
sfx_volume: 0.3,
|
||||||
|
music_volume: 0.7,
|
||||||
|
animation_speed: AnimSpeed::Fast,
|
||||||
|
theme: Theme::Dark,
|
||||||
|
sync_backend: SyncBackend::SolitaireServer {
|
||||||
|
url: "https://example.com".to_string(),
|
||||||
|
username: "testuser".to_string(),
|
||||||
|
},
|
||||||
|
selected_card_back: 0,
|
||||||
|
selected_background: 0,
|
||||||
|
first_run_complete: true,
|
||||||
};
|
};
|
||||||
save_settings_to(&path, &s).expect("save");
|
save_settings_to(&path, &s).expect("save");
|
||||||
let loaded = load_settings_from(&path);
|
let loaded = load_settings_from(&path);
|
||||||
@@ -142,4 +298,25 @@ mod tests {
|
|||||||
let s = load_settings_from(&path);
|
let s = load_settings_from(&path);
|
||||||
assert_eq!(s, Settings::default());
|
assert_eq!(s, Settings::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_from_old_format_uses_defaults_for_new_fields() {
|
||||||
|
// Simulate a settings.json written by an older version that only had
|
||||||
|
// sfx_volume and first_run_complete.
|
||||||
|
let path = tmp_path("old_format");
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
br#"{ "sfx_volume": 0.6, "first_run_complete": true }"#,
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
let s = load_settings_from(&path);
|
||||||
|
assert!((s.sfx_volume - 0.6).abs() < 1e-6);
|
||||||
|
assert!(s.first_run_complete);
|
||||||
|
// New fields should fall back to their defaults.
|
||||||
|
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
||||||
|
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
||||||
|
assert_eq!(s.theme, Theme::Green);
|
||||||
|
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||||
|
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-59
@@ -1,51 +1,24 @@
|
|||||||
//! Player statistics — persisted to `stats.json` between sessions.
|
//! Player statistics — persisted to `stats.json` between sessions.
|
||||||
|
//!
|
||||||
|
//! [`StatsSnapshot`] is defined in `solitaire_sync` and re-exported here.
|
||||||
|
//! This module adds the [`StatsExt`] extension trait, which supplies the
|
||||||
|
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::Utc;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
|
||||||
/// Cumulative game statistics. Stored as `stats.json` in the platform data dir.
|
pub use solitaire_sync::StatsSnapshot;
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct StatsSnapshot {
|
|
||||||
pub games_played: u32,
|
|
||||||
pub games_won: u32,
|
|
||||||
pub games_lost: u32,
|
|
||||||
pub win_streak_current: u32,
|
|
||||||
pub win_streak_best: u32,
|
|
||||||
/// Rolling average of win times in seconds.
|
|
||||||
pub avg_time_seconds: u64,
|
|
||||||
/// Fastest win time. `u64::MAX` means no wins yet.
|
|
||||||
pub fastest_win_seconds: u64,
|
|
||||||
/// Sum of all winning scores.
|
|
||||||
pub lifetime_score: u64,
|
|
||||||
pub best_single_score: u32,
|
|
||||||
pub draw_one_wins: u32,
|
|
||||||
pub draw_three_wins: u32,
|
|
||||||
pub last_modified: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for StatsSnapshot {
|
/// Extension trait providing game-logic mutation helpers for [`StatsSnapshot`].
|
||||||
fn default() -> Self {
|
///
|
||||||
Self {
|
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
|
||||||
games_played: 0,
|
pub trait StatsExt {
|
||||||
games_won: 0,
|
|
||||||
games_lost: 0,
|
|
||||||
win_streak_current: 0,
|
|
||||||
win_streak_best: 0,
|
|
||||||
avg_time_seconds: 0,
|
|
||||||
fastest_win_seconds: u64::MAX,
|
|
||||||
lifetime_score: 0,
|
|
||||||
best_single_score: 0,
|
|
||||||
draw_one_wins: 0,
|
|
||||||
draw_three_wins: 0,
|
|
||||||
last_modified: DateTime::UNIX_EPOCH,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StatsSnapshot {
|
|
||||||
/// Record a completed win. Updates all relevant counters and rolling averages.
|
/// Record a completed win. Updates all relevant counters and rolling averages.
|
||||||
pub 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatsExt for StatsSnapshot {
|
||||||
|
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
|
||||||
let prev_wins = self.games_won;
|
let prev_wins = self.games_won;
|
||||||
self.games_played += 1;
|
self.games_played += 1;
|
||||||
self.games_won += 1;
|
self.games_won += 1;
|
||||||
@@ -78,23 +51,6 @@ impl StatsSnapshot {
|
|||||||
|
|
||||||
self.last_modified = Utc::now();
|
self.last_modified = Utc::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an abandoned game (player started a new game without winning).
|
|
||||||
pub fn record_abandoned(&mut self) {
|
|
||||||
self.games_played += 1;
|
|
||||||
self.games_lost += 1;
|
|
||||||
self.win_streak_current = 0;
|
|
||||||
self.last_modified = Utc::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Win percentage as 0–100, or `None` if no games played.
|
|
||||||
pub fn win_rate(&self) -> Option<f32> {
|
|
||||||
if self.games_played == 0 {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(self.games_won as f32 / self.games_played as f32 * 100.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Atomic file I/O for `StatsSnapshot` persistence.
|
//! Atomic file I/O for persisted game data.
|
||||||
//!
|
//!
|
||||||
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
||||||
//! loss during a write never corrupts the saved data.
|
//! loss during a write never corrupts the saved data.
|
||||||
@@ -7,10 +7,13 @@ use std::fs;
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
use crate::stats::StatsSnapshot;
|
use crate::stats::StatsSnapshot;
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||||
const STATS_FILE_NAME: &str = "stats.json";
|
const STATS_FILE_NAME: &str = "stats.json";
|
||||||
|
const GAME_STATE_FILE_NAME: &str = "game_state.json";
|
||||||
|
|
||||||
/// Returns the platform-specific path to `stats.json`, or `None` if
|
/// Returns the platform-specific path to `stats.json`, or `None` if
|
||||||
/// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
|
/// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||||
@@ -58,10 +61,96 @@ pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
|
|||||||
save_stats_to(&path, stats)
|
save_stats_to(&path, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// In-progress game state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns the platform-specific path to `game_state.json`, or `None` if
|
||||||
|
/// `dirs::data_dir()` is unavailable.
|
||||||
|
pub fn game_state_file_path() -> Option<PathBuf> {
|
||||||
|
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
|
||||||
|
/// missing, corrupt, or represents a finished game.
|
||||||
|
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
|
||||||
|
let data = fs::read(path).ok()?;
|
||||||
|
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
||||||
|
if gs.is_won {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(gs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
|
||||||
|
/// because a completed game should not be resumed.
|
||||||
|
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
|
||||||
|
if gs.is_won {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(gs).map_err(io::Error::other)?;
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, json.as_bytes())?;
|
||||||
|
fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the game state file (called on win, loss, or new-game start).
|
||||||
|
/// Silently ignores `NotFound` errors.
|
||||||
|
pub fn delete_game_state_at(path: &Path) -> io::Result<()> {
|
||||||
|
match fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove any leftover `*.json.tmp` files in the app data directory.
|
||||||
|
///
|
||||||
|
/// These can be left behind if the process crashes between the write and rename
|
||||||
|
/// in an atomic save. Safe to call on startup; missing or unreadable entries
|
||||||
|
/// are silently skipped.
|
||||||
|
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
|
||||||
|
let dir = match dirs::data_dir() {
|
||||||
|
Some(d) => d.join(APP_DIR_NAME),
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_tmp_files_in(&dir);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inner helper: delete `*.json.tmp` entries inside `dir`.
|
||||||
|
///
|
||||||
|
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||||
|
fn cleanup_tmp_files_in(dir: &Path) {
|
||||||
|
if let Ok(entries) = fs::read_dir(dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(|n| n.ends_with(".json.tmp"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::stats::StatsSnapshot;
|
use crate::stats::{StatsExt, StatsSnapshot};
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
@@ -109,4 +198,138 @@ mod tests {
|
|||||||
let stats = load_stats_from(&path);
|
let stats = load_stats_from(&path);
|
||||||
assert_eq!(stats, StatsSnapshot::default());
|
assert_eq!(stats, StatsSnapshot::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test the core cleanup logic by creating `.json.tmp` files in a temporary
|
||||||
|
/// directory, running the cleanup loop manually, and verifying removal.
|
||||||
|
#[test]
|
||||||
|
fn cleanup_removes_tmp_files() {
|
||||||
|
let dir = env::temp_dir().join("solitaire_cleanup_test");
|
||||||
|
fs::create_dir_all(&dir).expect("create test dir");
|
||||||
|
|
||||||
|
// Create a pair of .json.tmp files and one regular file that must survive.
|
||||||
|
let tmp1 = dir.join("stats.json.tmp");
|
||||||
|
let tmp2 = dir.join("progress.json.tmp");
|
||||||
|
let keep = dir.join("settings.json");
|
||||||
|
fs::write(&tmp1, b"orphan1").expect("write tmp1");
|
||||||
|
fs::write(&tmp2, b"orphan2").expect("write tmp2");
|
||||||
|
fs::write(&keep, b"{}").expect("write keep");
|
||||||
|
|
||||||
|
// Run the cleanup logic directly against our test directory.
|
||||||
|
cleanup_tmp_files_in(&dir);
|
||||||
|
|
||||||
|
assert!(!tmp1.exists(), "stats.json.tmp should have been removed");
|
||||||
|
assert!(!tmp2.exists(), "progress.json.tmp should have been removed");
|
||||||
|
assert!(keep.exists(), "settings.json must not be removed");
|
||||||
|
|
||||||
|
// Tidy up.
|
||||||
|
let _ = fs::remove_file(&keep);
|
||||||
|
let _ = fs::remove_dir(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calling `cleanup_orphaned_tmp_files` on a box with no app data dir is a
|
||||||
|
/// no-op and must not return an error.
|
||||||
|
#[test]
|
||||||
|
fn cleanup_on_nonexistent_dir_is_ok() {
|
||||||
|
// We can't control whether the real app dir exists in the test
|
||||||
|
// environment, but the public function must at least not panic or
|
||||||
|
// return an Err when the directory is absent.
|
||||||
|
// The real implementation returns Ok(()) for missing dirs.
|
||||||
|
let result = cleanup_orphaned_tmp_files();
|
||||||
|
// The function is allowed to succeed whether or not the dir exists.
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// game_state persistence tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn gs_path(name: &str) -> PathBuf {
|
||||||
|
env::temp_dir().join(format!("solitaire_test_gs_{name}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_state_round_trip() {
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
let path = gs_path("round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let gs = GameState::new(12345, DrawMode::DrawOne);
|
||||||
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
|
|
||||||
|
let loaded = load_game_state_from(&path).expect("load");
|
||||||
|
assert_eq!(loaded.seed, gs.seed);
|
||||||
|
assert_eq!(loaded.draw_mode, gs.draw_mode);
|
||||||
|
assert!(!loaded.is_won);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_game_state_missing_file_returns_none() {
|
||||||
|
let path = gs_path("missing_xyz");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
assert!(load_game_state_from(&path).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_game_state_corrupt_file_returns_none() {
|
||||||
|
let path = gs_path("corrupt");
|
||||||
|
fs::write(&path, b"not valid json!!!").expect("write");
|
||||||
|
assert!(load_game_state_from(&path).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_game_state_skips_won_games() {
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
let path = gs_path("won_skip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let mut gs = GameState::new(99, DrawMode::DrawOne);
|
||||||
|
gs.is_won = true;
|
||||||
|
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
|
||||||
|
assert!(!path.exists(), "should not have written a file for a won game");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_game_state_ignores_won_games() {
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
let path = gs_path("won_load");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
// Write a won game directly (bypassing save_game_state_to's guard).
|
||||||
|
let mut gs = GameState::new(77, DrawMode::DrawOne);
|
||||||
|
gs.is_won = true;
|
||||||
|
let json = serde_json::to_string_pretty(&gs).unwrap();
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, json.as_bytes()).unwrap();
|
||||||
|
fs::rename(&tmp, &path).unwrap();
|
||||||
|
|
||||||
|
assert!(load_game_state_from(&path).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_game_state_removes_file() {
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
let path = gs_path("delete");
|
||||||
|
let gs = GameState::new(1, DrawMode::DrawOne);
|
||||||
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
|
assert!(path.exists());
|
||||||
|
delete_game_state_at(&path).expect("delete");
|
||||||
|
assert!(!path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_game_state_missing_file_is_ok() {
|
||||||
|
let path = gs_path("delete_missing");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
assert!(delete_game_state_at(&path).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_game_state_is_atomic() {
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
let path = gs_path("atomic");
|
||||||
|
let gs = GameState::new(55, DrawMode::DrawThree);
|
||||||
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,487 @@
|
|||||||
|
//! Concrete [`SyncProvider`] implementations and a factory for constructing
|
||||||
|
//! the correct provider from a [`SyncBackend`] setting.
|
||||||
|
//!
|
||||||
|
//! # Backends
|
||||||
|
//!
|
||||||
|
//! | Struct | Backend |
|
||||||
|
//! |---|---|
|
||||||
|
//! | [`LocalOnlyProvider`] | No-op; used when sync is disabled |
|
||||||
|
//! | [`SolitaireServerClient`] | Self-hosted Solitaire Quest server (JWT auth) |
|
||||||
|
//!
|
||||||
|
//! Use [`provider_for_backend`] to obtain a `Box<dyn SyncProvider + Send + Sync>`
|
||||||
|
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
||||||
|
settings::SyncBackend,
|
||||||
|
SyncError, SyncProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LocalOnlyProvider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A no-op sync provider used when the player has not configured any backend.
|
||||||
|
///
|
||||||
|
/// Both [`pull`](SyncProvider::pull) and [`push`](SyncProvider::push) always
|
||||||
|
/// return [`SyncError::UnsupportedPlatform`], so callers know no remote data
|
||||||
|
/// is available without treating it as a fatal error.
|
||||||
|
pub struct LocalOnlyProvider;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SyncProvider for LocalOnlyProvider {
|
||||||
|
async fn pull(&self) -> Result<SyncPayload, SyncError> {
|
||||||
|
Err(SyncError::UnsupportedPlatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn push(&self, _payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
|
||||||
|
Err(SyncError::UnsupportedPlatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend_name(&self) -> &'static str {
|
||||||
|
"local"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_authenticated(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SolitaireServerClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// HTTP sync client for the self-hosted Solitaire Quest server.
|
||||||
|
///
|
||||||
|
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
|
||||||
|
/// client automatically attempts a token refresh and retries the request once
|
||||||
|
/// before returning an error.
|
||||||
|
pub struct SolitaireServerClient {
|
||||||
|
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
|
||||||
|
/// Trailing slashes are stripped on construction.
|
||||||
|
base_url: String,
|
||||||
|
/// The player's username on this server — used as the keychain key.
|
||||||
|
username: String,
|
||||||
|
/// Shared `reqwest` client (keeps connection pools alive across calls).
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SolitaireServerClient {
|
||||||
|
/// Construct a new client for the given server URL and username.
|
||||||
|
///
|
||||||
|
/// The `base_url` trailing slash is stripped so URL construction is
|
||||||
|
/// consistent regardless of how the user entered the setting.
|
||||||
|
pub fn new(base_url: impl Into<String>, username: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||||
|
username: username.into(),
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to refresh the access token using the stored refresh token.
|
||||||
|
///
|
||||||
|
/// On success the new access token is persisted to the OS keychain,
|
||||||
|
/// replacing the previous one. The refresh token itself is unchanged.
|
||||||
|
async fn refresh_token(&self) -> Result<(), SyncError> {
|
||||||
|
let refresh = load_refresh_token(&self.username)
|
||||||
|
.map_err(|e| SyncError::Auth(e.to_string()))?;
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/auth/refresh", self.base_url))
|
||||||
|
.json(&serde_json::json!({ "refresh_token": refresh }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(SyncError::Auth("refresh failed".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let new_access = body["access_token"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?;
|
||||||
|
|
||||||
|
// store_tokens replaces both access and refresh; we keep the old
|
||||||
|
// refresh token unchanged so its 30-day TTL is preserved.
|
||||||
|
store_tokens(&self.username, new_access, &refresh)
|
||||||
|
.map_err(|e| SyncError::Auth(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the current access token from the OS keychain.
|
||||||
|
fn access_token(&self) -> Result<String, SyncError> {
|
||||||
|
load_access_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SyncProvider for SolitaireServerClient {
|
||||||
|
/// Fetch the latest sync payload from the server.
|
||||||
|
///
|
||||||
|
/// On HTTP 401 the client refreshes the access token and retries once.
|
||||||
|
async fn pull(&self) -> Result<SyncPayload, SyncError> {
|
||||||
|
let token = self.access_token()?;
|
||||||
|
let url = format!("{}/api/sync/pull", self.base_url);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
// Token expired — refresh and retry once.
|
||||||
|
self.refresh_token().await?;
|
||||||
|
let new_token = self.access_token()?;
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(new_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
return extract_pull_body(resp).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_pull_body(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push the local payload to the server and return the merged response.
|
||||||
|
///
|
||||||
|
/// On HTTP 401 the client refreshes the access token and retries once.
|
||||||
|
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
|
||||||
|
let token = self.access_token()?;
|
||||||
|
let url = format!("{}/api/sync/push", self.base_url);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.json(payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
// Token expired — refresh and retry once.
|
||||||
|
self.refresh_token().await?;
|
||||||
|
let new_token = self.access_token()?;
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(new_token)
|
||||||
|
.json(payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
return extract_push_body(resp).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_push_body(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend_name(&self) -> &'static str {
|
||||||
|
"solitaire_server"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if a valid access token is present in the OS keychain.
|
||||||
|
fn is_authenticated(&self) -> bool {
|
||||||
|
load_access_token(&self.username).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch today's daily challenge from the server.
|
||||||
|
///
|
||||||
|
/// Does not require authentication — the endpoint is public. Returns `None`
|
||||||
|
/// on any non-success HTTP status so the caller falls back to the local seed.
|
||||||
|
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError> {
|
||||||
|
let url = format!("{}/api/daily-challenge", self.base_url);
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if resp.status().is_success() {
|
||||||
|
let goal: ChallengeGoal = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||||
|
Ok(Some(goal))
|
||||||
|
} else {
|
||||||
|
// Non-fatal — caller will use the locally computed seed instead.
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
|
||||||
|
let token = self.access_token()?;
|
||||||
|
let url = format!("{}/api/leaderboard/opt-in", self.base_url);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.json(&serde_json::json!({ "display_name": display_name }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
self.refresh_token().await?;
|
||||||
|
let new_token = self.access_token()?;
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(new_token)
|
||||||
|
.json(&serde_json::json!({ "display_name": display_name }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(SyncError::Auth(format!("opt-in failed: {}", resp.status())));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(SyncError::Auth(format!("opt-in failed: {}", resp.status())));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
|
||||||
|
let token = self.access_token()?;
|
||||||
|
let url = format!("{}/api/leaderboard/opt-in", self.base_url);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.delete(&url)
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
self.refresh_token().await?;
|
||||||
|
let new_token = self.access_token()?;
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.delete(&url)
|
||||||
|
.bearer_auth(new_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status())));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status())));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||||
|
let token = self.access_token()?;
|
||||||
|
let url = format!("{}/api/account", self.base_url);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.delete(&url)
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
self.refresh_token().await?;
|
||||||
|
let new_token = self.access_token()?;
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.delete(&url)
|
||||||
|
.bearer_auth(new_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(SyncError::Auth(format!("delete account failed: {}", resp.status())));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(SyncError::Auth(format!("delete account failed: {}", resp.status())));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||||
|
let token = self.access_token()?;
|
||||||
|
let url = format!("{}/api/leaderboard", self.base_url);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
self.refresh_token().await?;
|
||||||
|
let new_token = self.access_token()?;
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(new_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
return extract_leaderboard_body(resp).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_leaderboard_body(resp).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response extraction helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Deserialize a pull response body as [`SyncResponse`] and return its
|
||||||
|
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
|
||||||
|
async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncError> {
|
||||||
|
let status = resp.status();
|
||||||
|
if status.is_success() {
|
||||||
|
let sync_resp: SyncResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||||
|
Ok(sync_resp.merged)
|
||||||
|
} else {
|
||||||
|
Err(SyncError::Auth(format!("server returned {status}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
|
||||||
|
async fn extract_leaderboard_body(resp: reqwest::Response) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||||
|
let status = resp.status();
|
||||||
|
if status.is_success() {
|
||||||
|
resp.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Serialization(e.to_string()))
|
||||||
|
} else {
|
||||||
|
Err(SyncError::Network(format!("server returned {status}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a push response body as [`SyncResponse`], or map non-200
|
||||||
|
/// statuses to the appropriate [`SyncError`].
|
||||||
|
async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, SyncError> {
|
||||||
|
let status = resp.status();
|
||||||
|
if status.is_success() {
|
||||||
|
resp.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Serialization(e.to_string()))
|
||||||
|
} else {
|
||||||
|
Err(SyncError::Auth(format!("server returned {status}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Construct the appropriate [`SyncProvider`] for the given [`SyncBackend`]
|
||||||
|
/// setting.
|
||||||
|
///
|
||||||
|
/// This is the **one** place in the codebase that matches on [`SyncBackend`]
|
||||||
|
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
|
||||||
|
/// and remains backend-agnostic.
|
||||||
|
///
|
||||||
|
/// `GooglePlayGames` is Android-only; on desktop it silently falls back to
|
||||||
|
/// [`LocalOnlyProvider`].
|
||||||
|
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
|
||||||
|
match backend {
|
||||||
|
SyncBackend::Local => Box::new(LocalOnlyProvider),
|
||||||
|
SyncBackend::SolitaireServer { url, username } => {
|
||||||
|
Box::new(SolitaireServerClient::new(url.clone(), username.clone()))
|
||||||
|
}
|
||||||
|
SyncBackend::GooglePlayGames => {
|
||||||
|
// GPGS is Android-only; fall back to no-op on desktop.
|
||||||
|
Box::new(LocalOnlyProvider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_provider_backend_name() {
|
||||||
|
assert_eq!(LocalOnlyProvider.backend_name(), "local");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_provider_not_authenticated() {
|
||||||
|
assert!(!LocalOnlyProvider.is_authenticated());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_provider_pull_returns_unsupported() {
|
||||||
|
let err = LocalOnlyProvider.pull().await.unwrap_err();
|
||||||
|
assert!(matches!(err, SyncError::UnsupportedPlatform));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_client_strips_trailing_slash() {
|
||||||
|
let c = SolitaireServerClient::new("https://example.com/", "alice");
|
||||||
|
assert_eq!(c.base_url, "https://example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_client_backend_name() {
|
||||||
|
let c = SolitaireServerClient::new("https://example.com", "alice");
|
||||||
|
assert_eq!(c.backend_name(), "solitaire_server");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_local_returns_local_provider() {
|
||||||
|
let provider = provider_for_backend(&SyncBackend::Local);
|
||||||
|
assert_eq!(provider.backend_name(), "local");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_gpgs_falls_back_to_local() {
|
||||||
|
let provider = provider_for_backend(&SyncBackend::GooglePlayGames);
|
||||||
|
assert_eq!(provider.backend_name(), "local");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_server_returns_server_client() {
|
||||||
|
let provider = provider_for_backend(&SyncBackend::SolitaireServer {
|
||||||
|
url: "https://example.com".to_string(),
|
||||||
|
username: "bob".to_string(),
|
||||||
|
});
|
||||||
|
assert_eq!(provider.backend_name(), "solitaire_server");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
|
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
|
||||||
|
|
||||||
use chrono::{Datelike, NaiveDate};
|
use chrono::{Datelike, NaiveDate};
|
||||||
|
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;
|
||||||
@@ -17,6 +18,8 @@ pub enum WeeklyGoalKind {
|
|||||||
WinWithoutUndo,
|
WinWithoutUndo,
|
||||||
/// A win in strictly fewer than `seconds` seconds counts.
|
/// A win in strictly fewer than `seconds` seconds counts.
|
||||||
WinUnder { seconds: u64 },
|
WinUnder { seconds: u64 },
|
||||||
|
/// A win in Draw-3 mode counts.
|
||||||
|
WinDrawThree,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Static metadata for a single weekly goal.
|
/// Static metadata for a single weekly goal.
|
||||||
@@ -29,10 +32,11 @@ pub struct WeeklyGoalDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Per-event facts a goal needs to decide whether it matched.
|
/// Per-event facts a goal needs to decide whether it matched.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WeeklyGoalContext {
|
pub struct WeeklyGoalContext {
|
||||||
pub time_seconds: u64,
|
pub time_seconds: u64,
|
||||||
pub used_undo: bool,
|
pub used_undo: bool,
|
||||||
|
pub draw_mode: DrawMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WeeklyGoalDef {
|
impl WeeklyGoalDef {
|
||||||
@@ -43,6 +47,7 @@ impl WeeklyGoalDef {
|
|||||||
WeeklyGoalKind::WinGame => true,
|
WeeklyGoalKind::WinGame => true,
|
||||||
WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo,
|
WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo,
|
||||||
WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds,
|
WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds,
|
||||||
|
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawMode::DrawThree,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +72,18 @@ pub const WEEKLY_GOALS: &[WeeklyGoalDef] = &[
|
|||||||
target: 3,
|
target: 3,
|
||||||
kind: WeeklyGoalKind::WinUnder { seconds: 180 },
|
kind: WeeklyGoalKind::WinUnder { seconds: 180 },
|
||||||
},
|
},
|
||||||
|
WeeklyGoalDef {
|
||||||
|
id: "weekly_1_under_five",
|
||||||
|
description: "Win 1 game in under 5 minutes this week",
|
||||||
|
target: 1,
|
||||||
|
kind: WeeklyGoalKind::WinUnder { seconds: 300 },
|
||||||
|
},
|
||||||
|
WeeklyGoalDef {
|
||||||
|
id: "weekly_draw_three",
|
||||||
|
description: "Win 1 Draw-3 game this week",
|
||||||
|
target: 1,
|
||||||
|
kind: WeeklyGoalKind::WinDrawThree,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Stable identifier for the ISO week containing `date`, e.g. `"2026-W17"`.
|
/// Stable identifier for the ISO week containing `date`, e.g. `"2026-W17"`.
|
||||||
@@ -89,6 +106,15 @@ mod tests {
|
|||||||
WeeklyGoalContext {
|
WeeklyGoalContext {
|
||||||
time_seconds: time,
|
time_seconds: time,
|
||||||
used_undo: undo,
|
used_undo: undo,
|
||||||
|
draw_mode: DrawMode::DrawOne,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ctx_d3(time: u64) -> WeeklyGoalContext {
|
||||||
|
WeeklyGoalContext {
|
||||||
|
time_seconds: time,
|
||||||
|
used_undo: false,
|
||||||
|
draw_mode: DrawMode::DrawThree,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,4 +171,20 @@ mod tests {
|
|||||||
assert!(key.starts_with("2026-W"));
|
assert!(key.starts_with("2026-W"));
|
||||||
assert_eq!(key.len(), 8);
|
assert_eq!(key.len(), 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn under_five_matches_wins_below_300_seconds() {
|
||||||
|
let g = weekly_goal_by_id("weekly_1_under_five").unwrap();
|
||||||
|
assert!(g.matches(&ctx(0, false)));
|
||||||
|
assert!(g.matches(&ctx(299, true)));
|
||||||
|
assert!(!g.matches(&ctx(300, false)));
|
||||||
|
assert!(!g.matches(&ctx(999, false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_three_goal_matches_only_draw_three_wins() {
|
||||||
|
let g = weekly_goal_by_id("weekly_draw_three").unwrap();
|
||||||
|
assert!(g.matches(&ctx_d3(600)));
|
||||||
|
assert!(!g.matches(&ctx(600, false)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,10 @@ bevy = { workspace = true }
|
|||||||
kira = { workspace = true }
|
kira = { workspace = true }
|
||||||
solitaire_core = { workspace = true }
|
solitaire_core = { workspace = true }
|
||||||
solitaire_data = { workspace = true }
|
solitaire_data = { workspace = true }
|
||||||
|
solitaire_sync = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
|||||||
@@ -10,18 +10,23 @@ use std::path::PathBuf;
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use chrono::{Local, Timelike, Utc};
|
use chrono::{Local, Timelike, Utc};
|
||||||
use solitaire_core::achievement::{
|
use solitaire_core::achievement::{
|
||||||
achievement_by_id, check_achievements, AchievementContext, ALL_ACHIEVEMENTS,
|
achievement_by_id, check_achievements, AchievementContext, Reward, ALL_ACHIEVEMENTS,
|
||||||
};
|
};
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
|
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
|
||||||
|
save_progress_to,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
use crate::events::{AchievementUnlockedEvent, GameWonEvent, XpAwardedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressUpdate};
|
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
|
|
||||||
|
/// Marker on the achievements overlay root node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct AchievementsScreen;
|
||||||
|
|
||||||
/// All per-player achievement records (one per known achievement).
|
/// All per-player achievement records (one per known achievement).
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
||||||
@@ -67,6 +72,7 @@ impl Plugin for AchievementPlugin {
|
|||||||
.insert_resource(AchievementsStoragePath(self.storage_path.clone()))
|
.insert_resource(AchievementsStoragePath(self.storage_path.clone()))
|
||||||
.add_event::<AchievementUnlockedEvent>()
|
.add_event::<AchievementUnlockedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
|
.add_event::<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).
|
||||||
@@ -76,7 +82,8 @@ impl Plugin for AchievementPlugin {
|
|||||||
.after(GameMutation)
|
.after(GameMutation)
|
||||||
.after(StatsUpdate)
|
.after(StatsUpdate)
|
||||||
.after(ProgressUpdate),
|
.after(ProgressUpdate),
|
||||||
);
|
)
|
||||||
|
.add_systems(Update, toggle_achievements_screen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,11 +91,14 @@ impl Plugin for AchievementPlugin {
|
|||||||
fn evaluate_on_win(
|
fn evaluate_on_win(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: EventReader<GameWonEvent>,
|
||||||
mut unlocks: EventWriter<AchievementUnlockedEvent>,
|
mut unlocks: EventWriter<AchievementUnlockedEvent>,
|
||||||
|
mut levelups: EventWriter<LevelUpEvent>,
|
||||||
|
mut xp_awarded: EventWriter<XpAwardedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
stats: Res<StatsResource>,
|
stats: Res<StatsResource>,
|
||||||
progress: Res<ProgressResource>,
|
|
||||||
path: Res<AchievementsStoragePath>,
|
path: Res<AchievementsStoragePath>,
|
||||||
|
progress_path: Res<ProgressStoragePath>,
|
||||||
mut achievements: ResMut<AchievementsResource>,
|
mut achievements: ResMut<AchievementsResource>,
|
||||||
|
mut progress: ResMut<ProgressResource>,
|
||||||
) {
|
) {
|
||||||
let Some(ev) = wins.read().last() else {
|
let Some(ev) = wins.read().last() else {
|
||||||
return;
|
return;
|
||||||
@@ -106,6 +116,8 @@ fn evaluate_on_win(
|
|||||||
last_win_time_seconds: ev.time_seconds,
|
last_win_time_seconds: ev.time_seconds,
|
||||||
last_win_used_undo: game.0.undo_count > 0,
|
last_win_used_undo: game.0.undo_count > 0,
|
||||||
wall_clock_hour: Some(Local::now().hour()),
|
wall_clock_hour: Some(Local::now().hour()),
|
||||||
|
last_win_recycle_count: game.0.recycle_count,
|
||||||
|
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
|
||||||
};
|
};
|
||||||
|
|
||||||
let hits = check_achievements(&ctx);
|
let hits = check_achievements(&ctx);
|
||||||
@@ -114,7 +126,9 @@ fn evaluate_on_win(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let mut changed = false;
|
let mut achievements_changed = false;
|
||||||
|
let mut progress_changed = false;
|
||||||
|
|
||||||
for def in hits {
|
for def in hits {
|
||||||
let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else {
|
let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -123,17 +137,60 @@ fn evaluate_on_win(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
record.unlock(now);
|
record.unlock(now);
|
||||||
changed = true;
|
achievements_changed = true;
|
||||||
unlocks.send(AchievementUnlockedEvent(def.id.to_string()));
|
|
||||||
|
// Grant the reward on first unlock.
|
||||||
|
if !record.reward_granted {
|
||||||
|
if let Some(reward) = def.reward {
|
||||||
|
match reward {
|
||||||
|
Reward::CardBack(idx) => {
|
||||||
|
if !progress.0.unlocked_card_backs.contains(&idx) {
|
||||||
|
progress.0.unlocked_card_backs.push(idx);
|
||||||
|
progress_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Reward::Background(idx) => {
|
||||||
|
if !progress.0.unlocked_backgrounds.contains(&idx) {
|
||||||
|
progress.0.unlocked_backgrounds.push(idx);
|
||||||
|
progress_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Reward::BonusXp(amount) => {
|
||||||
|
xp_awarded.send(XpAwardedEvent { amount });
|
||||||
|
let prev_level = progress.0.add_xp(amount);
|
||||||
|
if progress.0.leveled_up_from(prev_level) {
|
||||||
|
levelups.send(LevelUpEvent {
|
||||||
|
previous_level: prev_level,
|
||||||
|
new_level: progress.0.level,
|
||||||
|
total_xp: progress.0.total_xp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
progress_changed = true;
|
||||||
|
}
|
||||||
|
Reward::Badge => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record.reward_granted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if changed {
|
unlocks.send(AchievementUnlockedEvent(record.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if achievements_changed {
|
||||||
if let Some(target) = &path.0 {
|
if let Some(target) = &path.0 {
|
||||||
if let Err(e) = save_achievements_to(target, &achievements.0) {
|
if let Err(e) = save_achievements_to(target, &achievements.0) {
|
||||||
warn!("failed to save achievements: {e}");
|
warn!("failed to save achievements: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if progress_changed {
|
||||||
|
if let Some(target) = &progress_path.0 {
|
||||||
|
if let Err(e) = save_progress_to(target, &progress.0) {
|
||||||
|
warn!("failed to save progress after reward: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience: resolve an achievement ID to its human-readable name.
|
/// Convenience: resolve an achievement ID to its human-readable name.
|
||||||
@@ -144,6 +201,165 @@ pub fn display_name_for(id: &str) -> String {
|
|||||||
.unwrap_or_else(|| id.to_string())
|
.unwrap_or_else(|| id.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toggle the achievements overlay with the `A` key.
|
||||||
|
fn toggle_achievements_screen(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
achievements: Res<AchievementsResource>,
|
||||||
|
screens: Query<Entity, With<AchievementsScreen>>,
|
||||||
|
) {
|
||||||
|
if !keys.just_pressed(KeyCode::KeyA) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(entity) = screens.get_single() {
|
||||||
|
commands.entity(entity).despawn_recursive();
|
||||||
|
} else {
|
||||||
|
spawn_achievements_screen(&mut commands, &achievements.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_achievements_screen(commands: &mut Commands, records: &[AchievementRecord]) {
|
||||||
|
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
|
||||||
|
let total = ALL_ACHIEVEMENTS.len();
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
AchievementsScreen,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Percent(0.0),
|
||||||
|
top: Val::Percent(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.82)),
|
||||||
|
ZIndex(210),
|
||||||
|
))
|
||||||
|
.with_children(|root| {
|
||||||
|
root.spawn((
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
padding: UiRect::all(Val::Px(28.0)),
|
||||||
|
row_gap: Val::Px(8.0),
|
||||||
|
min_width: Val::Px(380.0),
|
||||||
|
max_height: Val::Percent(80.0),
|
||||||
|
overflow: Overflow::clip_y(),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
|
||||||
|
BorderRadius::all(Val::Px(8.0)),
|
||||||
|
))
|
||||||
|
.with_children(|card| {
|
||||||
|
// Header
|
||||||
|
card.spawn((
|
||||||
|
Text::new(format!(
|
||||||
|
"Achievements ({}/{})",
|
||||||
|
unlocked.len(),
|
||||||
|
total
|
||||||
|
)),
|
||||||
|
TextFont { font_size: 26.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
card.spawn((
|
||||||
|
Text::new("Press A to close"),
|
||||||
|
TextFont { font_size: 14.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.55, 0.55, 0.60)),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
card.spawn((
|
||||||
|
Node {
|
||||||
|
height: Val::Px(1.0),
|
||||||
|
margin: UiRect::vertical(Val::Px(6.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Achievement rows — unlocked first, then locked
|
||||||
|
let mut sorted: Vec<_> = records.iter().collect();
|
||||||
|
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
|
||||||
|
|
||||||
|
for record in &sorted {
|
||||||
|
let def = achievement_by_id(&record.id);
|
||||||
|
let (name, description) = def
|
||||||
|
.map(|d| (d.name, d.description))
|
||||||
|
.unwrap_or((&record.id, ""));
|
||||||
|
|
||||||
|
// Hide secret locked achievements
|
||||||
|
let is_secret = def.map(|d| d.secret).unwrap_or(false);
|
||||||
|
if is_secret && !record.unlocked {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (name_color, desc_color, prefix) = if record.unlocked {
|
||||||
|
(
|
||||||
|
Color::srgb(1.0, 0.87, 0.0),
|
||||||
|
Color::srgb(0.75, 0.75, 0.70),
|
||||||
|
"✓ ",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
Color::srgb(0.45, 0.45, 0.50),
|
||||||
|
Color::srgb(0.35, 0.35, 0.40),
|
||||||
|
"◯ ",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: Val::Px(1.0),
|
||||||
|
margin: UiRect::bottom(Val::Px(4.0)),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new(format!("{prefix}{name}")),
|
||||||
|
TextFont { font_size: 16.0, ..default() },
|
||||||
|
TextColor(name_color),
|
||||||
|
));
|
||||||
|
if !description.is_empty() {
|
||||||
|
row.spawn((
|
||||||
|
Text::new(format!(" {description}")),
|
||||||
|
TextFont { font_size: 13.0, ..default() },
|
||||||
|
TextColor(desc_color),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Reward line
|
||||||
|
if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) {
|
||||||
|
row.spawn((
|
||||||
|
Text::new(format!(" Reward: {reward_str}")),
|
||||||
|
TextFont { font_size: 12.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.45, 0.75, 0.45)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Unlock date for unlocked achievements
|
||||||
|
if let Some(date) = record.unlock_date {
|
||||||
|
row.spawn((
|
||||||
|
Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))),
|
||||||
|
TextFont { font_size: 11.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.40, 0.40, 0.45)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_reward(reward: Reward) -> String {
|
||||||
|
match reward {
|
||||||
|
Reward::CardBack(idx) => format!("Card Back #{idx}"),
|
||||||
|
Reward::Background(idx) => format!("Background #{idx}"),
|
||||||
|
Reward::BonusXp(xp) => format!("+{xp} XP"),
|
||||||
|
Reward::Badge => "Badge".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -201,7 +417,7 @@ mod tests {
|
|||||||
// Verify the event was emitted.
|
// Verify the event was emitted.
|
||||||
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
|
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +444,7 @@ mod tests {
|
|||||||
|
|
||||||
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
|
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.clone()).collect();
|
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
||||||
assert!(
|
assert!(
|
||||||
!fired.contains(&"first_win".to_string()),
|
!fired.contains(&"first_win".to_string()),
|
||||||
"first_win must not re-fire on subsequent wins"
|
"first_win must not re-fire on subsequent wins"
|
||||||
@@ -240,4 +456,60 @@ mod tests {
|
|||||||
assert_eq!(display_name_for("first_win"), "First Win");
|
assert_eq!(display_name_for("first_win"), "First Win");
|
||||||
assert_eq!(display_name_for("bogus"), "bogus");
|
assert_eq!(display_name_for("bogus"), "bogus");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bonus_xp_reward_fires_xp_awarded_event() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// "no_undo" achievement awards BonusXp(25). Trigger it by sending a
|
||||||
|
// GameWonEvent with undo_count == 0 (default) and enough stats to match.
|
||||||
|
app.world_mut().send_event(GameWonEvent {
|
||||||
|
score: 1000,
|
||||||
|
time_seconds: 300,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Events<XpAwardedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let xp_events: Vec<u64> = cursor.read(events).map(|e| e.amount).collect();
|
||||||
|
// The no_undo achievement (BonusXp 25) must have fired an XpAwardedEvent.
|
||||||
|
assert!(
|
||||||
|
xp_events.contains(&25),
|
||||||
|
"BonusXp(25) must fire XpAwardedEvent; got {xp_events:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(app: &mut App, key: KeyCode) {
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(key);
|
||||||
|
input.clear();
|
||||||
|
input.press(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_a_spawns_achievements_screen() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
press(&mut app, KeyCode::KeyA);
|
||||||
|
app.update();
|
||||||
|
let count = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&AchievementsScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_a_twice_dismisses_screen() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
press(&mut app, KeyCode::KeyA);
|
||||||
|
app.update();
|
||||||
|
press(&mut app, KeyCode::KeyA);
|
||||||
|
app.update();
|
||||||
|
let count = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&AchievementsScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,45 @@
|
|||||||
//! it directly when adding animations outside this file.
|
//! it directly when adding animations outside this file.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::achievement_plugin::display_name_for;
|
use crate::achievement_plugin::display_name_for;
|
||||||
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
use crate::challenge_plugin::ChallengeAdvancedEvent;
|
||||||
use crate::daily_challenge_plugin::DailyChallengeCompletedEvent;
|
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
|
||||||
|
use crate::events::{InfoToastEvent, NewGameConfirmEvent, XpAwardedEvent};
|
||||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::progress_plugin::LevelUpEvent;
|
use crate::progress_plugin::LevelUpEvent;
|
||||||
use crate::settings_plugin::SettingsChangedEvent;
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||||
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
|
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
|
||||||
|
|
||||||
/// Duration of a card slide (move) animation in seconds.
|
/// Duration of a card slide (move) animation in seconds at Normal speed.
|
||||||
pub const SLIDE_SECS: f32 = 0.15;
|
pub const SLIDE_SECS: f32 = 0.15;
|
||||||
|
|
||||||
|
/// The effective slide duration, updated whenever `Settings::animation_speed` changes.
|
||||||
|
#[derive(Resource, Debug, Clone, Copy)]
|
||||||
|
pub struct EffectiveSlideDuration {
|
||||||
|
pub slide_secs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EffectiveSlideDuration {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { slide_secs: SLIDE_SECS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn anim_speed_to_secs(speed: &AnimSpeed) -> f32 {
|
||||||
|
match speed {
|
||||||
|
AnimSpeed::Normal => SLIDE_SECS,
|
||||||
|
AnimSpeed::Fast => 0.07,
|
||||||
|
AnimSpeed::Instant => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const WIN_TOAST_SECS: f32 = 4.0;
|
const WIN_TOAST_SECS: f32 = 4.0;
|
||||||
const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
|
const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
|
||||||
const LEVELUP_TOAST_SECS: f32 = 3.0;
|
const LEVELUP_TOAST_SECS: f32 = 3.0;
|
||||||
@@ -64,22 +87,34 @@ impl Plugin for AnimationPlugin {
|
|||||||
.add_event::<AchievementUnlockedEvent>()
|
.add_event::<AchievementUnlockedEvent>()
|
||||||
.add_event::<LevelUpEvent>()
|
.add_event::<LevelUpEvent>()
|
||||||
.add_event::<DailyChallengeCompletedEvent>()
|
.add_event::<DailyChallengeCompletedEvent>()
|
||||||
|
.add_event::<DailyGoalAnnouncementEvent>()
|
||||||
.add_event::<WeeklyGoalCompletedEvent>()
|
.add_event::<WeeklyGoalCompletedEvent>()
|
||||||
.add_event::<TimeAttackEndedEvent>()
|
.add_event::<TimeAttackEndedEvent>()
|
||||||
.add_event::<ChallengeAdvancedEvent>()
|
.add_event::<ChallengeAdvancedEvent>()
|
||||||
.add_event::<SettingsChangedEvent>()
|
.add_event::<SettingsChangedEvent>()
|
||||||
|
.add_event::<NewGameConfirmEvent>()
|
||||||
|
.add_event::<InfoToastEvent>()
|
||||||
|
.add_event::<XpAwardedEvent>()
|
||||||
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
|
.add_systems(Startup, init_slide_duration)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
advance_card_anims,
|
advance_card_anims,
|
||||||
|
sync_slide_duration,
|
||||||
handle_win_cascade,
|
handle_win_cascade,
|
||||||
handle_achievement_toast,
|
handle_achievement_toast,
|
||||||
handle_levelup_toast,
|
handle_levelup_toast,
|
||||||
|
handle_daily_goal_announcement_toast,
|
||||||
handle_daily_toast,
|
handle_daily_toast,
|
||||||
handle_weekly_toast,
|
handle_weekly_toast,
|
||||||
handle_time_attack_toast,
|
handle_time_attack_toast,
|
||||||
handle_challenge_toast,
|
handle_challenge_toast,
|
||||||
handle_settings_toast,
|
handle_settings_toast,
|
||||||
|
handle_auto_complete_toast,
|
||||||
|
handle_new_game_confirm_toast,
|
||||||
|
handle_info_toast,
|
||||||
|
handle_xp_awarded_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
)
|
)
|
||||||
.after(GameMutation),
|
.after(GameMutation),
|
||||||
@@ -87,6 +122,24 @@ impl Plugin for AnimationPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn init_slide_duration(
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut dur: ResMut<EffectiveSlideDuration>,
|
||||||
|
) {
|
||||||
|
if let Some(s) = settings {
|
||||||
|
dur.slide_secs = anim_speed_to_secs(&s.0.animation_speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_slide_duration(
|
||||||
|
mut events: EventReader<SettingsChangedEvent>,
|
||||||
|
mut dur: ResMut<EffectiveSlideDuration>,
|
||||||
|
) {
|
||||||
|
for ev in events.read() {
|
||||||
|
dur.slide_secs = anim_speed_to_secs(&ev.0.animation_speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn advance_card_anims(
|
fn advance_card_anims(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
@@ -113,9 +166,9 @@ fn handle_win_cascade(
|
|||||||
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
) {
|
) {
|
||||||
if events.read().next().is_none() {
|
let Some(ev) = events.read().next() else {
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
|
|
||||||
let margin = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
|
let margin = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
|
||||||
|
|
||||||
@@ -131,7 +184,10 @@ fn handle_win_cascade(
|
|||||||
Vec3::new(-margin, 0.0, 300.0),
|
Vec3::new(-margin, 0.0, 300.0),
|
||||||
];
|
];
|
||||||
|
|
||||||
spawn_toast(&mut commands, "You Win!".to_string(), WIN_TOAST_SECS);
|
let m = ev.time_seconds / 60;
|
||||||
|
let s = ev.time_seconds % 60;
|
||||||
|
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
|
||||||
|
spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS);
|
||||||
|
|
||||||
for (i, (entity, transform)) in cards.iter().enumerate() {
|
for (i, (entity, transform)) in cards.iter().enumerate() {
|
||||||
commands.entity(entity).insert(CardAnim {
|
commands.entity(entity).insert(CardAnim {
|
||||||
@@ -151,7 +207,7 @@ fn handle_achievement_toast(
|
|||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
spawn_toast(
|
spawn_toast(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
format!("Achievement: {}", display_name_for(&ev.0)),
|
format!("Achievement: {}", display_name_for(&ev.0.id)),
|
||||||
ACHIEVEMENT_TOAST_SECS,
|
ACHIEVEMENT_TOAST_SECS,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -167,6 +223,15 @@ fn handle_levelup_toast(mut commands: Commands, mut events: EventReader<LevelUpE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_daily_goal_announcement_toast(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut events: EventReader<DailyGoalAnnouncementEvent>,
|
||||||
|
) {
|
||||||
|
for ev in events.read() {
|
||||||
|
spawn_toast(&mut commands, format!("Goal: {}", ev.0), DAILY_TOAST_SECS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_daily_toast(
|
fn handle_daily_toast(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: EventReader<DailyChallengeCompletedEvent>,
|
mut events: EventReader<DailyChallengeCompletedEvent>,
|
||||||
@@ -222,14 +287,64 @@ fn handle_challenge_toast(
|
|||||||
fn handle_settings_toast(
|
fn handle_settings_toast(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut events: EventReader<SettingsChangedEvent>,
|
mut events: EventReader<SettingsChangedEvent>,
|
||||||
|
mut last_sfx: Local<Option<f32>>,
|
||||||
|
mut last_music: Local<Option<f32>>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
let pct = (ev.0.sfx_volume * 100.0).round() as i32;
|
let sfx = ev.0.sfx_volume;
|
||||||
spawn_toast(
|
let music = ev.0.music_volume;
|
||||||
&mut commands,
|
let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > f32::EPSILON);
|
||||||
format!("SFX: {pct}%"),
|
let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > f32::EPSILON);
|
||||||
VOLUME_TOAST_SECS,
|
*last_sfx = Some(sfx);
|
||||||
);
|
*last_music = Some(music);
|
||||||
|
if sfx_changed {
|
||||||
|
let pct = (sfx * 100.0).round() as i32;
|
||||||
|
spawn_toast(&mut commands, format!("SFX: {pct}%"), VOLUME_TOAST_SECS);
|
||||||
|
}
|
||||||
|
if music_changed {
|
||||||
|
let pct = (music * 100.0).round() as i32;
|
||||||
|
spawn_toast(&mut commands, format!("Music: {pct}%"), VOLUME_TOAST_SECS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a one-time "Auto-completing..." toast when auto-complete activates.
|
||||||
|
fn handle_auto_complete_toast(
|
||||||
|
mut commands: Commands,
|
||||||
|
state: Option<Res<AutoCompleteState>>,
|
||||||
|
mut shown: Local<bool>,
|
||||||
|
) {
|
||||||
|
let Some(s) = state else { return };
|
||||||
|
if s.is_changed() {
|
||||||
|
if s.active {
|
||||||
|
if !*shown {
|
||||||
|
*shown = true;
|
||||||
|
spawn_toast(&mut commands, "Auto-completing…".to_string(), 2.0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*shown = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_new_game_confirm_toast(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut events: EventReader<NewGameConfirmEvent>,
|
||||||
|
) {
|
||||||
|
for _ in events.read() {
|
||||||
|
spawn_toast(&mut commands, "Press N again to start a new game".to_string(), 3.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_info_toast(mut commands: Commands, mut events: EventReader<InfoToastEvent>) {
|
||||||
|
for ev in events.read() {
|
||||||
|
spawn_toast(&mut commands, ev.0.clone(), 3.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_xp_awarded_toast(mut commands: Commands, mut events: EventReader<XpAwardedEvent>) {
|
||||||
|
for ev in events.read() {
|
||||||
|
spawn_toast(&mut commands, format!("+{} XP", ev.amount), 3.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ use bevy::prelude::*;
|
|||||||
use kira::manager::backend::DefaultBackend;
|
use kira::manager::backend::DefaultBackend;
|
||||||
use kira::manager::{AudioManager, AudioManagerSettings};
|
use kira::manager::{AudioManager, AudioManagerSettings};
|
||||||
use kira::sound::static_sound::StaticSoundData;
|
use kira::sound::static_sound::StaticSoundData;
|
||||||
|
use kira::track::{TrackBuilder, TrackHandle};
|
||||||
use kira::tween::Tween;
|
use kira::tween::Tween;
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
|
NewGameRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
|
|
||||||
@@ -44,17 +46,33 @@ pub struct SoundLibrary {
|
|||||||
/// some platforms.
|
/// some platforms.
|
||||||
pub struct AudioState {
|
pub struct AudioState {
|
||||||
manager: Option<AudioManager<DefaultBackend>>,
|
manager: Option<AudioManager<DefaultBackend>>,
|
||||||
|
/// Dedicated sub-track for sound effects. Volume controlled by `sfx_volume`.
|
||||||
|
sfx_track: Option<TrackHandle>,
|
||||||
|
/// Dedicated sub-track for ambient music. Volume controlled by `music_volume`.
|
||||||
|
/// No sounds are currently routed here; the track exists so future ambient
|
||||||
|
/// music can be added without changing the volume architecture.
|
||||||
|
music_track: Option<TrackHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AudioPlugin;
|
pub struct AudioPlugin;
|
||||||
|
|
||||||
impl Plugin for AudioPlugin {
|
impl Plugin for AudioPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
let manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
|
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
|
||||||
if manager.is_none() {
|
if manager.is_none() {
|
||||||
warn!("audio device unavailable; SFX disabled");
|
warn!("audio device unavailable; SFX disabled");
|
||||||
}
|
}
|
||||||
app.insert_non_send_resource(AudioState { manager });
|
|
||||||
|
let (sfx_track, music_track) = match manager.as_mut() {
|
||||||
|
Some(mgr) => {
|
||||||
|
let sfx = mgr.add_sub_track(TrackBuilder::default()).ok();
|
||||||
|
let music = mgr.add_sub_track(TrackBuilder::default()).ok();
|
||||||
|
(sfx, music)
|
||||||
|
}
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
app.insert_non_send_resource(AudioState { manager, sfx_track, music_track });
|
||||||
|
|
||||||
let library = build_library();
|
let library = build_library();
|
||||||
if let Some(lib) = library {
|
if let Some(lib) = library {
|
||||||
@@ -68,6 +86,7 @@ impl Plugin for AudioPlugin {
|
|||||||
.add_event::<MoveRejectedEvent>()
|
.add_event::<MoveRejectedEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_event::<NewGameRequestEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
|
.add_event::<CardFlippedEvent>()
|
||||||
.add_event::<SettingsChangedEvent>()
|
.add_event::<SettingsChangedEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Startup,
|
Startup,
|
||||||
@@ -81,6 +100,7 @@ impl Plugin for AudioPlugin {
|
|||||||
play_on_rejected,
|
play_on_rejected,
|
||||||
play_on_new_game,
|
play_on_new_game,
|
||||||
play_on_win,
|
play_on_win,
|
||||||
|
play_on_card_flip,
|
||||||
apply_volume_on_change,
|
apply_volume_on_change,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -116,26 +136,36 @@ fn play(audio: &mut AudioState, sound: &StaticSoundData) {
|
|||||||
let Some(manager) = audio.manager.as_mut() else {
|
let Some(manager) = audio.manager.as_mut() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Err(e) = manager.play(sound.clone()) {
|
// Route SFX through the dedicated sfx_track so its volume is independent
|
||||||
|
// of the music_track volume.
|
||||||
|
let mut data = sound.clone();
|
||||||
|
if let Some(track) = &audio.sfx_track {
|
||||||
|
data.settings.output_destination = track.id().into();
|
||||||
|
}
|
||||||
|
if let Err(e) = manager.play(data) {
|
||||||
warn!("failed to play SFX: {e}");
|
warn!("failed to play SFX: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_main_track_volume(audio: &mut AudioState, volume: f32) {
|
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
|
||||||
let Some(manager) = audio.manager.as_mut() else {
|
if let Some(track) = audio.sfx_track.as_mut() {
|
||||||
return;
|
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
|
||||||
};
|
}
|
||||||
manager
|
}
|
||||||
.main_track()
|
|
||||||
.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
|
fn set_music_volume(audio: &mut AudioState, volume: f32) {
|
||||||
|
if let Some(track) = audio.music_track.as_mut() {
|
||||||
|
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_initial_volume(
|
fn apply_initial_volume(
|
||||||
mut audio: NonSendMut<AudioState>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
) {
|
) {
|
||||||
let volume = settings.map_or(1.0, |s| s.0.sfx_volume);
|
let (sfx, music) = settings.map_or((1.0, 0.5), |s| (s.0.sfx_volume, s.0.music_volume));
|
||||||
set_main_track_volume(&mut audio, volume);
|
set_sfx_volume(&mut audio, sfx);
|
||||||
|
set_music_volume(&mut audio, music);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_volume_on_change(
|
fn apply_volume_on_change(
|
||||||
@@ -143,7 +173,8 @@ fn apply_volume_on_change(
|
|||||||
mut audio: NonSendMut<AudioState>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
set_main_track_volume(&mut audio, ev.0.sfx_volume);
|
set_sfx_volume(&mut audio, ev.0.sfx_volume);
|
||||||
|
set_music_volume(&mut audio, ev.0.music_volume);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +243,19 @@ fn play_on_win(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn play_on_card_flip(
|
||||||
|
mut events: EventReader<CardFlippedEvent>,
|
||||||
|
mut audio: NonSendMut<AudioState>,
|
||||||
|
lib: Option<Res<SoundLibrary>>,
|
||||||
|
) {
|
||||||
|
let Some(lib) = lib else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for _ in events.read() {
|
||||||
|
play(&mut audio, &lib.flip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//! Automatic card-to-foundation sequencing once `is_auto_completable` is set.
|
||||||
|
//!
|
||||||
|
//! When `GameState::is_auto_completable` becomes `true`, this plugin fires
|
||||||
|
//! `MoveRequestEvent` for one card per `STEP_INTERVAL` seconds until the game
|
||||||
|
//! is won. A single toast announces the sequence; no player input is required.
|
||||||
|
//!
|
||||||
|
//! The plugin is intentionally passive: it only reads `GameStateResource` and
|
||||||
|
//! fires `MoveRequestEvent`. If for some reason `next_auto_complete_move`
|
||||||
|
//! returns `None` (e.g. a transient state), the plugin retries next tick.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
|
||||||
|
/// Seconds between consecutive auto-complete moves.
|
||||||
|
const STEP_INTERVAL: f32 = 0.12;
|
||||||
|
|
||||||
|
/// Tracks whether auto-complete is active and when the next move fires.
|
||||||
|
#[derive(Resource, Default, Debug)]
|
||||||
|
pub struct AutoCompleteState {
|
||||||
|
/// `true` once we've detected `is_auto_completable` and started firing moves.
|
||||||
|
pub active: bool,
|
||||||
|
/// Countdown to the next move, in seconds.
|
||||||
|
cooldown: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin that drives the auto-complete sequence.
|
||||||
|
pub struct AutoCompletePlugin;
|
||||||
|
|
||||||
|
impl Plugin for AutoCompletePlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<AutoCompleteState>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(detect_auto_complete, drive_auto_complete)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activates auto-complete when `is_auto_completable` flips to `true`.
|
||||||
|
/// Deactivates it on win or new game (any state where it should not be running).
|
||||||
|
fn detect_auto_complete(
|
||||||
|
mut state: ResMut<AutoCompleteState>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
mut changed: EventReader<StateChangedEvent>,
|
||||||
|
) {
|
||||||
|
// Only re-evaluate on state changes to avoid per-frame allocations.
|
||||||
|
if changed.is_empty() && !game.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changed.clear();
|
||||||
|
|
||||||
|
if game.0.is_won {
|
||||||
|
state.active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if game.0.is_auto_completable && !state.active {
|
||||||
|
state.active = true;
|
||||||
|
state.cooldown = 0.0; // fire first move immediately
|
||||||
|
} else if !game.0.is_auto_completable {
|
||||||
|
state.active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
|
||||||
|
fn drive_auto_complete(
|
||||||
|
mut state: ResMut<AutoCompleteState>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut moves: EventWriter<MoveRequestEvent>,
|
||||||
|
) {
|
||||||
|
if !state.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.cooldown -= time.delta_secs();
|
||||||
|
if state.cooldown > 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((from, to)) = game.0.next_auto_complete_move() else {
|
||||||
|
// No move available yet (race with game state update); try next tick.
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
moves.send(MoveRequestEvent { from, to, count: 1 });
|
||||||
|
state.cooldown = STEP_INTERVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(AutoCompletePlugin);
|
||||||
|
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a nearly-won game: one Ace of Clubs in Tableau(0), all other
|
||||||
|
/// tableau piles empty, stock/waste empty, Clubs foundation empty.
|
||||||
|
fn nearly_won_state() -> GameState {
|
||||||
|
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
for i in 0..7 {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||||
|
id: 99,
|
||||||
|
suit: Suit::Clubs,
|
||||||
|
rank: Rank::Ace,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
g.is_auto_completable = true;
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_starts_inactive() {
|
||||||
|
let app = headless_app();
|
||||||
|
assert!(!app.world().resource::<AutoCompleteState>().active);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_activates_when_auto_completable() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Install a nearly-won state and fire StateChangedEvent.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
|
||||||
|
app.world_mut().send_event(StateChangedEvent);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(app.world().resource::<AutoCompleteState>().active);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_fires_move_request_when_active() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 = nearly_won_state();
|
||||||
|
app.world_mut().send_event(StateChangedEvent);
|
||||||
|
app.update(); // detect runs, sets active
|
||||||
|
app.update(); // drive fires the move
|
||||||
|
|
||||||
|
let events = app.world().resource::<Events<MoveRequestEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(events).collect();
|
||||||
|
// At least one MoveRequestEvent should have been fired.
|
||||||
|
assert!(!fired.is_empty(), "expected at least one MoveRequestEvent");
|
||||||
|
assert_eq!(fired[0].from, PileType::Tableau(0));
|
||||||
|
assert_eq!(fired[0].to, PileType::Foundation(Suit::Clubs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_deactivates_on_win() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Inject a won game state — active should not be set.
|
||||||
|
let mut gs = nearly_won_state();
|
||||||
|
gs.is_won = true;
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||||
|
app.world_mut().send_event(StateChangedEvent);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(!app.world().resource::<AutoCompleteState>().active);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,15 +19,19 @@ use solitaire_core::card::{Card, Rank, Suit};
|
|||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
use crate::animation_plugin::{CardAnim, SLIDE_SECS};
|
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
||||||
use crate::events::StateChangedEvent;
|
use crate::events::StateChangedEvent;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
|
|
||||||
/// Fraction of card height used as vertical offset between stacked tableau cards.
|
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
||||||
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||||
|
|
||||||
|
/// Tighter fan for face-down cards in the tableau — just enough to show the stack.
|
||||||
|
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
|
||||||
|
|
||||||
/// Fraction of card height used as a tiny offset between stacked cards in
|
/// Fraction of card height used as a tiny offset between stacked cards in
|
||||||
/// non-tableau piles, so stacking is visible.
|
/// non-tableau piles, so stacking is visible.
|
||||||
const STACK_FAN_FRAC: f32 = 0.003;
|
const STACK_FAN_FRAC: f32 = 0.003;
|
||||||
@@ -36,10 +40,21 @@ const STACK_FAN_FRAC: f32 = 0.003;
|
|||||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||||
|
|
||||||
const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
|
const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
|
||||||
const CARD_BACK_COLOUR: Color = Color::srgb(0.15, 0.30, 0.55);
|
|
||||||
const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15);
|
const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15);
|
||||||
const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
|
const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
|
||||||
|
|
||||||
|
/// Returns the card back color for the given unlocked card-back index.
|
||||||
|
/// Index 0 = default blue; 1–4 are unlockable alternate designs.
|
||||||
|
fn card_back_colour(selected_card_back: usize) -> Color {
|
||||||
|
match selected_card_back {
|
||||||
|
0 => Color::srgb(0.15, 0.30, 0.55), // default blue
|
||||||
|
1 => Color::srgb(0.55, 0.10, 0.10), // deep red
|
||||||
|
2 => Color::srgb(0.05, 0.40, 0.10), // forest green
|
||||||
|
3 => Color::srgb(0.35, 0.08, 0.52), // purple
|
||||||
|
_ => Color::srgb(0.05, 0.40, 0.42), // teal (4+)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Marker component linking a Bevy entity to a `solitaire_core::Card::id`.
|
/// Marker component linking a Bevy entity to a `solitaire_core::Card::id`.
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
pub struct CardEntity {
|
pub struct CardEntity {
|
||||||
@@ -57,8 +72,26 @@ impl Plugin for CardPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// PostStartup ensures TablePlugin's Startup system has inserted
|
// PostStartup ensures TablePlugin's Startup system has inserted
|
||||||
// LayoutResource before we try to read it.
|
// LayoutResource before we try to read it.
|
||||||
app.add_systems(PostStartup, sync_cards_startup)
|
app.add_event::<SettingsChangedEvent>()
|
||||||
.add_systems(Update, sync_cards_on_change.after(GameMutation));
|
.add_systems(PostStartup, sync_cards_startup)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
sync_cards_on_change.after(GameMutation),
|
||||||
|
resync_cards_on_settings_change.before(sync_cards_on_change),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When card-back selection changes in Settings, re-render all cards so the
|
||||||
|
/// new back colour is applied immediately (without waiting for a state change).
|
||||||
|
fn resync_cards_on_settings_change(
|
||||||
|
mut setting_events: EventReader<SettingsChangedEvent>,
|
||||||
|
mut state_events: EventWriter<StateChangedEvent>,
|
||||||
|
) {
|
||||||
|
if setting_events.read().next().is_some() {
|
||||||
|
state_events.send(StateChangedEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +102,16 @@ fn sync_cards_startup(
|
|||||||
commands: Commands,
|
commands: Commands,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
) {
|
) {
|
||||||
if let Some(layout) = layout {
|
if let Some(layout) = layout {
|
||||||
sync_cards(commands, &game.0, &layout.0, &entities);
|
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||||
|
let back_colour = settings
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
||||||
|
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, &entities);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,13 +120,19 @@ fn sync_cards_on_change(
|
|||||||
commands: Commands,
|
commands: Commands,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
) {
|
) {
|
||||||
if events.read().next().is_none() {
|
if events.read().next().is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(layout) = layout {
|
if let Some(layout) = layout {
|
||||||
sync_cards(commands, &game.0, &layout.0, &entities);
|
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||||
|
let back_colour = settings
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
||||||
|
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, &entities);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +140,8 @@ fn sync_cards(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
game: &GameState,
|
game: &GameState,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
|
slide_secs: f32,
|
||||||
|
back_colour: Color,
|
||||||
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||||
) {
|
) {
|
||||||
let positions = card_positions(game, layout);
|
let positions = card_positions(game, layout);
|
||||||
@@ -118,9 +165,9 @@ fn sync_cards(
|
|||||||
for (card, position, z) in positions {
|
for (card, position, z) in positions {
|
||||||
match existing.get(&card.id) {
|
match existing.get(&card.id) {
|
||||||
Some(&(entity, cur)) => {
|
Some(&(entity, cur)) => {
|
||||||
update_card_entity(&mut commands, entity, &card, position, z, layout, cur)
|
update_card_entity(&mut commands, entity, &card, position, z, layout, slide_secs, back_colour, cur)
|
||||||
}
|
}
|
||||||
None => spawn_card_entity(&mut commands, &card, position, z, layout),
|
None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,26 +199,34 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let is_tableau = matches!(pile_type, PileType::Tableau(_));
|
let is_tableau = matches!(pile_type, PileType::Tableau(_));
|
||||||
let fan_y = if is_tableau {
|
|
||||||
-layout.card_size.y * TABLEAU_FAN_FRAC
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
for (i, card) in pile.cards.iter().enumerate() {
|
// Tableau uses a two-speed fan: face-down cards are packed tighter
|
||||||
let pos = Vec2::new(base.x, base.y + fan_y * i as f32);
|
// than face-up cards so the visible (playable) portion stands out.
|
||||||
|
// Non-tableau piles stack with a negligible offset.
|
||||||
|
let cards = &pile.cards;
|
||||||
|
let mut y_offset = 0.0_f32;
|
||||||
|
for (i, card) in cards.iter().enumerate() {
|
||||||
|
let pos = Vec2::new(base.x, base.y + y_offset);
|
||||||
let z = 1.0 + (i as f32) * STACK_FAN_FRAC;
|
let z = 1.0 + (i as f32) * STACK_FAN_FRAC;
|
||||||
out.push((card.clone(), pos, z));
|
out.push((card.clone(), pos, z));
|
||||||
|
if is_tableau {
|
||||||
|
let step = if card.face_up {
|
||||||
|
TABLEAU_FAN_FRAC
|
||||||
|
} else {
|
||||||
|
TABLEAU_FACEDOWN_FAN_FRAC
|
||||||
|
};
|
||||||
|
y_offset -= layout.card_size.y * step;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout) {
|
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color) {
|
||||||
let body_colour = if card.face_up {
|
let body_colour = if card.face_up {
|
||||||
CARD_FACE_COLOUR
|
CARD_FACE_COLOUR
|
||||||
} else {
|
} else {
|
||||||
CARD_BACK_COLOUR
|
back_colour
|
||||||
};
|
};
|
||||||
|
|
||||||
commands
|
commands
|
||||||
@@ -202,6 +257,7 @@ fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, la
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn update_card_entity(
|
fn update_card_entity(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
@@ -209,12 +265,14 @@ fn update_card_entity(
|
|||||||
pos: Vec2,
|
pos: Vec2,
|
||||||
z: f32,
|
z: f32,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
|
slide_secs: f32,
|
||||||
|
back_colour: Color,
|
||||||
cur: Vec3,
|
cur: Vec3,
|
||||||
) {
|
) {
|
||||||
let body_colour = if card.face_up {
|
let body_colour = if card.face_up {
|
||||||
CARD_FACE_COLOUR
|
CARD_FACE_COLOUR
|
||||||
} else {
|
} else {
|
||||||
CARD_BACK_COLOUR
|
back_colour
|
||||||
};
|
};
|
||||||
|
|
||||||
let target = Vec3::new(pos.x, pos.y, z);
|
let target = Vec3::new(pos.x, pos.y, z);
|
||||||
@@ -227,7 +285,7 @@ fn update_card_entity(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Slide to the new position when it differs meaningfully; snap otherwise.
|
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||||
if (cur.truncate() - target.truncate()).length() > 1.0 {
|
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
||||||
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
|
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
|
||||||
commands
|
commands
|
||||||
.entity(entity)
|
.entity(entity)
|
||||||
@@ -236,7 +294,7 @@ fn update_card_entity(
|
|||||||
start,
|
start,
|
||||||
target,
|
target,
|
||||||
elapsed: 0.0,
|
elapsed: 0.0,
|
||||||
duration: SLIDE_SECS,
|
duration: slide_secs,
|
||||||
delay: 0.0,
|
delay: 0.0,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -454,4 +512,29 @@ mod tests {
|
|||||||
assert!(w[0] > w[1]);
|
assert!(w[0] > w[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
||||||
|
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||||
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
|
||||||
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
|
// Tableau(6) has 7 cards: 6 face-down + 1 face-up on top.
|
||||||
|
// Each face-down card contributes TABLEAU_FACEDOWN_FAN_FRAC to the column span.
|
||||||
|
// Total span should be 6 * FACEDOWN < 6 * TABLEAU_FAN_FRAC (the old uniform value).
|
||||||
|
let col6_base = layout.pile_positions[&PileType::Tableau(6)];
|
||||||
|
let mut col6_ys: Vec<f32> = positions
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, pos, _)| (pos.x - col6_base.x).abs() < 1e-3)
|
||||||
|
.map(|(_, pos, _)| pos.y)
|
||||||
|
.collect();
|
||||||
|
col6_ys.sort_by(|a, b| b.partial_cmp(a).unwrap());
|
||||||
|
assert_eq!(col6_ys.len(), 7);
|
||||||
|
let actual_span = col6_ys[0] - col6_ys[6];
|
||||||
|
let uniform_span = 6.0 * TABLEAU_FAN_FRAC * layout.card_size.y;
|
||||||
|
assert!(
|
||||||
|
actual_span < uniform_span,
|
||||||
|
"tighter face-down fan should reduce column span ({actual_span:.1} >= uniform {uniform_span:.1})"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use bevy::prelude::*;
|
|||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
use solitaire_data::{challenge_count, challenge_seed_for, save_progress_to};
|
use solitaire_data::{challenge_count, challenge_seed_for, save_progress_to};
|
||||||
|
|
||||||
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
use crate::events::{GameWonEvent, InfoToastEvent, NewGameRequestEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
@@ -31,6 +31,7 @@ impl Plugin for ChallengePlugin {
|
|||||||
app.add_event::<ChallengeAdvancedEvent>()
|
app.add_event::<ChallengeAdvancedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_event::<NewGameRequestEvent>()
|
||||||
|
.add_event::<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));
|
||||||
@@ -66,15 +67,15 @@ 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: EventWriter<NewGameRequestEvent>,
|
||||||
|
mut info_toast: EventWriter<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!(
|
info_toast.send(InfoToastEvent(format!(
|
||||||
"Challenge mode locked — reach level {} (currently {}).",
|
"Challenge mode unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
|
||||||
CHALLENGE_UNLOCK_LEVEL, progress.0.level
|
)));
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(seed) = challenge_seed_for(progress.0.challenge_index) else {
|
let Some(seed) = challenge_seed_for(progress.0.challenge_index) else {
|
||||||
|
|||||||
@@ -13,30 +13,48 @@
|
|||||||
|
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
use chrono::{Local, NaiveDate};
|
use chrono::{Local, NaiveDate};
|
||||||
use solitaire_data::{daily_seed_for, save_progress_to};
|
use solitaire_data::{daily_seed_for, save_progress_to};
|
||||||
|
use solitaire_sync::ChallengeGoal;
|
||||||
|
|
||||||
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
use crate::events::{GameWonEvent, NewGameRequestEvent, XpAwardedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
|
|
||||||
/// Bonus XP awarded for completing today's daily challenge.
|
/// Bonus XP awarded for completing today's daily challenge.
|
||||||
pub const DAILY_BONUS_XP: u64 = 100;
|
pub const DAILY_BONUS_XP: u64 = 100;
|
||||||
|
|
||||||
/// The active daily challenge — date + RNG seed for that date's deal.
|
/// The active daily challenge — date + RNG seed for that date's deal,
|
||||||
#[derive(Resource, Debug, Clone, Copy)]
|
/// plus optional goal metadata fetched from the server.
|
||||||
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct DailyChallengeResource {
|
pub struct DailyChallengeResource {
|
||||||
pub date: NaiveDate,
|
pub date: NaiveDate,
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
|
/// Human-readable goal description from the server, e.g. "Win in under 5 minutes".
|
||||||
|
pub goal_description: Option<String>,
|
||||||
|
/// Optional target score the server requires for this challenge.
|
||||||
|
pub target_score: Option<i32>,
|
||||||
|
/// Optional time limit in seconds the server imposes.
|
||||||
|
pub max_time_secs: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fired when the player presses C to start the daily challenge.
|
||||||
|
/// Carries the current goal description so it can be displayed as a toast.
|
||||||
|
#[derive(Event, Debug, Clone)]
|
||||||
|
pub struct DailyGoalAnnouncementEvent(pub String);
|
||||||
|
|
||||||
impl DailyChallengeResource {
|
impl DailyChallengeResource {
|
||||||
pub fn for_today() -> Self {
|
pub fn for_today() -> Self {
|
||||||
let date = Local::now().date_naive();
|
let date = Local::now().date_naive();
|
||||||
Self {
|
Self {
|
||||||
date,
|
date,
|
||||||
seed: daily_seed_for(date),
|
seed: daily_seed_for(date),
|
||||||
|
goal_description: None,
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,14 +66,24 @@ pub struct DailyChallengeCompletedEvent {
|
|||||||
pub streak: u32,
|
pub streak: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holds the in-flight server challenge fetch so the result can be polled
|
||||||
|
/// each frame without blocking the main thread.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
||||||
|
|
||||||
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>()
|
||||||
.add_event::<DailyChallengeCompletedEvent>()
|
.add_event::<DailyChallengeCompletedEvent>()
|
||||||
|
.add_event::<DailyGoalAnnouncementEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_event::<NewGameRequestEvent>()
|
||||||
|
.add_event::<XpAwardedEvent>()
|
||||||
|
.add_systems(Startup, fetch_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
|
||||||
// ProgressPlugin's add_xp on the same frame.
|
// ProgressPlugin's add_xp on the same frame.
|
||||||
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
||||||
@@ -63,6 +91,58 @@ impl Plugin for DailyChallengePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Startup system: spawns an async task to fetch the server's daily challenge.
|
||||||
|
///
|
||||||
|
/// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is
|
||||||
|
/// installed). The endpoint is public so authentication is not required.
|
||||||
|
fn fetch_server_challenge(
|
||||||
|
provider: Option<Res<SyncProviderResource>>,
|
||||||
|
mut task_res: ResMut<DailyChallengeTask>,
|
||||||
|
) {
|
||||||
|
let Some(provider) = provider else { return };
|
||||||
|
let provider = provider.0.clone();
|
||||||
|
let task = AsyncComputeTaskPool::get()
|
||||||
|
.spawn(async move { provider.fetch_daily_challenge().await.ok().flatten() });
|
||||||
|
task_res.0 = Some(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update system: polls the server-challenge fetch task.
|
||||||
|
///
|
||||||
|
/// On success, replaces the locally-computed seed in `DailyChallengeResource`
|
||||||
|
/// with the server's authoritative seed — ensuring all players worldwide get
|
||||||
|
/// the same deal on a given date regardless of their local clock hash.
|
||||||
|
///
|
||||||
|
/// Silently no-ops if the task is still in flight, already consumed, or
|
||||||
|
/// if the server returned a challenge for a different date.
|
||||||
|
fn poll_server_challenge(
|
||||||
|
mut task_res: ResMut<DailyChallengeTask>,
|
||||||
|
mut daily: ResMut<DailyChallengeResource>,
|
||||||
|
) {
|
||||||
|
let Some(task) = task_res.0.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
task_res.0 = None;
|
||||||
|
let Some(goal) = result else { return };
|
||||||
|
let Ok(date) = NaiveDate::parse_from_str(&goal.date, "%Y-%m-%d") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if date == daily.date {
|
||||||
|
let old_seed = daily.seed;
|
||||||
|
daily.seed = goal.seed;
|
||||||
|
daily.goal_description = Some(goal.description.clone());
|
||||||
|
daily.target_score = goal.target_score;
|
||||||
|
daily.max_time_secs = goal.max_time_secs;
|
||||||
|
info!(
|
||||||
|
"daily challenge seed updated from server: {old_seed} → {} ({})",
|
||||||
|
goal.seed,
|
||||||
|
goal.description
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_daily_completion(
|
fn handle_daily_completion(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: EventReader<GameWonEvent>,
|
||||||
daily: Res<DailyChallengeResource>,
|
daily: Res<DailyChallengeResource>,
|
||||||
@@ -70,16 +150,29 @@ fn handle_daily_completion(
|
|||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
path: Res<ProgressStoragePath>,
|
path: Res<ProgressStoragePath>,
|
||||||
mut completed: EventWriter<DailyChallengeCompletedEvent>,
|
mut completed: EventWriter<DailyChallengeCompletedEvent>,
|
||||||
|
mut xp_awarded: EventWriter<XpAwardedEvent>,
|
||||||
) {
|
) {
|
||||||
for _ in wins.read() {
|
for ev in wins.read() {
|
||||||
if game.0.seed != daily.seed {
|
if game.0.seed != daily.seed {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Enforce server-supplied goal constraints when present.
|
||||||
|
if let Some(target) = daily.target_score {
|
||||||
|
if ev.score < target {
|
||||||
|
continue; // score goal not met
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(max_secs) = daily.max_time_secs {
|
||||||
|
if ev.time_seconds > max_secs {
|
||||||
|
continue; // time limit exceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
if !progress.0.record_daily_completion(daily.date) {
|
if !progress.0.record_daily_completion(daily.date) {
|
||||||
// Already counted today — no-op.
|
// Already counted today — no-op.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
progress.0.add_xp(DAILY_BONUS_XP);
|
progress.0.add_xp(DAILY_BONUS_XP);
|
||||||
|
xp_awarded.send(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}");
|
||||||
@@ -96,12 +189,18 @@ 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: EventWriter<NewGameRequestEvent>,
|
||||||
|
mut announce: EventWriter<DailyGoalAnnouncementEvent>,
|
||||||
) {
|
) {
|
||||||
if keys.just_pressed(KeyCode::KeyC) {
|
if keys.just_pressed(KeyCode::KeyC) {
|
||||||
new_game.send(NewGameRequestEvent {
|
new_game.send(NewGameRequestEvent {
|
||||||
seed: Some(daily.seed),
|
seed: Some(daily.seed),
|
||||||
mode: None,
|
mode: None,
|
||||||
});
|
});
|
||||||
|
let desc = daily
|
||||||
|
.goal_description
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Daily Challenge".to_string());
|
||||||
|
announce.send(DailyGoalAnnouncementEvent(desc));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,4 +320,58 @@ mod tests {
|
|||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
assert_eq!(fired[0].seed, Some(daily_seed));
|
assert_eq!(fired[0].seed, Some(daily_seed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_c_fires_announcement_event_with_description() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Inject a goal description.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<DailyChallengeResource>()
|
||||||
|
.goal_description = Some("Win in under 5 minutes".to_string());
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::KeyC);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Events<DailyGoalAnnouncementEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(events).cloned().collect();
|
||||||
|
assert_eq!(fired.len(), 1);
|
||||||
|
assert_eq!(fired[0].0, "Win in under 5 minutes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_c_with_no_description_uses_fallback() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Ensure no description is set.
|
||||||
|
assert!(app.world().resource::<DailyChallengeResource>().goal_description.is_none());
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::KeyC);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Events<DailyGoalAnnouncementEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(events).cloned().collect();
|
||||||
|
assert_eq!(fired.len(), 1);
|
||||||
|
assert_eq!(fired[0].0, "Daily Challenge");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn goal_fields_stored_from_server_fetch() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Simulate what poll_server_challenge does when the server responds.
|
||||||
|
{
|
||||||
|
let mut daily = app.world_mut().resource_mut::<DailyChallengeResource>();
|
||||||
|
daily.goal_description = Some("Win without undo".to_string());
|
||||||
|
daily.target_score = Some(1_000);
|
||||||
|
daily.max_time_secs = Some(300);
|
||||||
|
}
|
||||||
|
let r = app.world().resource::<DailyChallengeResource>();
|
||||||
|
assert_eq!(r.goal_description.as_deref(), Some("Win without undo"));
|
||||||
|
assert_eq!(r.target_score, Some(1_000));
|
||||||
|
assert_eq!(r.max_time_secs, Some(300));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use bevy::prelude::Event;
|
use bevy::prelude::Event;
|
||||||
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;
|
||||||
|
|
||||||
/// 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`.
|
||||||
@@ -55,8 +56,34 @@ pub struct GameWonEvent {
|
|||||||
#[derive(Event, Debug, Clone, Copy)]
|
#[derive(Event, Debug, Clone, Copy)]
|
||||||
pub struct CardFlippedEvent(pub u32);
|
pub struct CardFlippedEvent(pub u32);
|
||||||
|
|
||||||
/// Achievement unlocked notification — name of the achievement.
|
/// Achievement unlocked notification carrying the full `AchievementRecord` for
|
||||||
///
|
/// the newly unlocked achievement. Consumed by the toast renderer and any
|
||||||
/// Uses `String` as a placeholder; replaced with `AchievementRecord` in Phase 5.
|
/// persistence/UI systems that need unlock metadata.
|
||||||
#[derive(Event, Debug, Clone)]
|
#[derive(Event, Debug, Clone)]
|
||||||
pub struct AchievementUnlockedEvent(pub String);
|
pub struct AchievementUnlockedEvent(pub AchievementRecord);
|
||||||
|
|
||||||
|
/// Request to manually trigger a sync pull from the active backend.
|
||||||
|
///
|
||||||
|
/// Fired by the Settings panel "Sync Now" button. `SyncPlugin` responds by
|
||||||
|
/// starting a new pull task if one is not already in flight.
|
||||||
|
#[derive(Event, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct ManualSyncRequestEvent;
|
||||||
|
|
||||||
|
/// Fired by `InputPlugin` when N is pressed while a game is in progress
|
||||||
|
/// but confirmation has not yet been received. The animation plugin shows
|
||||||
|
/// a "Press N again to confirm" toast. A second N press within the
|
||||||
|
/// confirmation window sends `NewGameRequestEvent`.
|
||||||
|
#[derive(Event, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct NewGameConfirmEvent;
|
||||||
|
|
||||||
|
/// Generic informational toast message. Any system can fire this to display
|
||||||
|
/// a short string to the player, e.g. "Locked — reach level 5".
|
||||||
|
#[derive(Event, Debug, Clone)]
|
||||||
|
pub struct InfoToastEvent(pub String);
|
||||||
|
|
||||||
|
/// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
|
||||||
|
/// animation layer can display a "+N XP" toast alongside the win cascade.
|
||||||
|
#[derive(Event, Debug, Clone, Copy)]
|
||||||
|
pub struct XpAwardedEvent {
|
||||||
|
pub amount: u64,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
//! Routes game-request events to `solitaire_core::GameState` and emits
|
//! Routes game-request events to `solitaire_core::GameState` and emits
|
||||||
//! state-change notifications.
|
//! state-change notifications.
|
||||||
|
//!
|
||||||
|
//! Game state persistence: on startup the plugin attempts to restore an
|
||||||
|
//! in-progress game from `game_state.json`. On app exit the current state is
|
||||||
|
//! written back (unless the game is won). On a win or new-game request the
|
||||||
|
//! file is deleted so the next launch starts fresh.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
use solitaire_data::{delete_game_state_at, game_state_file_path, load_game_state_from,
|
||||||
|
save_game_state_to};
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
||||||
@@ -18,16 +26,33 @@ use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct GameMutation;
|
pub struct GameMutation;
|
||||||
|
|
||||||
|
/// Persistence path for the in-progress game state file. `None` disables I/O.
|
||||||
|
#[derive(Resource, Debug, Clone)]
|
||||||
|
pub struct GameStatePath(pub Option<PathBuf>);
|
||||||
|
|
||||||
/// Registers game resources, events, and the systems that route user intent
|
/// Registers game resources, events, and the systems that route user intent
|
||||||
/// (events) into mutations on `GameState`.
|
/// (events) into mutations on `GameState`.
|
||||||
pub struct GamePlugin;
|
pub struct GamePlugin;
|
||||||
|
|
||||||
|
impl GamePlugin {
|
||||||
|
/// Plugin with no persistence. Use in headless tests to avoid touching the
|
||||||
|
/// real `game_state.json` on disk.
|
||||||
|
pub fn headless() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Plugin for GamePlugin {
|
impl Plugin for GamePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.insert_resource(GameStateResource(GameState::new(
|
let path = game_state_file_path();
|
||||||
seed_from_system_time(),
|
// Restore any saved in-progress game, falling back to a fresh deal.
|
||||||
DrawMode::DrawOne,
|
let initial_state = path
|
||||||
)))
|
.as_deref()
|
||||||
|
.and_then(load_game_state_from)
|
||||||
|
.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne));
|
||||||
|
|
||||||
|
app.insert_resource(GameStateResource(initial_state))
|
||||||
|
.insert_resource(GameStatePath(path))
|
||||||
.init_resource::<DragState>()
|
.init_resource::<DragState>()
|
||||||
.init_resource::<SyncStatusResource>()
|
.init_resource::<SyncStatusResource>()
|
||||||
.add_event::<MoveRequestEvent>()
|
.add_event::<MoveRequestEvent>()
|
||||||
@@ -50,7 +75,8 @@ impl Plugin for GamePlugin {
|
|||||||
.chain()
|
.chain()
|
||||||
.in_set(GameMutation),
|
.in_set(GameMutation),
|
||||||
)
|
)
|
||||||
.add_systems(Update, tick_elapsed_time);
|
.add_systems(Update, tick_elapsed_time)
|
||||||
|
.add_systems(Last, save_game_state_on_exit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +131,26 @@ fn handle_new_game(
|
|||||||
mut new_game: EventReader<NewGameRequestEvent>,
|
mut new_game: EventReader<NewGameRequestEvent>,
|
||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut changed: EventWriter<StateChangedEvent>,
|
mut changed: EventWriter<StateChangedEvent>,
|
||||||
|
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||||
|
path: Option<Res<GameStatePath>>,
|
||||||
) {
|
) {
|
||||||
for ev in new_game.read() {
|
for ev in new_game.read() {
|
||||||
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
|
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
|
||||||
let draw_mode = game.0.draw_mode.clone();
|
// Prefer the draw mode from Settings when starting a fresh game.
|
||||||
|
// Fall back to the current game's draw mode in headless/test contexts
|
||||||
|
// where SettingsPlugin is not installed.
|
||||||
|
let draw_mode = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.0.draw_mode.clone())
|
||||||
|
.unwrap_or_else(|| game.0.draw_mode.clone());
|
||||||
let mode = ev.mode.unwrap_or(game.0.mode);
|
let mode = ev.mode.unwrap_or(game.0.mode);
|
||||||
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
|
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
|
||||||
|
// Delete any previously saved in-progress state — this is a fresh game.
|
||||||
|
if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) {
|
||||||
|
if let Err(e) = delete_game_state_at(p) {
|
||||||
|
warn!("game_state: failed to delete saved game: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
changed.send(StateChangedEvent);
|
changed.send(StateChangedEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,17 +175,45 @@ fn handle_move(
|
|||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut changed: EventWriter<StateChangedEvent>,
|
mut changed: EventWriter<StateChangedEvent>,
|
||||||
mut won: EventWriter<GameWonEvent>,
|
mut won: EventWriter<GameWonEvent>,
|
||||||
|
mut flipped: EventWriter<crate::events::CardFlippedEvent>,
|
||||||
|
path: Option<Res<GameStatePath>>,
|
||||||
) {
|
) {
|
||||||
for ev in moves.read() {
|
for ev in moves.read() {
|
||||||
let was_won = game.0.is_won;
|
let was_won = game.0.is_won;
|
||||||
|
// Identify the card that will be exposed (and may flip face-up) by the move.
|
||||||
|
// It's the card just below the bottom of the moving stack in the source pile.
|
||||||
|
let flip_candidate_id = game.0.piles.get(&ev.from).and_then(|p| {
|
||||||
|
let n = p.cards.len();
|
||||||
|
if n > ev.count {
|
||||||
|
let c = &p.cards[n - ev.count - 1];
|
||||||
|
if !c.face_up { Some(c.id) } else { None }
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
match game.0.move_cards(ev.from.clone(), ev.to.clone(), ev.count) {
|
match game.0.move_cards(ev.from.clone(), ev.to.clone(), ev.count) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
// Fire flip event if the candidate card is now face-up.
|
||||||
|
if let Some(fid) = flip_candidate_id {
|
||||||
|
if game.0.piles.get(&ev.from)
|
||||||
|
.and_then(|p| p.cards.last())
|
||||||
|
.is_some_and(|c| c.id == fid && c.face_up)
|
||||||
|
{
|
||||||
|
flipped.send(crate::events::CardFlippedEvent(fid));
|
||||||
|
}
|
||||||
|
}
|
||||||
changed.send(StateChangedEvent);
|
changed.send(StateChangedEvent);
|
||||||
if !was_won && game.0.is_won {
|
if !was_won && game.0.is_won {
|
||||||
won.send(GameWonEvent {
|
won.send(GameWonEvent {
|
||||||
score: game.0.score,
|
score: game.0.score,
|
||||||
time_seconds: game.0.elapsed_seconds,
|
time_seconds: game.0.elapsed_seconds,
|
||||||
});
|
});
|
||||||
|
// Delete the saved state — a won game should not be resumed.
|
||||||
|
if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) {
|
||||||
|
if let Err(e) = delete_game_state_at(p) {
|
||||||
|
warn!("game_state: failed to delete on win: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => warn!("move rejected {:?} -> {:?} x{}: {e}", ev.from, ev.to, ev.count),
|
Err(e) => warn!("move rejected {:?} -> {:?} x{}: {e}", ev.from, ev.to, ev.count),
|
||||||
@@ -168,16 +236,38 @@ fn handle_undo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Last-schedule system: persists the current game state on `AppExit` so the
|
||||||
|
/// player can resume where they left off. Won games are not saved (the
|
||||||
|
/// `save_game_state_to` helper skips them). Blocking on exit is acceptable
|
||||||
|
/// because the game loop is already shutting down.
|
||||||
|
fn save_game_state_on_exit(
|
||||||
|
mut exit_events: EventReader<AppExit>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
path: Res<GameStatePath>,
|
||||||
|
) {
|
||||||
|
if exit_events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exit_events.clear();
|
||||||
|
let Some(p) = path.0.as_deref() else { return };
|
||||||
|
if let Err(e) = save_game_state_to(p, &game.0) {
|
||||||
|
warn!("game_state: failed to save on exit: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
/// Build a minimal headless `App` with just `GamePlugin` installed.
|
/// Build a minimal headless `App` with just `GamePlugin` installed.
|
||||||
/// Overrides the default random seed so tests are deterministic.
|
/// Disables persistence and overrides the seed so tests are deterministic
|
||||||
|
/// and don't touch `~/.local/share/solitaire_quest/game_state.json`.
|
||||||
fn test_app(seed: u64) -> App {
|
fn test_app(seed: u64) -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(GamePlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(GamePlugin);
|
||||||
|
// Disable I/O — tests must not touch the real game state file.
|
||||||
|
app.insert_resource(GameStatePath(None));
|
||||||
// Override the system-time seed with a known value.
|
// Override the system-time seed with a known value.
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<GameStateResource>()
|
.resource_mut::<GameStateResource>()
|
||||||
@@ -189,6 +279,7 @@ mod tests {
|
|||||||
fn plugin_inserts_game_state_resource() {
|
fn plugin_inserts_game_state_resource() {
|
||||||
let app = test_app(1);
|
let app = test_app(1);
|
||||||
assert!(app.world().get_resource::<GameStateResource>().is_some());
|
assert!(app.world().get_resource::<GameStateResource>().is_some());
|
||||||
|
assert!(app.world().get_resource::<GameStatePath>().is_some());
|
||||||
assert!(app.world().get_resource::<DragState>().is_some());
|
assert!(app.world().get_resource::<DragState>().is_some());
|
||||||
assert!(app.world().get_resource::<SyncStatusResource>().is_some());
|
assert!(app.world().get_resource::<SyncStatusResource>().is_some());
|
||||||
}
|
}
|
||||||
@@ -325,4 +416,140 @@ mod tests {
|
|||||||
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Persistence tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn tmp_gs_path(name: &str) -> std::path::PathBuf {
|
||||||
|
std::env::temp_dir().join(format!("engine_test_gs_{name}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// save_game_state_on_exit writes to disk when AppExit fires.
|
||||||
|
#[test]
|
||||||
|
fn exit_saves_game_state() {
|
||||||
|
use solitaire_data::load_game_state_from;
|
||||||
|
|
||||||
|
let path = tmp_gs_path("exit_save");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
|
let mut app = test_app(7);
|
||||||
|
// Point persistence at our temp file.
|
||||||
|
app.insert_resource(GameStatePath(Some(path.clone())));
|
||||||
|
// Override the seed so we can verify it was written.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
|
GameState::new(7654, DrawMode::DrawOne);
|
||||||
|
|
||||||
|
app.world_mut().send_event(AppExit::Success);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let loaded = load_game_state_from(&path).expect("file should exist after exit");
|
||||||
|
assert_eq!(loaded.seed, 7654);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// new_game_request deletes any previously saved state file.
|
||||||
|
#[test]
|
||||||
|
fn new_game_deletes_saved_state() {
|
||||||
|
use solitaire_data::save_game_state_to;
|
||||||
|
|
||||||
|
let path = tmp_gs_path("new_game_delete");
|
||||||
|
// Pre-create a saved file.
|
||||||
|
save_game_state_to(&path, &GameState::new(1, DrawMode::DrawOne)).unwrap();
|
||||||
|
assert!(path.exists());
|
||||||
|
|
||||||
|
let mut app = test_app(1);
|
||||||
|
app.insert_resource(GameStatePath(Some(path.clone())));
|
||||||
|
app.world_mut().send_event(NewGameRequestEvent { seed: Some(2), mode: None });
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(!path.exists(), "saved file should be deleted after new game");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moving_cards_off_face_down_card_fires_card_flipped_event() {
|
||||||
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
|
let mut app = test_app(1);
|
||||||
|
// Build a tableau with two cards: a face-down King at bottom, face-up Queen on top.
|
||||||
|
{
|
||||||
|
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
|
let t = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||||
|
t.cards.clear();
|
||||||
|
t.cards.push(Card { id: 900, suit: Suit::Spades, rank: Rank::King, face_up: false });
|
||||||
|
t.cards.push(Card { id: 901, suit: Suit::Hearts, rank: Rank::Queen, face_up: true });
|
||||||
|
}
|
||||||
|
// Set up an empty Tableau(1) for the Queen to land on.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.piles
|
||||||
|
.get_mut(&PileType::Tableau(1))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
|
||||||
|
// A King must be in Tableau(1) for Queen to land there; skip validation
|
||||||
|
// by placing a King first.
|
||||||
|
{
|
||||||
|
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
|
let t = gs.0.piles.get_mut(&PileType::Tableau(1)).unwrap();
|
||||||
|
t.cards.push(Card { id: 902, suit: Suit::Clubs, rank: Rank::King, face_up: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.world_mut().send_event(MoveRequestEvent {
|
||||||
|
from: PileType::Tableau(0),
|
||||||
|
to: PileType::Tableau(1),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Events<crate::events::CardFlippedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(events).collect();
|
||||||
|
assert_eq!(fired.len(), 1, "CardFlippedEvent must fire when a face-down card is exposed");
|
||||||
|
assert_eq!(fired[0].0, 900, "event must carry the flipped card's id");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
|
||||||
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
|
let mut app = test_app(1);
|
||||||
|
// Build a tableau with two face-up cards.
|
||||||
|
{
|
||||||
|
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
|
let t = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||||
|
t.cards.clear();
|
||||||
|
t.cards.push(Card { id: 910, suit: Suit::Clubs, rank: Rank::King, face_up: true });
|
||||||
|
t.cards.push(Card { id: 911, suit: Suit::Hearts, rank: Rank::Queen, face_up: true });
|
||||||
|
}
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.piles
|
||||||
|
.get_mut(&PileType::Tableau(1))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
{
|
||||||
|
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
|
gs.0.piles
|
||||||
|
.get_mut(&PileType::Tableau(1))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.push(Card { id: 912, suit: Suit::Spades, rank: Rank::King, face_up: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.world_mut().send_event(MoveRequestEvent {
|
||||||
|
from: PileType::Tableau(0),
|
||||||
|
to: PileType::Tableau(1),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Events<crate::events::CardFlippedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(events).collect();
|
||||||
|
assert!(fired.is_empty(), "no flip event when exposed card was already face-up");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,15 +44,18 @@ fn spawn_help_screen(commands: &mut Commands) {
|
|||||||
" Click stock Draw".to_string(),
|
" Click stock Draw".to_string(),
|
||||||
String::new(),
|
String::new(),
|
||||||
"-- New Game --".to_string(),
|
"-- New Game --".to_string(),
|
||||||
" N New Classic game".to_string(),
|
" N New Classic game (N twice if in progress)".to_string(),
|
||||||
" C Start today's daily challenge".to_string(),
|
" C Start today's daily challenge".to_string(),
|
||||||
" Z Start a Zen game (level 5+)".to_string(),
|
" Z Start a Zen game (level 5+)".to_string(),
|
||||||
" X Start the next Challenge (level 5+)".to_string(),
|
" X Start the next Challenge (level 5+)".to_string(),
|
||||||
" T Start a Time Attack session (level 5+)".to_string(),
|
" T Start a Time Attack session (level 5+)".to_string(),
|
||||||
String::new(),
|
String::new(),
|
||||||
"-- Overlays --".to_string(),
|
"-- Overlays --".to_string(),
|
||||||
" S Toggle stats / progression".to_string(),
|
" S Stats & progression".to_string(),
|
||||||
" H or ? Toggle this help".to_string(),
|
" A Achievements".to_string(),
|
||||||
|
" L Leaderboard".to_string(),
|
||||||
|
" O Settings".to_string(),
|
||||||
|
" H or ? This help screen".to_string(),
|
||||||
" Esc Pause / resume".to_string(),
|
" Esc Pause / resume".to_string(),
|
||||||
" [ / ] SFX volume down / up".to_string(),
|
" [ / ] SFX volume down / up".to_string(),
|
||||||
String::new(),
|
String::new(),
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
//! Persistent in-game HUD: score, move count, elapsed time, and mode badge.
|
||||||
|
//!
|
||||||
|
//! The HUD spawns once at startup and lives for the app's lifetime. Text is
|
||||||
|
//! refreshed whenever `GameStateResource` changes (which happens on every move
|
||||||
|
//! and every elapsed-time tick), so score, moves, and timer all stay current
|
||||||
|
//! without a separate tick system.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
|
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
|
|
||||||
|
/// Marker on the score text node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct HudScore;
|
||||||
|
|
||||||
|
/// Marker on the move-count text node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct HudMoves;
|
||||||
|
|
||||||
|
/// Marker on the elapsed-time text node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct HudTime;
|
||||||
|
|
||||||
|
/// Marker on the mode badge text node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct HudMode;
|
||||||
|
|
||||||
|
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||||
|
const Z_HUD: i32 = 50;
|
||||||
|
|
||||||
|
pub struct HudPlugin;
|
||||||
|
|
||||||
|
impl Plugin for HudPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.add_systems(Startup, spawn_hud)
|
||||||
|
.add_systems(Update, update_hud.after(GameMutation));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_hud(mut commands: Commands) {
|
||||||
|
let white = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
|
||||||
|
let font = TextFont { font_size: 18.0, ..default() };
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Px(12.0),
|
||||||
|
top: Val::Px(8.0),
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
column_gap: Val::Px(20.0),
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
ZIndex(Z_HUD),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((HudScore, Text::new("Score: 0"), font.clone(), white));
|
||||||
|
b.spawn((HudMoves, Text::new("Moves: 0"), font.clone(), white));
|
||||||
|
b.spawn((HudTime, Text::new("0:00"), font, white));
|
||||||
|
b.spawn((
|
||||||
|
HudMode,
|
||||||
|
Text::new(""),
|
||||||
|
TextFont { font_size: 17.0, ..default() },
|
||||||
|
TextColor(Color::srgb(1.0, 0.85, 0.25)),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn update_hud(
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
time_attack: Option<Res<TimeAttackResource>>,
|
||||||
|
mut score_q: Query<&mut Text, (With<HudScore>, Without<HudMoves>, Without<HudTime>, Without<HudMode>)>,
|
||||||
|
mut moves_q: Query<&mut Text, (With<HudMoves>, Without<HudScore>, Without<HudTime>, Without<HudMode>)>,
|
||||||
|
mut time_q: Query<&mut Text, (With<HudTime>, Without<HudScore>, Without<HudMoves>, Without<HudMode>)>,
|
||||||
|
mut mode_q: Query<&mut Text, (With<HudMode>, Without<HudScore>, Without<HudMoves>, Without<HudTime>)>,
|
||||||
|
) {
|
||||||
|
let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active);
|
||||||
|
|
||||||
|
// Score, moves, and mode only need updating when the game state changes.
|
||||||
|
if game.is_changed() {
|
||||||
|
let g = &game.0;
|
||||||
|
let is_zen = g.mode == GameMode::Zen;
|
||||||
|
if let Ok(mut t) = score_q.get_single_mut() {
|
||||||
|
// Zen mode suppresses score display per spec ("No score display").
|
||||||
|
**t = if is_zen {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("Score: {}", g.score)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Ok(mut t) = moves_q.get_single_mut() {
|
||||||
|
**t = format!("Moves: {}", g.move_count);
|
||||||
|
}
|
||||||
|
if let Ok(mut t) = mode_q.get_single_mut() {
|
||||||
|
**t = match g.mode {
|
||||||
|
GameMode::Classic => match g.draw_mode {
|
||||||
|
DrawMode::DrawOne => String::new(),
|
||||||
|
DrawMode::DrawThree => "Draw 3".to_string(),
|
||||||
|
},
|
||||||
|
GameMode::Zen => "ZEN".to_string(),
|
||||||
|
GameMode::Challenge => "CHALLENGE".to_string(),
|
||||||
|
GameMode::TimeAttack => "TIME ATTACK".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time display: show Time Attack countdown every frame when active;
|
||||||
|
// Zen mode suppresses the timer per spec ("No timer").
|
||||||
|
// Otherwise show game elapsed time (updates once per second via game.is_changed()).
|
||||||
|
let is_zen = game.0.mode == GameMode::Zen;
|
||||||
|
let update_time = (ta_active || game.is_changed()) && !is_zen;
|
||||||
|
if update_time {
|
||||||
|
if let Ok(mut t) = time_q.get_single_mut() {
|
||||||
|
if let Some(ta) = time_attack.as_ref().filter(|ta| ta.active) {
|
||||||
|
let remaining = ta.remaining_secs.max(0.0) as u64;
|
||||||
|
let m = remaining / 60;
|
||||||
|
let s = remaining % 60;
|
||||||
|
**t = format!("{m}:{s:02}");
|
||||||
|
} else {
|
||||||
|
let secs = game.0.elapsed_seconds;
|
||||||
|
let m = secs / 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
**t = format!("{m}:{s:02}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if is_zen && game.is_changed() {
|
||||||
|
// Clear the time display when entering Zen mode.
|
||||||
|
if let Ok(mut t) = time_q.get_single_mut() {
|
||||||
|
**t = String::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(HudPlugin);
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hud_plugin_registers_without_panic() {
|
||||||
|
let _app = headless_app();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_hud_runs_after_game_mutation_without_panic() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
|
GameState::new(42, DrawMode::DrawOne);
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,7 @@
|
|||||||
//! - `U` → `UndoRequestEvent`
|
//! - `U` → `UndoRequestEvent`
|
||||||
//! - `N` → `NewGameRequestEvent { seed: None }`
|
//! - `N` → `NewGameRequestEvent { seed: None }`
|
||||||
//! - `D` → `DrawRequestEvent`
|
//! - `D` → `DrawRequestEvent`
|
||||||
//! - `Esc` → logged as a pause placeholder (no event yet; wired up when the
|
//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
|
||||||
//! pause screen lands in a later phase)
|
|
||||||
//!
|
//!
|
||||||
//! Mouse:
|
//! Mouse:
|
||||||
//! - Left-click on the stock pile (face-down top) → `DrawRequestEvent`
|
//! - Left-click on the stock pile (face-down top) → `DrawRequestEvent`
|
||||||
@@ -26,8 +25,8 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
|||||||
use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC};
|
use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC};
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
|
||||||
UndoRequestEvent,
|
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
@@ -48,7 +47,9 @@ pub struct InputPlugin;
|
|||||||
|
|
||||||
impl Plugin for InputPlugin {
|
impl Plugin for InputPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_systems(
|
app.add_event::<NewGameConfirmEvent>()
|
||||||
|
.add_event::<InfoToastEvent>()
|
||||||
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
handle_keyboard,
|
handle_keyboard,
|
||||||
@@ -62,18 +63,48 @@ impl Plugin for InputPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seconds after the first N press during which a second N confirms new game.
|
||||||
|
const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_keyboard(
|
fn handle_keyboard(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
game: Option<Res<crate::resources::GameStateResource>>,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut confirm_countdown: Local<f32>,
|
||||||
mut undo: EventWriter<UndoRequestEvent>,
|
mut undo: EventWriter<UndoRequestEvent>,
|
||||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||||
|
mut confirm_event: EventWriter<NewGameConfirmEvent>,
|
||||||
|
mut info_toast: EventWriter<InfoToastEvent>,
|
||||||
mut draw: EventWriter<DrawRequestEvent>,
|
mut draw: EventWriter<DrawRequestEvent>,
|
||||||
) {
|
) {
|
||||||
|
// Tick down any active confirmation window.
|
||||||
|
if *confirm_countdown > 0.0 {
|
||||||
|
*confirm_countdown -= time.delta_secs();
|
||||||
|
if *confirm_countdown <= 0.0 {
|
||||||
|
*confirm_countdown = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if keys.just_pressed(KeyCode::KeyU) {
|
if keys.just_pressed(KeyCode::KeyU) {
|
||||||
undo.send(UndoRequestEvent);
|
undo.send(UndoRequestEvent);
|
||||||
}
|
}
|
||||||
if keys.just_pressed(KeyCode::KeyN) {
|
if keys.just_pressed(KeyCode::KeyN) {
|
||||||
|
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
|
||||||
|
if !active_game {
|
||||||
|
// No active game — start immediately.
|
||||||
new_game.send(NewGameRequestEvent::default());
|
new_game.send(NewGameRequestEvent::default());
|
||||||
|
*confirm_countdown = 0.0;
|
||||||
|
} else if *confirm_countdown > 0.0 {
|
||||||
|
// Second press within the window — confirmed.
|
||||||
|
new_game.send(NewGameRequestEvent::default());
|
||||||
|
*confirm_countdown = 0.0;
|
||||||
|
} else {
|
||||||
|
// First press on an active game — require confirmation.
|
||||||
|
*confirm_countdown = NEW_GAME_CONFIRM_WINDOW;
|
||||||
|
confirm_event.send(NewGameConfirmEvent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if keys.just_pressed(KeyCode::KeyZ) {
|
if keys.just_pressed(KeyCode::KeyZ) {
|
||||||
// Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL.
|
// Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL.
|
||||||
@@ -85,10 +116,9 @@ fn handle_keyboard(
|
|||||||
mode: Some(solitaire_core::game_state::GameMode::Zen),
|
mode: Some(solitaire_core::game_state::GameMode::Zen),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
info!(
|
info_toast.send(InfoToastEvent(format!(
|
||||||
"Zen mode locked — reach level {} (currently {}).",
|
"Zen mode unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
|
||||||
CHALLENGE_UNLOCK_LEVEL, level
|
)));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if keys.just_pressed(KeyCode::KeyD) {
|
if keys.just_pressed(KeyCode::KeyD) {
|
||||||
|
|||||||
@@ -0,0 +1,610 @@
|
|||||||
|
//! In-game leaderboard panel.
|
||||||
|
//!
|
||||||
|
//! Press `L` to open the panel. On first open, an async fetch is kicked off
|
||||||
|
//! against the active [`SyncProvider`]. Fetched results are cached in
|
||||||
|
//! [`LeaderboardResource`] and re-displayed without another network trip until
|
||||||
|
//! the user explicitly presses `L` again while the panel is already open
|
||||||
|
//! (which closes it) and then `L` once more (which re-fetches).
|
||||||
|
//!
|
||||||
|
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
|
||||||
|
//! the panel shows "Not available" immediately.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
|
use solitaire_data::settings::SyncBackend;
|
||||||
|
use solitaire_sync::LeaderboardEntry;
|
||||||
|
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Cached leaderboard data. `None` means no fetch has completed yet.
|
||||||
|
#[derive(Resource, Default, Debug, Clone)]
|
||||||
|
pub struct LeaderboardResource(pub Option<Vec<LeaderboardEntry>>);
|
||||||
|
|
||||||
|
/// Set to `true` in the frame the user explicitly closes the panel so that a
|
||||||
|
/// fetch completing in the same frame doesn't immediately reopen it.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct ClosedThisFrame(bool);
|
||||||
|
|
||||||
|
/// In-flight fetch task result carrier — transfers data from the task thread.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct LeaderboardFetchResult(Option<Result<Vec<LeaderboardEntry>, String>>);
|
||||||
|
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct LeaderboardFetchTask(Option<Task<Result<Vec<LeaderboardEntry>, String>>>);
|
||||||
|
|
||||||
|
/// Marker on the leaderboard overlay root node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct LeaderboardScreen;
|
||||||
|
|
||||||
|
/// Marker on the "Opt In" button inside the leaderboard panel.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct LeaderboardOptInButton;
|
||||||
|
|
||||||
|
/// Marker on the "Opt Out" button inside the leaderboard panel.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct LeaderboardOptOutButton;
|
||||||
|
|
||||||
|
/// In-flight opt-in task.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct OptInTask(Option<Task<Result<(), String>>>);
|
||||||
|
|
||||||
|
/// In-flight opt-out task.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct OptOutTask(Option<Task<Result<(), String>>>);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct LeaderboardPlugin;
|
||||||
|
|
||||||
|
impl Plugin for LeaderboardPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<LeaderboardResource>()
|
||||||
|
.init_resource::<LeaderboardFetchResult>()
|
||||||
|
.init_resource::<LeaderboardFetchTask>()
|
||||||
|
.init_resource::<ClosedThisFrame>()
|
||||||
|
.init_resource::<OptInTask>()
|
||||||
|
.init_resource::<OptOutTask>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
reset_closed_flag,
|
||||||
|
toggle_leaderboard_screen,
|
||||||
|
poll_leaderboard_fetch,
|
||||||
|
update_leaderboard_panel,
|
||||||
|
handle_opt_in_button,
|
||||||
|
poll_opt_in_task,
|
||||||
|
handle_opt_out_button,
|
||||||
|
poll_opt_out_task,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Clear the "closed this frame" flag at the start of each frame.
|
||||||
|
fn reset_closed_flag(mut flag: ResMut<ClosedThisFrame>) {
|
||||||
|
flag.0 = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `L` key — open or close the leaderboard panel.
|
||||||
|
/// On open, starts a new fetch if no data is cached or a fetch is not in flight.
|
||||||
|
fn toggle_leaderboard_screen(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||||
|
data: Res<LeaderboardResource>,
|
||||||
|
provider: Option<Res<SyncProviderResource>>,
|
||||||
|
mut task_res: ResMut<LeaderboardFetchTask>,
|
||||||
|
mut closed_flag: ResMut<ClosedThisFrame>,
|
||||||
|
) {
|
||||||
|
if !keys.just_pressed(KeyCode::KeyL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(entity) = screens.get_single() {
|
||||||
|
commands.entity(entity).despawn_recursive();
|
||||||
|
closed_flag.0 = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn the panel immediately with whatever data we have (may be None).
|
||||||
|
spawn_leaderboard_screen(&mut commands, data.0.as_deref());
|
||||||
|
|
||||||
|
// Start a background fetch if not already in flight.
|
||||||
|
if task_res.0.is_none() {
|
||||||
|
if let Some(p) = provider {
|
||||||
|
let provider = p.0.clone();
|
||||||
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
provider.fetch_leaderboard().await.map_err(|e| e.to_string())
|
||||||
|
});
|
||||||
|
task_res.0 = Some(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the background fetch task; store results when complete.
|
||||||
|
fn poll_leaderboard_fetch(
|
||||||
|
mut task_res: ResMut<LeaderboardFetchTask>,
|
||||||
|
mut result_res: ResMut<LeaderboardFetchResult>,
|
||||||
|
) {
|
||||||
|
let Some(task) = task_res.0.as_mut() else { return };
|
||||||
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
|
task_res.0 = None;
|
||||||
|
result_res.0 = Some(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a fetch completes, cache the data and update any open panel.
|
||||||
|
/// Skips the panel rebuild if the user closed the panel in this same frame
|
||||||
|
/// (commands are deferred, so the query would still see the despawned entity).
|
||||||
|
fn update_leaderboard_panel(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut result_res: ResMut<LeaderboardFetchResult>,
|
||||||
|
mut data: ResMut<LeaderboardResource>,
|
||||||
|
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||||
|
closed_flag: Res<ClosedThisFrame>,
|
||||||
|
) {
|
||||||
|
let Some(result) = result_res.0.take() else { return };
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(entries) => {
|
||||||
|
data.0 = Some(entries);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("leaderboard fetch failed: {e}");
|
||||||
|
if data.0.is_none() {
|
||||||
|
data.0 = Some(vec![]); // show empty rather than spinner forever
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the panel if it's open — but not if the user just closed it in
|
||||||
|
// this frame (their despawn command is still deferred).
|
||||||
|
if closed_flag.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn_recursive();
|
||||||
|
spawn_leaderboard_screen(&mut commands, data.0.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires an async opt-in request when the player presses the "Opt In" button.
|
||||||
|
///
|
||||||
|
/// The display name is taken from the configured server username in
|
||||||
|
/// `SettingsResource`. If no server backend is active, the button is a no-op.
|
||||||
|
fn handle_opt_in_button(
|
||||||
|
interaction_query: Query<&Interaction, (Changed<Interaction>, With<LeaderboardOptInButton>)>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
provider: Option<Res<SyncProviderResource>>,
|
||||||
|
mut task_res: ResMut<OptInTask>,
|
||||||
|
) {
|
||||||
|
if task_res.0.is_some() {
|
||||||
|
return; // already in flight
|
||||||
|
}
|
||||||
|
let Some(provider) = provider else { return };
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let display_name = settings
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| {
|
||||||
|
if let SyncBackend::SolitaireServer { username, .. } = &s.0.sync_backend {
|
||||||
|
Some(username.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "Player".to_string());
|
||||||
|
|
||||||
|
let provider = provider.0.clone();
|
||||||
|
let task = AsyncComputeTaskPool::get()
|
||||||
|
.spawn(async move { provider.opt_in_leaderboard(&display_name).await.map_err(|e| e.to_string()) });
|
||||||
|
task_res.0 = Some(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the opt-in task; logs on error, clears on completion.
|
||||||
|
fn poll_opt_in_task(mut task_res: ResMut<OptInTask>) {
|
||||||
|
let Some(task) = task_res.0.as_mut() else { return };
|
||||||
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
|
task_res.0 = None;
|
||||||
|
if let Err(e) = result {
|
||||||
|
warn!("leaderboard opt-in failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires an async opt-out request when the player presses the "Opt Out" button.
|
||||||
|
fn handle_opt_out_button(
|
||||||
|
interaction_query: Query<&Interaction, (Changed<Interaction>, With<LeaderboardOptOutButton>)>,
|
||||||
|
provider: Option<Res<SyncProviderResource>>,
|
||||||
|
mut task_res: ResMut<OptOutTask>,
|
||||||
|
) {
|
||||||
|
if task_res.0.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(provider) = provider else { return };
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let provider = provider.0.clone();
|
||||||
|
let task = AsyncComputeTaskPool::get()
|
||||||
|
.spawn(async move { provider.opt_out_leaderboard().await.map_err(|e| e.to_string()) });
|
||||||
|
task_res.0 = Some(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the opt-out task; logs on error, clears on completion.
|
||||||
|
fn poll_opt_out_task(mut task_res: ResMut<OptOutTask>) {
|
||||||
|
let Some(task) = task_res.0.as_mut() else { return };
|
||||||
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
|
task_res.0 = None;
|
||||||
|
if let Err(e) = result {
|
||||||
|
warn!("leaderboard opt-out failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI construction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[LeaderboardEntry]>) {
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
LeaderboardScreen,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Percent(0.0),
|
||||||
|
top: Val::Percent(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.82)),
|
||||||
|
ZIndex(210),
|
||||||
|
))
|
||||||
|
.with_children(|root| {
|
||||||
|
root.spawn((
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
padding: UiRect::all(Val::Px(28.0)),
|
||||||
|
row_gap: Val::Px(8.0),
|
||||||
|
min_width: Val::Px(420.0),
|
||||||
|
max_height: Val::Percent(80.0),
|
||||||
|
overflow: Overflow::clip_y(),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
|
||||||
|
BorderRadius::all(Val::Px(8.0)),
|
||||||
|
))
|
||||||
|
.with_children(|card| {
|
||||||
|
// Header
|
||||||
|
card.spawn((
|
||||||
|
Text::new("Leaderboard"),
|
||||||
|
TextFont { font_size: 26.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
card.spawn((
|
||||||
|
Text::new("Press L to close • Opt In / Opt Out to control your visibility"),
|
||||||
|
TextFont { font_size: 14.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.55, 0.55, 0.60)),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
card.spawn((
|
||||||
|
Node {
|
||||||
|
height: Val::Px(1.0),
|
||||||
|
margin: UiRect::vertical(Val::Px(6.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Opt-in / Opt-out buttons row
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
column_gap: Val::Px(10.0),
|
||||||
|
margin: UiRect::bottom(Val::Px(8.0)),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
LeaderboardOptInButton,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgb(0.18, 0.35, 0.50)),
|
||||||
|
BorderRadius::all(Val::Px(4.0)),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new("Opt In"),
|
||||||
|
TextFont { font_size: 15.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
row.spawn((
|
||||||
|
LeaderboardOptOutButton,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgb(0.42, 0.15, 0.15)),
|
||||||
|
BorderRadius::all(Val::Px(4.0)),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new("Opt Out"),
|
||||||
|
TextFont { font_size: 15.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
match entries {
|
||||||
|
None => {
|
||||||
|
// Fetch in progress
|
||||||
|
card.spawn((
|
||||||
|
Text::new("Fetching…"),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.65, 0.65, 0.70)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some([]) => {
|
||||||
|
card.spawn((
|
||||||
|
Text::new("No entries yet — sync and opt in to appear here."),
|
||||||
|
TextFont { font_size: 16.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.55, 0.55, 0.60)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(rows) => {
|
||||||
|
// Column headers
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
column_gap: Val::Px(16.0),
|
||||||
|
margin: UiRect::bottom(Val::Px(4.0)),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
header_cell(row, "#", 30.0);
|
||||||
|
header_cell(row, "Player", 160.0);
|
||||||
|
header_cell(row, "Best Score", 100.0);
|
||||||
|
header_cell(row, "Fastest Win", 110.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Data rows (top 10)
|
||||||
|
let mut sorted = rows.to_vec();
|
||||||
|
sorted.sort_by(|a, b| {
|
||||||
|
b.best_score
|
||||||
|
.unwrap_or(0)
|
||||||
|
.cmp(&a.best_score.unwrap_or(0))
|
||||||
|
});
|
||||||
|
|
||||||
|
for (i, entry) in sorted.iter().take(10).enumerate() {
|
||||||
|
let rank_color = match i {
|
||||||
|
0 => Color::srgb(1.0, 0.84, 0.0),
|
||||||
|
1 => Color::srgb(0.75, 0.75, 0.75),
|
||||||
|
2 => Color::srgb(0.80, 0.50, 0.20),
|
||||||
|
_ => Color::srgb(0.80, 0.80, 0.80),
|
||||||
|
};
|
||||||
|
|
||||||
|
let time_str = entry
|
||||||
|
.best_time_secs
|
||||||
|
.map(format_secs)
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
let score_str = entry
|
||||||
|
.best_score
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
column_gap: Val::Px(16.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
data_cell(row, &format!("{}", i + 1), 30.0, rank_color);
|
||||||
|
data_cell(row, &entry.display_name, 160.0, Color::WHITE);
|
||||||
|
data_cell(row, &score_str, 100.0, Color::WHITE);
|
||||||
|
data_cell(row, &time_str, 110.0, Color::WHITE);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_cell(parent: &mut ChildBuilder, text: &str, width: f32) {
|
||||||
|
parent.spawn((
|
||||||
|
Text::new(text.to_string()),
|
||||||
|
TextFont { font_size: 13.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.55, 0.75, 0.55)),
|
||||||
|
Node { width: Val::Px(width), ..default() },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_cell(parent: &mut ChildBuilder, text: &str, width: f32, color: Color) {
|
||||||
|
parent.spawn((
|
||||||
|
Text::new(text.to_string()),
|
||||||
|
TextFont { font_size: 15.0, ..default() },
|
||||||
|
TextColor(color),
|
||||||
|
Node { width: Val::Px(width), ..default() },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_secs(secs: u64) -> String {
|
||||||
|
let m = secs / 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
if m > 0 {
|
||||||
|
format!("{m}:{s:02}")
|
||||||
|
} else {
|
||||||
|
format!("{s}s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use crate::sync_plugin::SyncPlugin;
|
||||||
|
use solitaire_data::SyncError;
|
||||||
|
use solitaire_sync::{SyncPayload, SyncResponse};
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use solitaire_sync::PlayerProgress;
|
||||||
|
use solitaire_data::StatsSnapshot;
|
||||||
|
|
||||||
|
struct NoOpProvider;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl solitaire_data::SyncProvider for NoOpProvider {
|
||||||
|
async fn pull(&self) -> Result<SyncPayload, SyncError> {
|
||||||
|
Ok(SyncPayload {
|
||||||
|
user_id: Uuid::nil(),
|
||||||
|
stats: StatsSnapshot::default(),
|
||||||
|
achievements: vec![],
|
||||||
|
progress: PlayerProgress::default(),
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async fn push(&self, _p: &SyncPayload) -> Result<SyncResponse, SyncError> {
|
||||||
|
Ok(SyncResponse {
|
||||||
|
merged: SyncPayload {
|
||||||
|
user_id: Uuid::nil(),
|
||||||
|
stats: StatsSnapshot::default(),
|
||||||
|
achievements: vec![],
|
||||||
|
progress: PlayerProgress::default(),
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
},
|
||||||
|
server_time: Utc::now(),
|
||||||
|
conflicts: vec![],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn backend_name(&self) -> &'static str { "no-op" }
|
||||||
|
fn is_authenticated(&self) -> bool { false }
|
||||||
|
|
||||||
|
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
|
||||||
|
Ok(vec![
|
||||||
|
LeaderboardEntry {
|
||||||
|
display_name: "Alice".to_string(),
|
||||||
|
best_score: Some(5000),
|
||||||
|
best_time_secs: Some(180),
|
||||||
|
recorded_at: Utc::now(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(crate::stats_plugin::StatsPlugin::headless())
|
||||||
|
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||||
|
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
|
||||||
|
.add_plugins(SyncPlugin::new(NoOpProvider))
|
||||||
|
.add_plugins(LeaderboardPlugin);
|
||||||
|
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(app: &mut App, key: KeyCode) {
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(key);
|
||||||
|
input.clear();
|
||||||
|
input.press(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resource_starts_empty() {
|
||||||
|
let app = headless_app();
|
||||||
|
assert!(app.world().resource::<LeaderboardResource>().0.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_l_spawns_screen() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
press(&mut app, KeyCode::KeyL);
|
||||||
|
app.update();
|
||||||
|
let count = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&LeaderboardScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_l_twice_dismisses_screen() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
press(&mut app, KeyCode::KeyL);
|
||||||
|
app.update();
|
||||||
|
press(&mut app, KeyCode::KeyL);
|
||||||
|
app.update();
|
||||||
|
let count = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&LeaderboardScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_secs_below_minute() {
|
||||||
|
assert_eq!(format_secs(45), "45s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_secs_above_minute() {
|
||||||
|
assert_eq!(format_secs(183), "3:03");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_secs_zero() {
|
||||||
|
assert_eq!(format_secs(0), "0s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_secs_59_stays_below_minute() {
|
||||||
|
assert_eq!(format_secs(59), "59s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_secs_60_crosses_into_minutes() {
|
||||||
|
assert_eq!(format_secs(60), "1:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_secs_pads_seconds_with_leading_zero() {
|
||||||
|
// 65 seconds = 1:05, not 1:5
|
||||||
|
assert_eq!(format_secs(65), "1:05");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
pub mod achievement_plugin;
|
pub mod achievement_plugin;
|
||||||
pub mod animation_plugin;
|
pub mod animation_plugin;
|
||||||
|
pub mod auto_complete_plugin;
|
||||||
pub mod audio_plugin;
|
pub mod audio_plugin;
|
||||||
pub mod card_plugin;
|
pub mod card_plugin;
|
||||||
pub mod challenge_plugin;
|
pub mod challenge_plugin;
|
||||||
@@ -9,6 +10,8 @@ pub mod daily_challenge_plugin;
|
|||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod game_plugin;
|
pub mod game_plugin;
|
||||||
pub mod help_plugin;
|
pub mod help_plugin;
|
||||||
|
pub mod hud_plugin;
|
||||||
|
pub mod leaderboard_plugin;
|
||||||
pub mod input_plugin;
|
pub mod input_plugin;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod onboarding_plugin;
|
pub mod onboarding_plugin;
|
||||||
@@ -17,11 +20,12 @@ pub mod settings_plugin;
|
|||||||
pub mod progress_plugin;
|
pub mod progress_plugin;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
pub mod stats_plugin;
|
pub mod stats_plugin;
|
||||||
|
pub mod sync_plugin;
|
||||||
pub mod table_plugin;
|
pub mod table_plugin;
|
||||||
pub mod time_attack_plugin;
|
pub mod time_attack_plugin;
|
||||||
pub mod weekly_goals_plugin;
|
pub mod weekly_goals_plugin;
|
||||||
|
|
||||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource};
|
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||||
pub use challenge_plugin::{
|
pub use challenge_plugin::{
|
||||||
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
|
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
|
||||||
};
|
};
|
||||||
@@ -31,23 +35,28 @@ 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::{AnimationPlugin, CardAnim};
|
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
||||||
|
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};
|
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
||||||
pub use events::{
|
pub use events::{
|
||||||
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
|
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent,
|
||||||
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
|
||||||
|
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
pub use game_plugin::{GameMutation, GamePlugin};
|
pub use game_plugin::{GameMutation, GamePlugin, GameStatePath};
|
||||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||||
|
pub use hud_plugin::HudPlugin;
|
||||||
|
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
||||||
pub use input_plugin::InputPlugin;
|
pub use input_plugin::InputPlugin;
|
||||||
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
||||||
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
|
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
|
||||||
pub use settings_plugin::{
|
pub use settings_plugin::{
|
||||||
SettingsChangedEvent, SettingsPlugin, SettingsResource, SFX_STEP,
|
SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, SFX_STEP,
|
||||||
};
|
};
|
||||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||||
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
|
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
|
||||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||||
|
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||||
pub use table_plugin::{PileMarker, TableBackground, TablePlugin};
|
pub use table_plugin::{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,
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
//! input-blocking on top if desired.
|
//! input-blocking on top if desired.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use solitaire_data::save_game_state_to;
|
||||||
|
|
||||||
|
use crate::game_plugin::GameStatePath;
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
|
||||||
/// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`.
|
/// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`.
|
||||||
#[derive(Resource, Debug, Default)]
|
#[derive(Resource, Debug, Default)]
|
||||||
@@ -34,6 +38,8 @@ fn toggle_pause(
|
|||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
mut paused: ResMut<PausedResource>,
|
mut paused: ResMut<PausedResource>,
|
||||||
screens: Query<Entity, With<PauseScreen>>,
|
screens: Query<Entity, With<PauseScreen>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
path: Option<Res<GameStatePath>>,
|
||||||
) {
|
) {
|
||||||
if !keys.just_pressed(KeyCode::Escape) {
|
if !keys.just_pressed(KeyCode::Escape) {
|
||||||
return;
|
return;
|
||||||
@@ -44,6 +50,15 @@ fn toggle_pause(
|
|||||||
} else {
|
} else {
|
||||||
spawn_pause_screen(&mut commands);
|
spawn_pause_screen(&mut commands);
|
||||||
paused.0 = true;
|
paused.0 = true;
|
||||||
|
// Persist the current game state whenever the player opens the pause
|
||||||
|
// overlay so an OS-level kill still leaves a resumable save.
|
||||||
|
if let (Some(g), Some(p)) = (game, path) {
|
||||||
|
if let Some(disk_path) = p.0.as_deref() {
|
||||||
|
if let Err(e) = save_game_state_to(disk_path, &g.0) {
|
||||||
|
warn!("game_state: failed to save on pause: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,4 +151,28 @@ mod tests {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_is_symmetric_for_multiple_cycles() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Third press re-pauses after resume.
|
||||||
|
press_esc(&mut app);
|
||||||
|
app.update();
|
||||||
|
press_esc(&mut app);
|
||||||
|
app.update();
|
||||||
|
press_esc(&mut app);
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<PausedResource>().0,
|
||||||
|
"third Esc must re-pause"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&PauseScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"third Esc must re-spawn PauseScreen"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use solitaire_data::{
|
|||||||
load_progress_from, progress_file_path, save_progress_to, xp_for_win, PlayerProgress,
|
load_progress_from, progress_file_path, save_progress_to, xp_for_win, PlayerProgress,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::events::GameWonEvent;
|
use crate::events::{GameWonEvent, XpAwardedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
|
||||||
@@ -65,6 +65,7 @@ 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_event::<LevelUpEvent>()
|
||||||
|
.add_event::<XpAwardedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -78,6 +79,7 @@ impl Plugin for ProgressPlugin {
|
|||||||
fn award_xp_on_win(
|
fn award_xp_on_win(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: EventReader<GameWonEvent>,
|
||||||
mut levelups: EventWriter<LevelUpEvent>,
|
mut levelups: EventWriter<LevelUpEvent>,
|
||||||
|
mut xp_awarded: EventWriter<XpAwardedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
path: Res<ProgressStoragePath>,
|
path: Res<ProgressStoragePath>,
|
||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
@@ -86,6 +88,7 @@ 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 });
|
||||||
if progress.0.leveled_up_from(prev_level) {
|
if progress.0.leveled_up_from(prev_level) {
|
||||||
levelups.send(LevelUpEvent {
|
levelups.send(LevelUpEvent {
|
||||||
previous_level: prev_level,
|
previous_level: prev_level,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
//! Persists `solitaire_data::Settings` and exposes hotkeys for live tuning.
|
//! Persists `solitaire_data::Settings`, exposes hotkeys for live tuning,
|
||||||
|
//! and renders a Bevy UI Settings panel.
|
||||||
//!
|
//!
|
||||||
//! Hotkeys (always active, no overlay required):
|
//! Hotkeys (always active, no overlay required):
|
||||||
//! - `[` decrease SFX volume by `SFX_STEP`
|
//! - `[` — decrease SFX volume by `SFX_STEP`
|
||||||
//! - `]` increase SFX volume by `SFX_STEP`
|
//! - `]` — increase SFX volume by `SFX_STEP`
|
||||||
|
//! - `O` — open / close the Settings panel
|
||||||
//!
|
//!
|
||||||
//! On change, the plugin persists `settings.json` and fires
|
//! On change, the plugin persists `settings.json` and fires
|
||||||
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.
|
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.
|
||||||
@@ -10,39 +12,109 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, Settings};
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
|
||||||
|
|
||||||
/// Volume adjustment step.
|
use crate::events::ManualSyncRequestEvent;
|
||||||
|
use crate::progress_plugin::ProgressResource;
|
||||||
|
use crate::resources::{SyncStatus, SyncStatusResource};
|
||||||
|
|
||||||
|
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
||||||
pub const SFX_STEP: f32 = 0.1;
|
pub const SFX_STEP: f32 = 0.1;
|
||||||
|
|
||||||
/// Bevy resource wrapping the current `Settings`.
|
/// Bevy resource wrapping the current `Settings`.
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct SettingsResource(pub Settings);
|
pub struct SettingsResource(pub Settings);
|
||||||
|
|
||||||
/// Persistence path for `SettingsResource`. `None` disables I/O.
|
/// Persistence path for `SettingsResource`. `None` disables I/O (used in tests).
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct SettingsStoragePath(pub Option<PathBuf>);
|
pub struct SettingsStoragePath(pub Option<PathBuf>);
|
||||||
|
|
||||||
/// Fired any time settings change so consumers (audio, UI) can react.
|
/// Whether the Settings panel is currently visible. Toggle with `O`.
|
||||||
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
|
pub struct SettingsScreen(pub bool);
|
||||||
|
|
||||||
|
/// Fired whenever settings change so consumers (audio, UI) can react.
|
||||||
#[derive(Event, Debug, Clone)]
|
#[derive(Event, Debug, Clone)]
|
||||||
pub struct SettingsChangedEvent(pub Settings);
|
pub struct SettingsChangedEvent(pub Settings);
|
||||||
|
|
||||||
|
/// Marker on the root Settings panel entity.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SettingsPanel;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the live SFX volume value.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SfxVolumeText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the live music volume value.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct MusicVolumeText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current draw mode.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct DrawModeText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current theme.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ThemeText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the live sync status.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SyncStatusText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the active card-back index.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct CardBackText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current animation speed.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct AnimSpeedText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the active background index.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct BackgroundText;
|
||||||
|
|
||||||
|
/// Tags interactive buttons inside the Settings panel.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
enum SettingsButton {
|
||||||
|
SfxDown,
|
||||||
|
SfxUp,
|
||||||
|
MusicDown,
|
||||||
|
MusicUp,
|
||||||
|
ToggleDrawMode,
|
||||||
|
CycleAnimSpeed,
|
||||||
|
ToggleTheme,
|
||||||
|
CycleCardBack,
|
||||||
|
CycleBackground,
|
||||||
|
SyncNow,
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin that owns the settings lifecycle.
|
||||||
pub struct SettingsPlugin {
|
pub struct SettingsPlugin {
|
||||||
|
/// Path to `settings.json`. `None` in headless/test mode.
|
||||||
pub storage_path: Option<PathBuf>,
|
pub storage_path: Option<PathBuf>,
|
||||||
|
/// When `false`, panel spawn/despawn systems are not registered.
|
||||||
|
/// Use [`SettingsPlugin::headless`] for tests running under `MinimalPlugins`.
|
||||||
|
pub ui_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SettingsPlugin {
|
impl Default for SettingsPlugin {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
storage_path: settings_file_path(),
|
storage_path: settings_file_path(),
|
||||||
|
ui_enabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SettingsPlugin {
|
impl SettingsPlugin {
|
||||||
/// Plugin configured with no persistence — for tests and headless apps.
|
/// No persistence, no UI — safe to use under `MinimalPlugins` in tests.
|
||||||
pub fn headless() -> Self {
|
pub fn headless() -> Self {
|
||||||
Self { storage_path: None }
|
Self {
|
||||||
|
storage_path: None,
|
||||||
|
ui_enabled: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,27 +126,49 @@ impl Plugin for SettingsPlugin {
|
|||||||
};
|
};
|
||||||
app.insert_resource(SettingsResource(loaded))
|
app.insert_resource(SettingsResource(loaded))
|
||||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||||
|
.init_resource::<SettingsScreen>()
|
||||||
.add_event::<SettingsChangedEvent>()
|
.add_event::<SettingsChangedEvent>()
|
||||||
.add_systems(Update, handle_volume_keys);
|
.add_event::<ManualSyncRequestEvent>()
|
||||||
|
.add_systems(Update, (handle_volume_keys, toggle_settings_screen));
|
||||||
|
|
||||||
|
if self.ui_enabled {
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
sync_settings_panel_visibility,
|
||||||
|
handle_settings_buttons,
|
||||||
|
update_sync_status_text,
|
||||||
|
update_card_back_text,
|
||||||
|
update_background_text,
|
||||||
|
update_anim_speed_text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn persist(path: &SettingsStoragePath, settings: &Settings) {
|
fn persist(path: &SettingsStoragePath, settings: &Settings) {
|
||||||
let Some(target) = &path.0 else {
|
let Some(target) = &path.0 else { return };
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Err(e) = save_settings_to(target, settings) {
|
if let Err(e) = save_settings_to(target, settings) {
|
||||||
warn!("failed to save settings: {e}");
|
warn!("failed to save settings: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn handle_volume_keys(
|
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: EventWriter<SettingsChangedEvent>,
|
||||||
) {
|
) {
|
||||||
let mut delta = 0.0;
|
let mut delta = 0.0_f32;
|
||||||
if keys.just_pressed(KeyCode::BracketLeft) {
|
if keys.just_pressed(KeyCode::BracketLeft) {
|
||||||
delta -= SFX_STEP;
|
delta -= SFX_STEP;
|
||||||
}
|
}
|
||||||
@@ -87,13 +181,638 @@ fn handle_volume_keys(
|
|||||||
let before = settings.0.sfx_volume;
|
let before = settings.0.sfx_volume;
|
||||||
let after = settings.0.adjust_sfx_volume(delta);
|
let after = settings.0.adjust_sfx_volume(delta);
|
||||||
if (before - after).abs() < f32::EPSILON {
|
if (before - after).abs() < f32::EPSILON {
|
||||||
// Already at the rail — no point persisting or notifying.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens or closes the Settings panel when `O` is pressed.
|
||||||
|
fn toggle_settings_screen(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut screen: ResMut<SettingsScreen>,
|
||||||
|
) {
|
||||||
|
if keys.just_pressed(KeyCode::KeyO) {
|
||||||
|
screen.0 = !screen.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
||||||
|
/// despawns it when it becomes `false`.
|
||||||
|
fn sync_settings_panel_visibility(
|
||||||
|
screen: Res<SettingsScreen>,
|
||||||
|
panels: Query<Entity, With<SettingsPanel>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
sync_status: Option<Res<SyncStatusResource>>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
) {
|
||||||
|
if !screen.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if screen.0 {
|
||||||
|
if panels.is_empty() {
|
||||||
|
let status_label = sync_status
|
||||||
|
.map(|s| sync_status_label(&s.0))
|
||||||
|
.unwrap_or_else(|| "Status: not configured".to_string());
|
||||||
|
let unlocked_backs = progress
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.0.unlocked_card_backs.as_slice())
|
||||||
|
.unwrap_or(&[0]);
|
||||||
|
let unlocked_bgs = progress
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.0.unlocked_backgrounds.as_slice())
|
||||||
|
.unwrap_or(&[0]);
|
||||||
|
spawn_settings_panel(
|
||||||
|
&mut commands,
|
||||||
|
&settings.0,
|
||||||
|
&status_label,
|
||||||
|
unlocked_backs,
|
||||||
|
unlocked_bgs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for entity in &panels {
|
||||||
|
commands.entity(entity).despawn_recursive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the next unlocked index after `current` in the sorted `unlocked` list.
|
||||||
|
/// Wraps around. Falls back to `unlocked[0]` if `current` is not found.
|
||||||
|
fn cycle_unlocked(unlocked: &[usize], current: usize) -> usize {
|
||||||
|
if unlocked.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let pos = unlocked.iter().position(|&i| i == current).unwrap_or(0);
|
||||||
|
unlocked[(pos + 1) % unlocked.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the sync-status text node current while the panel is open.
|
||||||
|
fn update_sync_status_text(
|
||||||
|
sync_status: Option<Res<SyncStatusResource>>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<SyncStatusText>>,
|
||||||
|
) {
|
||||||
|
let Some(status) = sync_status else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !status.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let label = sync_status_label(&status.0);
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = label.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_card_back_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<CardBackText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = card_back_label(settings.0.selected_card_back);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_background_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<BackgroundText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = background_label(settings.0.selected_background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_anim_speed_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<AnimSpeedText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = anim_speed_label(&settings.0.animation_speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn card_back_label(idx: usize) -> String {
|
||||||
|
if idx == 0 {
|
||||||
|
"Default".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Style {idx}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn background_label(idx: usize) -> String {
|
||||||
|
if idx == 0 {
|
||||||
|
"Default".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Style {idx}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_status_label(status: &SyncStatus) -> String {
|
||||||
|
match status {
|
||||||
|
SyncStatus::Idle => "Status: idle".to_string(),
|
||||||
|
SyncStatus::Syncing => "Status: syncing…".to_string(),
|
||||||
|
SyncStatus::LastSynced(t) => {
|
||||||
|
let secs = chrono::Utc::now()
|
||||||
|
.signed_duration_since(*t)
|
||||||
|
.num_seconds()
|
||||||
|
.max(0);
|
||||||
|
if secs < 60 {
|
||||||
|
format!("Last synced: {secs}s ago")
|
||||||
|
} else {
|
||||||
|
format!("Last synced: {}m ago", secs / 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reacts to button presses inside the Settings panel.
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||||
|
fn handle_settings_buttons(
|
||||||
|
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
mut screen: ResMut<SettingsScreen>,
|
||||||
|
path: Res<SettingsStoragePath>,
|
||||||
|
mut changed: EventWriter<SettingsChangedEvent>,
|
||||||
|
mut manual_sync: EventWriter<ManualSyncRequestEvent>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
||||||
|
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
||||||
|
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
||||||
|
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<AnimSpeedText>)>,
|
||||||
|
mut anim_speed_text: Query<&mut Text, (With<AnimSpeedText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
|
||||||
|
) {
|
||||||
|
for (interaction, button) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match button {
|
||||||
|
SettingsButton::SfxDown => {
|
||||||
|
let before = settings.0.sfx_volume;
|
||||||
|
let after = settings.0.adjust_sfx_volume(-SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = sfx_text.get_single_mut() {
|
||||||
|
**t = format!("{:.2}", after);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::SfxUp => {
|
||||||
|
let before = settings.0.sfx_volume;
|
||||||
|
let after = settings.0.adjust_sfx_volume(SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = sfx_text.get_single_mut() {
|
||||||
|
**t = format!("{:.2}", after);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::MusicDown => {
|
||||||
|
let before = settings.0.music_volume;
|
||||||
|
let after = settings.0.adjust_music_volume(-SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = music_text.get_single_mut() {
|
||||||
|
**t = format!("{:.2}", after);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::MusicUp => {
|
||||||
|
let before = settings.0.music_volume;
|
||||||
|
let after = settings.0.adjust_music_volume(SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = music_text.get_single_mut() {
|
||||||
|
**t = format!("{:.2}", after);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleDrawMode => {
|
||||||
|
settings.0.draw_mode = match settings.0.draw_mode {
|
||||||
|
DrawMode::DrawOne => DrawMode::DrawThree,
|
||||||
|
DrawMode::DrawThree => DrawMode::DrawOne,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = draw_text.get_single_mut() {
|
||||||
|
**t = draw_mode_label(&settings.0.draw_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::CycleAnimSpeed => {
|
||||||
|
settings.0.animation_speed = match settings.0.animation_speed {
|
||||||
|
AnimSpeed::Normal => AnimSpeed::Fast,
|
||||||
|
AnimSpeed::Fast => AnimSpeed::Instant,
|
||||||
|
AnimSpeed::Instant => AnimSpeed::Normal,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = anim_speed_text.get_single_mut() {
|
||||||
|
**t = anim_speed_label(&settings.0.animation_speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleTheme => {
|
||||||
|
settings.0.theme = match settings.0.theme {
|
||||||
|
Theme::Green => Theme::Blue,
|
||||||
|
Theme::Blue => Theme::Dark,
|
||||||
|
Theme::Dark => Theme::Green,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = theme_text.get_single_mut() {
|
||||||
|
**t = theme_label(&settings.0.theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::CycleCardBack => {
|
||||||
|
let unlocked = progress
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.0.unlocked_card_backs.clone())
|
||||||
|
.unwrap_or_else(|| vec![0]);
|
||||||
|
settings.0.selected_card_back =
|
||||||
|
cycle_unlocked(&unlocked, settings.0.selected_card_back);
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
SettingsButton::CycleBackground => {
|
||||||
|
let unlocked = progress
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.0.unlocked_backgrounds.clone())
|
||||||
|
.unwrap_or_else(|| vec![0]);
|
||||||
|
settings.0.selected_background =
|
||||||
|
cycle_unlocked(&unlocked, settings.0.selected_background);
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
SettingsButton::SyncNow => {
|
||||||
|
manual_sync.send(ManualSyncRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::Done => {
|
||||||
|
screen.0 = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_mode_label(mode: &DrawMode) -> String {
|
||||||
|
match mode {
|
||||||
|
DrawMode::DrawOne => "Draw 1".into(),
|
||||||
|
DrawMode::DrawThree => "Draw 3".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn anim_speed_label(speed: &AnimSpeed) -> String {
|
||||||
|
match speed {
|
||||||
|
AnimSpeed::Normal => "Normal".into(),
|
||||||
|
AnimSpeed::Fast => "Fast".into(),
|
||||||
|
AnimSpeed::Instant => "Instant".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_label(theme: &Theme) -> String {
|
||||||
|
match theme {
|
||||||
|
Theme::Green => "Green".into(),
|
||||||
|
Theme::Blue => "Blue".into(),
|
||||||
|
Theme::Dark => "Dark".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI construction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn spawn_settings_panel(
|
||||||
|
commands: &mut Commands,
|
||||||
|
settings: &Settings,
|
||||||
|
sync_status: &str,
|
||||||
|
unlocked_card_backs: &[usize],
|
||||||
|
unlocked_backgrounds: &[usize],
|
||||||
|
) {
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
SettingsPanel,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Percent(0.0),
|
||||||
|
top: Val::Percent(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.72)),
|
||||||
|
ZIndex(200),
|
||||||
|
))
|
||||||
|
.with_children(|root| {
|
||||||
|
// Inner card — max_height + clip_y keeps it on-screen on small windows.
|
||||||
|
root.spawn((
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
padding: UiRect::all(Val::Px(28.0)),
|
||||||
|
row_gap: Val::Px(14.0),
|
||||||
|
min_width: Val::Px(340.0),
|
||||||
|
max_height: Val::Percent(88.0),
|
||||||
|
overflow: Overflow::clip_y(),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
|
||||||
|
BorderRadius::all(Val::Px(8.0)),
|
||||||
|
))
|
||||||
|
.with_children(|card| {
|
||||||
|
// Title
|
||||||
|
card.spawn((
|
||||||
|
Text::new("Settings"),
|
||||||
|
TextFont {
|
||||||
|
font_size: 30.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
|
||||||
|
// --- Audio section ---
|
||||||
|
section_label(card, "Audio");
|
||||||
|
|
||||||
|
// SFX volume row
|
||||||
|
volume_row(card, "SFX Volume", settings.sfx_volume, SfxVolumeText,
|
||||||
|
SettingsButton::SfxDown, SettingsButton::SfxUp);
|
||||||
|
|
||||||
|
// Music volume row
|
||||||
|
volume_row(card, "Music Volume", settings.music_volume, MusicVolumeText,
|
||||||
|
SettingsButton::MusicDown, SettingsButton::MusicUp);
|
||||||
|
|
||||||
|
// --- Gameplay section ---
|
||||||
|
section_label(card, "Gameplay");
|
||||||
|
|
||||||
|
// Draw mode row
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: Val::Px(8.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new("Draw Mode"),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||||
|
));
|
||||||
|
row.spawn((
|
||||||
|
DrawModeText,
|
||||||
|
Text::new(draw_mode_label(&settings.draw_mode)),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
icon_button(row, "⇄", SettingsButton::ToggleDrawMode);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animation speed row
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: Val::Px(8.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new("Anim Speed"),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||||
|
));
|
||||||
|
row.spawn((
|
||||||
|
AnimSpeedText,
|
||||||
|
Text::new(anim_speed_label(&settings.animation_speed)),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
icon_button(row, "⇄", SettingsButton::CycleAnimSpeed);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Appearance section ---
|
||||||
|
section_label(card, "Appearance");
|
||||||
|
|
||||||
|
// Theme row
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: Val::Px(8.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new("Theme"),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||||
|
));
|
||||||
|
row.spawn((
|
||||||
|
ThemeText,
|
||||||
|
Text::new(theme_label(&settings.theme)),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
icon_button(row, "⇄", SettingsButton::ToggleTheme);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Card back row — only shown when the player has unlocked more than one.
|
||||||
|
if unlocked_card_backs.len() > 1 {
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: Val::Px(8.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new("Card Back"),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||||
|
));
|
||||||
|
row.spawn((
|
||||||
|
CardBackText,
|
||||||
|
Text::new(card_back_label(settings.selected_card_back)),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
icon_button(row, "⇄", SettingsButton::CycleCardBack);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background row — only shown when the player has unlocked more than one.
|
||||||
|
if unlocked_backgrounds.len() > 1 {
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: Val::Px(8.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new("Background"),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||||
|
));
|
||||||
|
row.spawn((
|
||||||
|
BackgroundText,
|
||||||
|
Text::new(background_label(settings.selected_background)),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
icon_button(row, "⇄", SettingsButton::CycleBackground);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sync section ---
|
||||||
|
section_label(card, "Sync");
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: Val::Px(10.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
SyncStatusText,
|
||||||
|
Text::new(sync_status.to_string()),
|
||||||
|
TextFont { font_size: 16.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.65, 0.65, 0.70)),
|
||||||
|
));
|
||||||
|
// "Sync Now" button — hidden when SyncPlugin is not installed;
|
||||||
|
// visible because ManualSyncRequestEvent is always registered.
|
||||||
|
row.spawn((
|
||||||
|
SettingsButton::SyncNow,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
|
||||||
|
BorderRadius::all(Val::Px(4.0)),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new("Sync Now"),
|
||||||
|
TextFont { font_size: 14.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Done button
|
||||||
|
card.spawn((
|
||||||
|
SettingsButton::Done,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(Val::Px(20.0), Val::Px(8.0)),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
margin: UiRect::top(Val::Px(6.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
|
||||||
|
BorderRadius::all(Val::Px(4.0)),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new("Done"),
|
||||||
|
TextFont {
|
||||||
|
font_size: 18.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn section_label(parent: &mut ChildBuilder, title: &str) {
|
||||||
|
parent.spawn((
|
||||||
|
Text::new(title),
|
||||||
|
TextFont {
|
||||||
|
font_size: 14.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(Color::srgb(0.55, 0.75, 0.55)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic volume row: `Label 0.80 [−] [+]`
|
||||||
|
fn volume_row<Marker: Component>(
|
||||||
|
parent: &mut ChildBuilder,
|
||||||
|
label: &str,
|
||||||
|
value: f32,
|
||||||
|
marker: Marker,
|
||||||
|
btn_down: SettingsButton,
|
||||||
|
btn_up: SettingsButton,
|
||||||
|
) {
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: Val::Px(8.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new(label.to_string()),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||||
|
));
|
||||||
|
row.spawn((
|
||||||
|
marker,
|
||||||
|
Text::new(format!("{:.2}", value)),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
icon_button(row, "−", btn_down);
|
||||||
|
icon_button(row, "+", btn_up);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon_button(parent: &mut ChildBuilder, label: &str, action: SettingsButton) {
|
||||||
|
parent
|
||||||
|
.spawn((
|
||||||
|
action,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
width: Val::Px(28.0),
|
||||||
|
height: Val::Px(28.0),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
|
||||||
|
BorderRadius::all(Val::Px(4.0)),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new(label.to_string()),
|
||||||
|
TextFont {
|
||||||
|
font_size: 18.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -142,7 +861,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pressing_right_bracket_increases_volume() {
|
fn pressing_right_bracket_increases_volume() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Drop volume first so there's headroom to grow.
|
|
||||||
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.5;
|
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.5;
|
||||||
|
|
||||||
press(&mut app, KeyCode::BracketRight);
|
press(&mut app, KeyCode::BracketRight);
|
||||||
@@ -155,7 +873,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn clamped_change_does_not_emit_event() {
|
fn clamped_change_does_not_emit_event() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Already at max — pressing right bracket should be a no-op.
|
|
||||||
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 1.0;
|
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 1.0;
|
||||||
|
|
||||||
press(&mut app, KeyCode::BracketRight);
|
press(&mut app, KeyCode::BracketRight);
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ use std::path::PathBuf;
|
|||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
load_stats_from, save_stats_to, stats_file_path, PlayerProgress, StatsSnapshot, WEEKLY_GOALS,
|
load_stats_from, save_stats_to, stats_file_path, PlayerProgress, StatsExt, StatsSnapshot,
|
||||||
|
WEEKLY_GOALS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::challenge_plugin::challenge_progress_label;
|
||||||
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
@@ -170,12 +172,16 @@ fn spawn_stats_screen(
|
|||||||
"=== Statistics ===".to_string(),
|
"=== Statistics ===".to_string(),
|
||||||
format!("Games Played: {}", stats.games_played),
|
format!("Games Played: {}", stats.games_played),
|
||||||
format!("Games Won: {}", stats.games_won),
|
format!("Games Won: {}", stats.games_won),
|
||||||
|
format!("Games Lost: {}", stats.games_lost),
|
||||||
format!("Win Rate: {win_rate}"),
|
format!("Win Rate: {win_rate}"),
|
||||||
format!(
|
format!(
|
||||||
"Win Streak: {} (Best: {})",
|
"Win Streak: {} (Best: {})",
|
||||||
stats.win_streak_current, stats.win_streak_best
|
stats.win_streak_current, stats.win_streak_best
|
||||||
),
|
),
|
||||||
|
format!("Draw 1 Wins: {}", stats.draw_one_wins),
|
||||||
|
format!("Draw 3 Wins: {}", stats.draw_three_wins),
|
||||||
format!("Best Score: {}", stats.best_single_score),
|
format!("Best Score: {}", stats.best_single_score),
|
||||||
|
format!("Lifetime Score:{}", stats.lifetime_score),
|
||||||
format!("Fastest Win: {fastest}"),
|
format!("Fastest Win: {fastest}"),
|
||||||
format!("Avg Win Time: {avg}"),
|
format!("Avg Win Time: {avg}"),
|
||||||
];
|
];
|
||||||
@@ -185,10 +191,15 @@ fn spawn_stats_screen(
|
|||||||
lines.push("=== Progression ===".to_string());
|
lines.push("=== Progression ===".to_string());
|
||||||
lines.push(format!("Level: {}", p.level));
|
lines.push(format!("Level: {}", p.level));
|
||||||
lines.push(format!("Total XP: {}", p.total_xp));
|
lines.push(format!("Total XP: {}", p.total_xp));
|
||||||
|
lines.push(format!("Next Level: {}", xp_to_next_level_label(p.total_xp, p.level)));
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
"Daily Streak: {}",
|
"Daily Streak: {}",
|
||||||
p.daily_challenge_streak
|
p.daily_challenge_streak
|
||||||
));
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
"Challenge: {}",
|
||||||
|
challenge_progress_label(p.challenge_index)
|
||||||
|
));
|
||||||
lines.push(String::new());
|
lines.push(String::new());
|
||||||
lines.push("-- Weekly Goals --".to_string());
|
lines.push("-- Weekly Goals --".to_string());
|
||||||
for goal in WEEKLY_GOALS {
|
for goal in WEEKLY_GOALS {
|
||||||
@@ -260,6 +271,25 @@ fn spawn_stats_screen(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns XP remaining until next level, formatted as "N XP (P%)".
|
||||||
|
fn xp_to_next_level_label(total_xp: u64, level: u32) -> String {
|
||||||
|
let xp_current = if level < 10 {
|
||||||
|
level as u64 * 500
|
||||||
|
} else {
|
||||||
|
5_000 + (level as u64 - 10) * 1_000
|
||||||
|
};
|
||||||
|
let xp_next = if level < 10 {
|
||||||
|
(level as u64 + 1) * 500
|
||||||
|
} else {
|
||||||
|
5_000 + (level as u64 - 9) * 1_000
|
||||||
|
};
|
||||||
|
let span = xp_next - xp_current;
|
||||||
|
let done = total_xp.saturating_sub(xp_current).min(span);
|
||||||
|
let pct = if span == 0 { 100 } else { done.saturating_mul(100).checked_div(span).unwrap_or(100) };
|
||||||
|
let remaining = span - done;
|
||||||
|
format!("{remaining} XP ({pct}%)")
|
||||||
|
}
|
||||||
|
|
||||||
fn format_duration(secs: u64) -> String {
|
fn format_duration(secs: u64) -> String {
|
||||||
let m = secs / 60;
|
let m = secs / 60;
|
||||||
let s = secs % 60;
|
let s = secs % 60;
|
||||||
@@ -424,4 +454,24 @@ mod tests {
|
|||||||
fn format_id_list_sorts_dedups_and_prefixes() {
|
fn format_id_list_sorts_dedups_and_prefixes() {
|
||||||
assert_eq!(format_id_list(&[3, 1, 1, 2]), "#1, #2, #3");
|
assert_eq!(format_id_list(&[3, 1, 1, 2]), "#1, #2, #3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xp_to_next_level_label_at_zero_xp() {
|
||||||
|
// Level 0, 0 XP: 500 needed, 0% done.
|
||||||
|
assert_eq!(xp_to_next_level_label(0, 0), "500 XP (0%)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xp_to_next_level_label_halfway_through_level_1() {
|
||||||
|
// Level 1 starts at 500 XP, level 2 at 1000 XP.
|
||||||
|
// At 750 XP: 250 done of 500, 50%, 250 remaining.
|
||||||
|
assert_eq!(xp_to_next_level_label(750, 1), "250 XP (50%)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xp_to_next_level_label_at_level_10_boundary() {
|
||||||
|
// Level 10 starts at 5000 XP, level 11 at 6000 XP.
|
||||||
|
// At 5000 XP: 0 done, 0%, 1000 remaining.
|
||||||
|
assert_eq!(xp_to_next_level_label(5_000, 10), "1000 XP (0%)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
//! Backend-agnostic sync plugin for Solitaire Quest.
|
||||||
|
//!
|
||||||
|
//! On startup, the plugin spawns an async pull task on [`AsyncComputeTaskPool`]
|
||||||
|
//! that fetches the remote payload from the active [`SyncProvider`]. Once the
|
||||||
|
//! task resolves, the merged result is written to disk and the in-world
|
||||||
|
//! resources are updated. On app exit, a blocking push sends the current local
|
||||||
|
//! state to the backend.
|
||||||
|
//!
|
||||||
|
//! The plugin is completely backend-agnostic: the caller (usually
|
||||||
|
//! `solitaire_app`) constructs the right [`SyncProvider`] implementation and
|
||||||
|
//! passes it to [`SyncPlugin::new`]. No `match` on a backend enum variant ever
|
||||||
|
//! occurs inside this module.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use solitaire_data::{
|
||||||
|
save_achievements_to, save_progress_to, save_stats_to, AchievementRecord, PlayerProgress,
|
||||||
|
StatsSnapshot, SyncError, SyncProvider,
|
||||||
|
};
|
||||||
|
use solitaire_sync::{merge, SyncPayload};
|
||||||
|
|
||||||
|
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||||
|
use crate::events::ManualSyncRequestEvent;
|
||||||
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
|
use crate::resources::{SyncStatus, SyncStatusResource};
|
||||||
|
use crate::stats_plugin::{StatsResource, StatsStoragePath};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public resources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Wraps the active sync backend. Shared with async tasks via [`Arc`].
|
||||||
|
///
|
||||||
|
/// Registered by [`SyncPlugin`] during `build()`. Other plugins may read this
|
||||||
|
/// resource to check [`SyncProvider::is_authenticated`] or
|
||||||
|
/// [`SyncProvider::backend_name`].
|
||||||
|
#[derive(Resource, Clone)]
|
||||||
|
pub struct SyncProviderResource(pub Arc<dyn SyncProvider + Send + Sync>);
|
||||||
|
|
||||||
|
/// Holds a pending pull result transferred from the async compute task to the
|
||||||
|
/// main thread. Consumed and cleared by [`poll_pull_result`].
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct PullTaskResult(pub Option<Result<SyncPayload, SyncError>>);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal resources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Holds the in-flight pull task so [`poll_pull_result`] can check its status
|
||||||
|
/// each frame without blocking the main thread.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct PullTask(Option<Task<Result<SyncPayload, SyncError>>>);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin struct
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Bevy plugin that manages the full sync lifecycle:
|
||||||
|
///
|
||||||
|
/// - **Startup** — spawns an async pull task on [`AsyncComputeTaskPool`].
|
||||||
|
/// - **Update** — polls the task each frame; on completion merges the remote
|
||||||
|
/// payload with local data, persists the result, and updates in-world
|
||||||
|
/// resources.
|
||||||
|
/// - **Last** — on [`AppExit`], performs a blocking push of the current local
|
||||||
|
/// state to the active backend.
|
||||||
|
///
|
||||||
|
/// Construct via [`SyncPlugin::new`], passing any type that implements
|
||||||
|
/// [`SyncProvider`].
|
||||||
|
pub struct SyncPlugin {
|
||||||
|
provider: Arc<dyn SyncProvider + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncPlugin {
|
||||||
|
/// Create a new [`SyncPlugin`] backed by the given [`SyncProvider`].
|
||||||
|
///
|
||||||
|
/// The provider is heap-allocated and reference-counted so it can be
|
||||||
|
/// cloned cheaply into async tasks.
|
||||||
|
pub fn new(provider: impl SyncProvider + 'static) -> Self {
|
||||||
|
Self {
|
||||||
|
provider: Arc::new(provider),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plugin for SyncPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.insert_resource(SyncProviderResource(self.provider.clone()))
|
||||||
|
.init_resource::<SyncStatusResource>()
|
||||||
|
.init_resource::<PullTaskResult>()
|
||||||
|
.init_resource::<PullTask>()
|
||||||
|
.add_event::<ManualSyncRequestEvent>()
|
||||||
|
.add_systems(Startup, start_pull)
|
||||||
|
.add_systems(Update, (poll_pull_result, handle_manual_sync_request))
|
||||||
|
.add_systems(Last, push_on_exit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Startup system: spawns the async pull task and sets status to `Syncing`.
|
||||||
|
fn start_pull(
|
||||||
|
provider: Res<SyncProviderResource>,
|
||||||
|
mut task_res: ResMut<PullTask>,
|
||||||
|
mut status: ResMut<SyncStatusResource>,
|
||||||
|
) {
|
||||||
|
let provider = provider.0.clone();
|
||||||
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
provider.pull().await
|
||||||
|
});
|
||||||
|
task_res.0 = Some(task);
|
||||||
|
status.0 = SyncStatus::Syncing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update system: starts a new pull task when `ManualSyncRequestEvent` is
|
||||||
|
/// received, but only if no pull is already in flight.
|
||||||
|
fn handle_manual_sync_request(
|
||||||
|
mut events: EventReader<ManualSyncRequestEvent>,
|
||||||
|
provider: Res<SyncProviderResource>,
|
||||||
|
mut task_res: ResMut<PullTask>,
|
||||||
|
mut status: ResMut<SyncStatusResource>,
|
||||||
|
) {
|
||||||
|
if events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.clear();
|
||||||
|
if task_res.0.is_some() {
|
||||||
|
return; // Already pulling — ignore.
|
||||||
|
}
|
||||||
|
let provider = provider.0.clone();
|
||||||
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
provider.pull().await
|
||||||
|
});
|
||||||
|
task_res.0 = Some(task);
|
||||||
|
status.0 = SyncStatus::Syncing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update system: polls the pull task without blocking.
|
||||||
|
///
|
||||||
|
/// When the task resolves successfully:
|
||||||
|
/// 1. Merges the remote payload with the current local state.
|
||||||
|
/// 2. Persists the merged result atomically.
|
||||||
|
/// 3. Updates the in-world [`StatsResource`], [`AchievementsResource`], and
|
||||||
|
/// [`ProgressResource`].
|
||||||
|
/// 4. Sets [`SyncStatusResource`] to [`SyncStatus::LastSynced`].
|
||||||
|
///
|
||||||
|
/// On failure, sets [`SyncStatusResource`] to [`SyncStatus::Error`].
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn poll_pull_result(
|
||||||
|
mut task_res: ResMut<PullTask>,
|
||||||
|
mut status: ResMut<SyncStatusResource>,
|
||||||
|
mut stats: ResMut<StatsResource>,
|
||||||
|
stats_path: Res<StatsStoragePath>,
|
||||||
|
mut achievements: ResMut<AchievementsResource>,
|
||||||
|
achievements_path: Res<AchievementsStoragePath>,
|
||||||
|
mut progress: ResMut<ProgressResource>,
|
||||||
|
progress_path: Res<ProgressStoragePath>,
|
||||||
|
) {
|
||||||
|
let Some(task) = task_res.0.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
task_res.0 = None;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(remote) => {
|
||||||
|
let local = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||||
|
let (merged, _conflicts) = merge(&local, &remote);
|
||||||
|
|
||||||
|
// Persist merged state atomically.
|
||||||
|
if let Some(p) = &stats_path.0 {
|
||||||
|
if let Err(e) = save_stats_to(p, &merged.stats) {
|
||||||
|
warn!("sync: failed to persist stats: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(p) = &achievements_path.0 {
|
||||||
|
if let Err(e) = save_achievements_to(p, &merged.achievements) {
|
||||||
|
warn!("sync: failed to persist achievements: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(p) = &progress_path.0 {
|
||||||
|
if let Err(e) = save_progress_to(p, &merged.progress) {
|
||||||
|
warn!("sync: failed to persist progress: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in-world resources.
|
||||||
|
stats.0 = merged.stats;
|
||||||
|
achievements.0 = merged.achievements;
|
||||||
|
progress.0 = merged.progress;
|
||||||
|
status.0 = SyncStatus::LastSynced(Utc::now());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("sync pull failed: {e}");
|
||||||
|
let msg = match &e {
|
||||||
|
SyncError::Network(_) => "Can't reach server — check your connection".to_string(),
|
||||||
|
SyncError::Auth(_) => "Login expired — tap Sync Now after re-logging in".to_string(),
|
||||||
|
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
|
||||||
|
SyncError::UnsupportedPlatform => "Sync not configured".to_string(),
|
||||||
|
};
|
||||||
|
status.0 = SyncStatus::Error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last-schedule system: pushes the current local state on [`AppExit`].
|
||||||
|
///
|
||||||
|
/// A blocking push is acceptable here — ARCHITECTURE.md §4 explicitly notes
|
||||||
|
/// that blocking on exit is permitted because the game loop is already
|
||||||
|
/// shutting down.
|
||||||
|
fn push_on_exit(
|
||||||
|
mut exit_events: EventReader<AppExit>,
|
||||||
|
provider: Res<SyncProviderResource>,
|
||||||
|
stats: Res<StatsResource>,
|
||||||
|
achievements: Res<AchievementsResource>,
|
||||||
|
progress: Res<ProgressResource>,
|
||||||
|
) {
|
||||||
|
if exit_events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exit_events.clear();
|
||||||
|
|
||||||
|
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||||
|
let provider = provider.0.clone();
|
||||||
|
|
||||||
|
// Prefer an existing tokio runtime; fall back to futures_lite block_on
|
||||||
|
// for environments (e.g. tests) that don't have one.
|
||||||
|
match tokio::runtime::Handle::try_current() {
|
||||||
|
Ok(handle) => {
|
||||||
|
let _ = handle.block_on(provider.push(&payload));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = future::block_on(provider.push(&payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Constructs a [`SyncPayload`] from the current in-world state.
|
||||||
|
///
|
||||||
|
/// `user_id` is set to [`Uuid::nil()`] — the server replaces it with the
|
||||||
|
/// authenticated user's real ID when it processes the push request.
|
||||||
|
fn build_payload(
|
||||||
|
stats: &StatsSnapshot,
|
||||||
|
achievements: &[AchievementRecord],
|
||||||
|
progress: &PlayerProgress,
|
||||||
|
) -> SyncPayload {
|
||||||
|
SyncPayload {
|
||||||
|
user_id: Uuid::nil(),
|
||||||
|
stats: stats.clone(),
|
||||||
|
achievements: achievements.to_vec(),
|
||||||
|
progress: progress.clone(),
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use solitaire_data::SyncError;
|
||||||
|
use solitaire_sync::SyncResponse;
|
||||||
|
|
||||||
|
/// A no-op sync provider that always returns a default payload on pull
|
||||||
|
/// and succeeds silently on push. Used to exercise the plugin in headless
|
||||||
|
/// tests without any network I/O.
|
||||||
|
struct NoOpProvider;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl SyncProvider for NoOpProvider {
|
||||||
|
async fn pull(&self) -> Result<SyncPayload, SyncError> {
|
||||||
|
Ok(SyncPayload {
|
||||||
|
user_id: Uuid::nil(),
|
||||||
|
stats: StatsSnapshot::default(),
|
||||||
|
achievements: vec![],
|
||||||
|
progress: PlayerProgress::default(),
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn push(&self, _payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
|
||||||
|
Ok(SyncResponse {
|
||||||
|
merged: SyncPayload {
|
||||||
|
user_id: Uuid::nil(),
|
||||||
|
stats: StatsSnapshot::default(),
|
||||||
|
achievements: vec![],
|
||||||
|
progress: PlayerProgress::default(),
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
},
|
||||||
|
server_time: Utc::now(),
|
||||||
|
conflicts: vec![],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend_name(&self) -> &'static str {
|
||||||
|
"no-op"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_authenticated(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A provider that always fails on pull, used to test the error path.
|
||||||
|
struct FailingProvider;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl SyncProvider for FailingProvider {
|
||||||
|
async fn pull(&self) -> Result<SyncPayload, SyncError> {
|
||||||
|
Err(SyncError::Network("simulated failure".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn push(&self, _payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
|
||||||
|
Err(SyncError::Network("simulated failure".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend_name(&self) -> &'static str {
|
||||||
|
"failing"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_authenticated(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn headless_app_with(provider: impl SyncProvider + 'static) -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(crate::game_plugin::GamePlugin)
|
||||||
|
.add_plugins(crate::table_plugin::TablePlugin)
|
||||||
|
.add_plugins(crate::stats_plugin::StatsPlugin::headless())
|
||||||
|
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||||
|
.add_plugins(crate::achievement_plugin::AchievementPlugin::headless())
|
||||||
|
.add_plugins(SyncPlugin::new(provider));
|
||||||
|
// MinimalPlugins does not register keyboard input.
|
||||||
|
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_provider_resource_is_registered() {
|
||||||
|
let app = headless_app_with(NoOpProvider);
|
||||||
|
assert!(app.world().get_resource::<SyncProviderResource>().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_status_becomes_syncing_on_startup() {
|
||||||
|
// After the first update() the startup system has run and set Syncing,
|
||||||
|
// but the async task may not have resolved yet.
|
||||||
|
let mut app = headless_app_with(NoOpProvider);
|
||||||
|
// Run a second update to give the task pool a chance to complete.
|
||||||
|
app.update();
|
||||||
|
// Status is either Syncing (task still running) or LastSynced (resolved).
|
||||||
|
let status = &app.world().resource::<SyncStatusResource>().0;
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
status,
|
||||||
|
SyncStatus::Syncing | SyncStatus::LastSynced(_)
|
||||||
|
),
|
||||||
|
"status should be Syncing or LastSynced, got {status:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_failure_sets_error_status() {
|
||||||
|
let mut app = headless_app_with(FailingProvider);
|
||||||
|
// Pump frames until the task resolves (it's synchronous under
|
||||||
|
// AsyncComputeTaskPool in test mode, so a few updates suffice).
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
let status = &app.world().resource::<SyncStatusResource>().0;
|
||||||
|
assert!(
|
||||||
|
matches!(status, SyncStatus::Error(_)),
|
||||||
|
"expected Error status after failing pull, got {status:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_payload_sets_nil_user_id() {
|
||||||
|
let payload = build_payload(
|
||||||
|
&StatsSnapshot::default(),
|
||||||
|
&[],
|
||||||
|
&PlayerProgress::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(payload.user_id, Uuid::nil());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_payload_clones_stats() {
|
||||||
|
let mut stats = StatsSnapshot::default();
|
||||||
|
stats.games_played = 42;
|
||||||
|
let payload = build_payload(&stats, &[], &PlayerProgress::default());
|
||||||
|
assert_eq!(payload.stats.games_played, 42);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,10 @@ use bevy::prelude::*;
|
|||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::card::Suit;
|
use solitaire_core::card::Suit;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
use solitaire_data::settings::Theme;
|
||||||
|
|
||||||
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
|
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
|
||||||
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
|
|
||||||
/// Z-depth used for the background — below everything.
|
/// Z-depth used for the background — below everything.
|
||||||
const Z_BACKGROUND: f32 = -10.0;
|
const Z_BACKGROUND: f32 = -10.0;
|
||||||
@@ -34,8 +36,30 @@ impl Plugin for TablePlugin {
|
|||||||
// 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_event::<WindowResized>()
|
||||||
|
.add_event::<SettingsChangedEvent>()
|
||||||
.add_systems(Startup, setup_table)
|
.add_systems(Startup, setup_table)
|
||||||
.add_systems(Update, on_window_resized);
|
.add_systems(Update, (on_window_resized, apply_theme_on_settings_change));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the felt colour for a given theme.
|
||||||
|
fn theme_colour(theme: &Theme) -> Color {
|
||||||
|
match theme {
|
||||||
|
Theme::Green => Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]),
|
||||||
|
Theme::Blue => Color::srgb(0.059, 0.196, 0.322),
|
||||||
|
Theme::Dark => Color::srgb(0.08, 0.08, 0.10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective table background colour: unlocked background index overrides the
|
||||||
|
/// Theme when `selected_background > 0`.
|
||||||
|
fn effective_background_colour(theme: &Theme, selected_background: usize) -> Color {
|
||||||
|
match selected_background {
|
||||||
|
0 => theme_colour(theme),
|
||||||
|
1 => Color::srgb(0.25, 0.18, 0.10), // dark wood
|
||||||
|
2 => Color::srgb(0.05, 0.08, 0.22), // navy
|
||||||
|
3 => Color::srgb(0.30, 0.05, 0.08), // burgundy
|
||||||
|
_ => Color::srgb(0.12, 0.12, 0.14), // charcoal (4+)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +71,7 @@ fn setup_table(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
existing_camera: Query<(), With<Camera>>,
|
existing_camera: Query<(), With<Camera>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
) {
|
) {
|
||||||
// Only spawn a camera if one does not already exist (e.g. a parent app
|
// Only spawn a camera if one does not already exist (e.g. a parent app
|
||||||
// may have added one in tests).
|
// may have added one in tests).
|
||||||
@@ -61,18 +86,23 @@ fn setup_table(
|
|||||||
.unwrap_or(Vec2::new(1280.0, 800.0));
|
.unwrap_or(Vec2::new(1280.0, 800.0));
|
||||||
let layout = compute_layout(window_size);
|
let layout = compute_layout(window_size);
|
||||||
|
|
||||||
spawn_background(&mut commands, window_size);
|
let initial_colour = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| effective_background_colour(&s.0.theme, s.0.selected_background))
|
||||||
|
.unwrap_or_else(|| Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]));
|
||||||
|
|
||||||
|
spawn_background(&mut commands, window_size, initial_colour);
|
||||||
spawn_pile_markers(&mut commands, &layout);
|
spawn_pile_markers(&mut commands, &layout);
|
||||||
commands.insert_resource(LayoutResource(layout));
|
commands.insert_resource(LayoutResource(layout));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_background(commands: &mut Commands, window_size: Vec2) {
|
fn spawn_background(commands: &mut Commands, window_size: Vec2, color: Color) {
|
||||||
// Spawn a felt-coloured rectangle that always covers the window. We give
|
// Spawn a felt-coloured rectangle that always covers the window. We give
|
||||||
// it the window size plus headroom so resizing up doesn't expose edges
|
// it the window size plus headroom so resizing up doesn't expose edges
|
||||||
// before the resize handler runs.
|
// before the resize handler runs.
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
Sprite {
|
Sprite {
|
||||||
color: Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]),
|
color,
|
||||||
custom_size: Some(window_size * 2.0),
|
custom_size: Some(window_size * 2.0),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
@@ -81,6 +111,19 @@ fn spawn_background(commands: &mut Commands, window_size: Vec2) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_theme_on_settings_change(
|
||||||
|
mut events: EventReader<SettingsChangedEvent>,
|
||||||
|
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
|
||||||
|
) {
|
||||||
|
let Some(ev) = events.read().last() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let colour = effective_background_colour(&ev.0.theme, ev.0.selected_background);
|
||||||
|
for mut sprite in &mut backgrounds {
|
||||||
|
sprite.color = colour;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||||
let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||||
let marker_size = layout.card_size;
|
let marker_size = layout.card_size;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use bevy::prelude::*;
|
|||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
|
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
use crate::events::{GameWonEvent, InfoToastEvent, NewGameRequestEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
@@ -39,6 +39,7 @@ impl Plugin for TimeAttackPlugin {
|
|||||||
.add_event::<TimeAttackEndedEvent>()
|
.add_event::<TimeAttackEndedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_event::<NewGameRequestEvent>()
|
||||||
|
.add_event::<InfoToastEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
handle_start_time_attack_request.before(GameMutation),
|
handle_start_time_attack_request.before(GameMutation),
|
||||||
@@ -53,15 +54,15 @@ fn handle_start_time_attack_request(
|
|||||||
progress: Res<ProgressResource>,
|
progress: Res<ProgressResource>,
|
||||||
mut session: ResMut<TimeAttackResource>,
|
mut session: ResMut<TimeAttackResource>,
|
||||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||||
|
mut info_toast: EventWriter<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!(
|
info_toast.send(InfoToastEvent(format!(
|
||||||
"Time Attack locked — reach level {} (currently {}).",
|
"Time Attack unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
|
||||||
CHALLENGE_UNLOCK_LEVEL, progress.0.level
|
)));
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
*session = TimeAttackResource {
|
*session = TimeAttackResource {
|
||||||
@@ -178,14 +179,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn timer_expiry_fires_ended_event_and_clears_active() {
|
fn timer_expiry_fires_ended_event_and_clears_active() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Manually start a near-expired session.
|
// Set the session to an already-expired state (remaining < 0).
|
||||||
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
// MinimalPlugins time delta is nonzero so we skip the intermediate
|
||||||
active: true,
|
// 0.001-remaining step to avoid a double-fire.
|
||||||
remaining_secs: 0.001,
|
|
||||||
wins: 5,
|
|
||||||
};
|
|
||||||
app.update();
|
|
||||||
// First update advances time slightly; force the timer past zero.
|
|
||||||
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
||||||
active: true,
|
active: true,
|
||||||
remaining_secs: -1.0,
|
remaining_secs: -1.0,
|
||||||
@@ -269,4 +265,32 @@ mod tests {
|
|||||||
let session = app.world().resource::<TimeAttackResource>();
|
let session = app.world().resource::<TimeAttackResource>();
|
||||||
assert_eq!(session.wins, 0);
|
assert_eq!(session.wins, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paused_session_does_not_fire_ended_event() {
|
||||||
|
// Insert PausedResource(true) so the advance system exits early.
|
||||||
|
// Even with remaining_secs at -1 (which would normally trigger expiry),
|
||||||
|
// the timer must not fire while the game is paused.
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(crate::pause_plugin::PausedResource(true));
|
||||||
|
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
|
||||||
|
active: true,
|
||||||
|
remaining_secs: -1.0, // would normally expire
|
||||||
|
wins: 3,
|
||||||
|
};
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// remaining_secs must not have been reset to 0.0 (pause blocked the update).
|
||||||
|
let session = app.world().resource::<TimeAttackResource>();
|
||||||
|
assert!(session.active, "session must still be active while paused");
|
||||||
|
assert!(session.remaining_secs < 0.0, "remaining_secs must not change while paused");
|
||||||
|
|
||||||
|
// No ended event must have been emitted.
|
||||||
|
let events = app.world().resource::<Events<TimeAttackEndedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(events).next().is_none(),
|
||||||
|
"TimeAttackEndedEvent must not fire while paused"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ use solitaire_data::{
|
|||||||
WEEKLY_GOAL_XP,
|
WEEKLY_GOAL_XP,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::events::GameWonEvent;
|
use crate::events::{GameWonEvent, XpAwardedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||||
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.
|
||||||
@@ -27,6 +27,8 @@ impl Plugin for WeeklyGoalsPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_event::<WeeklyGoalCompletedEvent>()
|
app.add_event::<WeeklyGoalCompletedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
|
.add_event::<XpAwardedEvent>()
|
||||||
|
.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).
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@@ -38,12 +40,30 @@ impl Plugin for WeeklyGoalsPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rolls weekly-goal counters at startup so stale progress from a previous
|
||||||
|
/// week never shows in the UI when the player launches the game.
|
||||||
|
fn roll_weekly_goals_on_startup(
|
||||||
|
mut progress: ResMut<ProgressResource>,
|
||||||
|
path: Res<ProgressStoragePath>,
|
||||||
|
) {
|
||||||
|
let week_key = current_iso_week_key(Local::now().date_naive());
|
||||||
|
if progress.0.roll_weekly_goals_if_new_week(&week_key) {
|
||||||
|
if let Some(target) = &path.0 {
|
||||||
|
if let Err(e) = save_progress_to(target, &progress.0) {
|
||||||
|
warn!("failed to save progress after weekly reset on startup: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn evaluate_weekly_goals(
|
fn evaluate_weekly_goals(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: EventReader<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: EventWriter<WeeklyGoalCompletedEvent>,
|
||||||
|
mut levelups: EventWriter<LevelUpEvent>,
|
||||||
|
mut xp_awarded: EventWriter<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() {
|
||||||
@@ -62,6 +82,7 @@ fn evaluate_weekly_goals(
|
|||||||
let ctx = WeeklyGoalContext {
|
let ctx = WeeklyGoalContext {
|
||||||
time_seconds: ev.time_seconds,
|
time_seconds: ev.time_seconds,
|
||||||
used_undo: game.0.undo_count > 0,
|
used_undo: game.0.undo_count > 0,
|
||||||
|
draw_mode: game.0.draw_mode.clone(),
|
||||||
};
|
};
|
||||||
for def in WEEKLY_GOALS {
|
for def in WEEKLY_GOALS {
|
||||||
if !def.matches(&ctx) {
|
if !def.matches(&ctx) {
|
||||||
@@ -80,7 +101,15 @@ fn evaluate_weekly_goals(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if bonus_xp > 0 {
|
if bonus_xp > 0 {
|
||||||
progress.0.add_xp(bonus_xp);
|
xp_awarded.send(XpAwardedEvent { amount: bonus_xp });
|
||||||
|
let prev_level = progress.0.add_xp(bonus_xp);
|
||||||
|
if progress.0.leveled_up_from(prev_level) {
|
||||||
|
levelups.send(LevelUpEvent {
|
||||||
|
previous_level: prev_level,
|
||||||
|
new_level: progress.0.level,
|
||||||
|
total_xp: progress.0.total_xp,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if any_change {
|
if any_change {
|
||||||
@@ -166,11 +195,16 @@ mod tests {
|
|||||||
fn completing_a_goal_fires_event_and_awards_bonus() {
|
fn completing_a_goal_fires_event_and_awards_bonus() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// Pre-set the weekly_3_fast goal to 2/3 so the next fast win completes it.
|
// Pre-set the weekly_3_fast goal to 2/3 so the next fast win completes it.
|
||||||
app.world_mut()
|
// Also pre-complete weekly_1_under_five (target=1) and weekly_5_wins /
|
||||||
.resource_mut::<ProgressResource>()
|
// weekly_3_no_undo at target so a 60-second win only completes weekly_3_fast,
|
||||||
.0
|
// keeping the XP delta predictable.
|
||||||
.weekly_goal_progress
|
{
|
||||||
.insert("weekly_3_fast".to_string(), 2);
|
let mut p = app.world_mut().resource_mut::<ProgressResource>();
|
||||||
|
p.0.weekly_goal_progress.insert("weekly_3_fast".to_string(), 2);
|
||||||
|
p.0.weekly_goal_progress.insert("weekly_1_under_five".to_string(), 1);
|
||||||
|
p.0.weekly_goal_progress.insert("weekly_5_wins".to_string(), 5);
|
||||||
|
p.0.weekly_goal_progress.insert("weekly_3_no_undo".to_string(), 3);
|
||||||
|
}
|
||||||
// Match the current ISO week key so roll_weekly_goals doesn't clear it.
|
// Match the current ISO week key so roll_weekly_goals doesn't clear it.
|
||||||
let key = current_iso_week_key(Local::now().date_naive());
|
let key = current_iso_week_key(Local::now().date_naive());
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
@@ -200,6 +234,64 @@ mod tests {
|
|||||||
assert!(fired.iter().any(|e| e.goal_id == "weekly_3_fast"));
|
assert!(fired.iter().any(|e| e.goal_id == "weekly_3_fast"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_weekly_progress_is_cleared_on_startup() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Inject progress from a past week.
|
||||||
|
{
|
||||||
|
let mut p = app.world_mut().resource_mut::<ProgressResource>();
|
||||||
|
p.0.weekly_goal_week_iso = Some("1970-W01".to_string());
|
||||||
|
p.0.weekly_goal_progress
|
||||||
|
.insert("weekly_5_wins".to_string(), 3);
|
||||||
|
}
|
||||||
|
// A second Startup run (re-init) is hard to trigger directly; instead
|
||||||
|
// call the helper through a fresh app that starts with stale data.
|
||||||
|
// Here we simulate the effect: roll_weekly_goals_if_new_week clears.
|
||||||
|
let current_week = current_iso_week_key(Local::now().date_naive());
|
||||||
|
let rolled = app
|
||||||
|
.world_mut()
|
||||||
|
.resource_mut::<ProgressResource>()
|
||||||
|
.0
|
||||||
|
.roll_weekly_goals_if_new_week(¤t_week);
|
||||||
|
assert!(rolled, "expected stale week to trigger a roll");
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<ProgressResource>()
|
||||||
|
.0
|
||||||
|
.weekly_goal_progress
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weekly_bonus_xp_fires_levelup_when_threshold_crossed() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Set XP just below the first level boundary (500) so the 75-XP bonus crosses it.
|
||||||
|
app.world_mut().resource_mut::<ProgressResource>().0.total_xp = 430;
|
||||||
|
// Pre-set goal to 2/3 so the next fast win completes it.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ProgressResource>()
|
||||||
|
.0
|
||||||
|
.weekly_goal_progress
|
||||||
|
.insert("weekly_3_fast".to_string(), 2);
|
||||||
|
let key = current_iso_week_key(Local::now().date_naive());
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ProgressResource>()
|
||||||
|
.0
|
||||||
|
.weekly_goal_week_iso = Some(key);
|
||||||
|
|
||||||
|
app.world_mut().send_event(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 60,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Events<LevelUpEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
|
assert!(!fired.is_empty(), "LevelUpEvent must fire when weekly bonus pushes past a level threshold");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn weekly_goal_description_resolves_known_and_unknown() {
|
fn weekly_goal_description_resolves_known_and_unknown() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ name = "solitaire_server"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "solitaire_server"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "solitaire_server"
|
name = "solitaire_server"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
@@ -23,3 +27,10 @@ tower_governor = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
solitaire_sync = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
jsonwebtoken = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Migration 001: initial schema
|
||||||
|
-- Creates the core tables required by the Solitaire Quest sync server.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID v4
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL, -- bcrypt, cost 12
|
||||||
|
created_at TEXT NOT NULL, -- ISO 8601
|
||||||
|
leaderboard_opt_in INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_state (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
stats_json TEXT NOT NULL,
|
||||||
|
achievements_json TEXT NOT NULL,
|
||||||
|
progress_json TEXT NOT NULL,
|
||||||
|
last_modified TEXT NOT NULL -- ISO 8601
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_challenges (
|
||||||
|
date TEXT PRIMARY KEY, -- "YYYY-MM-DD"
|
||||||
|
seed INTEGER NOT NULL,
|
||||||
|
goal_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS leaderboard (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
best_time_secs INTEGER,
|
||||||
|
best_score INTEGER,
|
||||||
|
recorded_at TEXT NOT NULL -- ISO 8601
|
||||||
|
);
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//! Authentication handlers: register, login, refresh, delete account.
|
||||||
|
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use bcrypt::{hash, verify};
|
||||||
|
use chrono::Utc;
|
||||||
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::AppError,
|
||||||
|
middleware::{validate_refresh_token, AuthenticatedUser, Claims},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Request / response shapes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Body for `POST /api/auth/register` and `POST /api/auth/login`.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AuthRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body for `POST /api/auth/refresh`.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RefreshRequest {
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Successful auth response — contains both tokens.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AuthResponse {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Successful refresh response — contains only the new access token.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RefreshResponse {
|
||||||
|
pub access_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal database row type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// User row fetched from the database during login.
|
||||||
|
/// Fields are `Option<String>` because sqlx treats all SQLite TEXT columns
|
||||||
|
/// as nullable regardless of the NOT NULL constraint in the schema.
|
||||||
|
struct UserRow {
|
||||||
|
id: Option<String>,
|
||||||
|
password_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// bcrypt cost used for password hashing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// bcrypt cost factor. Per ARCHITECTURE.md §19 this must be 12.
|
||||||
|
const BCRYPT_COST: u32 = 12;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Token generation helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Encode a JWT access token (24-hour expiry) for `user_id`.
|
||||||
|
pub fn make_access_token(user_id: &str, secret: &str) -> Result<String, AppError> {
|
||||||
|
let exp = (Utc::now() + chrono::Duration::hours(24)).timestamp() as usize;
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
exp,
|
||||||
|
kind: "access".to_string(),
|
||||||
|
};
|
||||||
|
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a JWT refresh token (30-day expiry) for `user_id`.
|
||||||
|
pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<String, AppError> {
|
||||||
|
let exp = (Utc::now() + chrono::Duration::days(30)).timestamp() as usize;
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
exp,
|
||||||
|
kind: "refresh".to_string(),
|
||||||
|
};
|
||||||
|
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `POST /api/auth/register` — create a new account and return tokens.
|
||||||
|
/// Minimum and maximum allowed username lengths.
|
||||||
|
const USERNAME_MIN: usize = 3;
|
||||||
|
const USERNAME_MAX: usize = 32;
|
||||||
|
/// Minimum password length.
|
||||||
|
const PASSWORD_MIN: usize = 8;
|
||||||
|
|
||||||
|
/// Returns `true` if every character in `s` is ASCII alphanumeric or `_`.
|
||||||
|
fn username_chars_ok(s: &str) -> bool {
|
||||||
|
s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
Json(body): Json<AuthRequest>,
|
||||||
|
) -> Result<Json<AuthResponse>, AppError> {
|
||||||
|
// Validate username: 3–32 characters, alphanumeric + underscores only.
|
||||||
|
let trimmed = body.username.trim();
|
||||||
|
if trimmed.len() < USERNAME_MIN || trimmed.len() > USERNAME_MAX {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"username must be {USERNAME_MIN}–{USERNAME_MAX} characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !username_chars_ok(trimmed) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"username may only contain letters, digits, and underscores".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Validate password: minimum 8 characters.
|
||||||
|
if body.password.len() < PASSWORD_MIN {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"password must be at least {PASSWORD_MIN} characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = trimmed.to_string();
|
||||||
|
|
||||||
|
// Check for duplicate username. SQLite returns TEXT as nullable so we
|
||||||
|
// flatten the Option<Option<String>> produced by fetch_optional.
|
||||||
|
let existing: Option<String> = sqlx::query_scalar!(
|
||||||
|
"SELECT id FROM users WHERE username = ?",
|
||||||
|
username
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(AppError::UsernameTaken);
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id = Uuid::new_v4().to_string();
|
||||||
|
let password_hash = hash(&body.password, BCRYPT_COST)?;
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let secret = std::env::var("JWT_SECRET")
|
||||||
|
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
||||||
|
|
||||||
|
Ok(Json(AuthResponse {
|
||||||
|
access_token: make_access_token(&user_id, &secret)?,
|
||||||
|
refresh_token: make_refresh_token(&user_id, &secret)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/auth/login` — verify credentials and return tokens.
|
||||||
|
pub async fn login(
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
Json(body): Json<AuthRequest>,
|
||||||
|
) -> Result<Json<AuthResponse>, AppError> {
|
||||||
|
let username = body.username.trim().to_string();
|
||||||
|
let row = sqlx::query_as!(
|
||||||
|
UserRow,
|
||||||
|
"SELECT id, password_hash FROM users WHERE username = ?",
|
||||||
|
username
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let row = row.ok_or(AppError::InvalidCredentials)?;
|
||||||
|
let row_id = row.id.ok_or_else(|| AppError::Internal("user id missing".into()))?;
|
||||||
|
let row_hash = row.password_hash.ok_or_else(|| AppError::Internal("password hash missing".into()))?;
|
||||||
|
|
||||||
|
let valid = verify(&body.password, &row_hash)?;
|
||||||
|
if !valid {
|
||||||
|
return Err(AppError::InvalidCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
let secret = std::env::var("JWT_SECRET")
|
||||||
|
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
||||||
|
|
||||||
|
Ok(Json(AuthResponse {
|
||||||
|
access_token: make_access_token(&row_id, &secret)?,
|
||||||
|
refresh_token: make_refresh_token(&row_id, &secret)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/auth/refresh` — exchange a refresh token for a new access token.
|
||||||
|
pub async fn refresh(
|
||||||
|
Json(body): Json<RefreshRequest>,
|
||||||
|
) -> Result<Json<RefreshResponse>, AppError> {
|
||||||
|
let secret = std::env::var("JWT_SECRET")
|
||||||
|
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
||||||
|
|
||||||
|
let claims = validate_refresh_token(&body.refresh_token, &secret)?;
|
||||||
|
|
||||||
|
Ok(Json(RefreshResponse {
|
||||||
|
access_token: make_access_token(&claims.sub, &secret)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/account` — permanently delete the authenticated user's account.
|
||||||
|
///
|
||||||
|
/// All related rows are removed via `ON DELETE CASCADE` in the schema.
|
||||||
|
pub async fn delete_account(
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
sqlx::query!("DELETE FROM users WHERE id = ?", user.user_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
//! Daily challenge endpoint.
|
||||||
|
//!
|
||||||
|
//! `GET /api/daily-challenge` — returns the challenge for today's date.
|
||||||
|
//!
|
||||||
|
//! The seed is deterministic (same for all players worldwide) and is
|
||||||
|
//! generated on first request for that date, then stored in the database
|
||||||
|
//! so subsequent calls return the same value.
|
||||||
|
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use solitaire_sync::ChallengeGoal;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Seed generation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Compute a deterministic seed from a date string such as `"2026-04-26"`.
|
||||||
|
///
|
||||||
|
/// Uses a simple polynomial rolling hash over the UTF-8 bytes of the string.
|
||||||
|
/// The computation is identical across all server instances and all clients
|
||||||
|
/// that implement the same algorithm.
|
||||||
|
pub fn hash_date_to_u64(date: &str) -> u64 {
|
||||||
|
date.bytes()
|
||||||
|
.fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a [`ChallengeGoal`] from a seed and date.
|
||||||
|
///
|
||||||
|
/// The goal type and parameters are derived deterministically from the seed
|
||||||
|
/// so all players face exactly the same challenge on the same day.
|
||||||
|
fn generate_goal(date: &str, seed: u64) -> ChallengeGoal {
|
||||||
|
// Pick a goal variant based on seed modulo number-of-variants.
|
||||||
|
// Six variants give a fortnight of variety before any repeat.
|
||||||
|
match seed % 6 {
|
||||||
|
0 => ChallengeGoal {
|
||||||
|
date: date.to_string(),
|
||||||
|
seed,
|
||||||
|
description: "Win in under 5 minutes".to_string(),
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: Some(300),
|
||||||
|
},
|
||||||
|
1 => ChallengeGoal {
|
||||||
|
date: date.to_string(),
|
||||||
|
seed,
|
||||||
|
description: "Reach a score of 4 000 or more".to_string(),
|
||||||
|
target_score: Some(4_000),
|
||||||
|
max_time_secs: None,
|
||||||
|
},
|
||||||
|
2 => ChallengeGoal {
|
||||||
|
date: date.to_string(),
|
||||||
|
seed,
|
||||||
|
description: "Win in under 3 minutes".to_string(),
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: Some(180),
|
||||||
|
},
|
||||||
|
3 => ChallengeGoal {
|
||||||
|
date: date.to_string(),
|
||||||
|
seed,
|
||||||
|
description: "Reach a score of 5 000 or more".to_string(),
|
||||||
|
target_score: Some(5_000),
|
||||||
|
max_time_secs: None,
|
||||||
|
},
|
||||||
|
4 => ChallengeGoal {
|
||||||
|
date: date.to_string(),
|
||||||
|
seed,
|
||||||
|
description: "Win in under 8 minutes".to_string(),
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: Some(480),
|
||||||
|
},
|
||||||
|
_ => ChallengeGoal {
|
||||||
|
date: date.to_string(),
|
||||||
|
seed,
|
||||||
|
description: "Win today's deal".to_string(),
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Database row helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct ChallengeRow {
|
||||||
|
goal_json: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `GET /api/daily-challenge` — no auth required.
|
||||||
|
///
|
||||||
|
/// Looks up today's challenge in the database. If none exists yet, generates
|
||||||
|
/// one deterministically and stores it before returning.
|
||||||
|
pub async fn daily_challenge(
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
) -> Result<Json<ChallengeGoal>, AppError> {
|
||||||
|
let today = Utc::now().format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
|
// Try to load an existing row.
|
||||||
|
let row = sqlx::query_as!(
|
||||||
|
ChallengeRow,
|
||||||
|
"SELECT goal_json FROM daily_challenges WHERE date = ?",
|
||||||
|
today
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(r) = row {
|
||||||
|
let json = r.goal_json.ok_or_else(|| AppError::Internal("missing goal_json".into()))?;
|
||||||
|
let goal: ChallengeGoal = serde_json::from_str(&json)?;
|
||||||
|
return Ok(Json(goal));
|
||||||
|
}
|
||||||
|
|
||||||
|
// No row yet — generate and store.
|
||||||
|
let seed = hash_date_to_u64(&today);
|
||||||
|
let goal = generate_goal(&today, seed);
|
||||||
|
let goal_json = serde_json::to_string(&goal)?;
|
||||||
|
let seed_i64 = seed as i64;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT OR IGNORE INTO daily_challenges (date, seed, goal_json) VALUES (?, ?, ?)",
|
||||||
|
today,
|
||||||
|
seed_i64,
|
||||||
|
goal_json
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(goal))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_date_is_deterministic() {
|
||||||
|
let date = "2026-04-26";
|
||||||
|
assert_eq!(hash_date_to_u64(date), hash_date_to_u64(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_date_differs_across_adjacent_days() {
|
||||||
|
assert_ne!(hash_date_to_u64("2026-04-26"), hash_date_to_u64("2026-04-27"));
|
||||||
|
assert_ne!(hash_date_to_u64("2026-04-26"), hash_date_to_u64("2026-04-25"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_date_differs_across_years() {
|
||||||
|
assert_ne!(hash_date_to_u64("2026-01-01"), hash_date_to_u64("2027-01-01"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_date_is_nonzero_for_real_dates() {
|
||||||
|
// Zero would be pathological — every date must produce a non-zero seed
|
||||||
|
// so the RNG initialises properly.
|
||||||
|
assert_ne!(hash_date_to_u64("2026-04-26"), 0);
|
||||||
|
assert_ne!(hash_date_to_u64("2026-01-01"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_goal_covers_all_six_variants() {
|
||||||
|
// The six variants are selected by seed % 6. Verify each branch
|
||||||
|
// produces a non-empty description and a non-empty date string.
|
||||||
|
for variant_idx in 0u64..6 {
|
||||||
|
let goal = generate_goal("2026-04-26", variant_idx);
|
||||||
|
assert_eq!(goal.date, "2026-04-26");
|
||||||
|
assert!(!goal.description.is_empty());
|
||||||
|
// seed field must match the passed-in seed.
|
||||||
|
assert_eq!(goal.seed, variant_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_goal_time_and_score_variants_are_set_correctly() {
|
||||||
|
// Variant 0: max_time_secs = 300, no score.
|
||||||
|
let g = generate_goal("2026-04-26", 0);
|
||||||
|
assert_eq!(g.max_time_secs, Some(300));
|
||||||
|
assert!(g.target_score.is_none());
|
||||||
|
|
||||||
|
// Variant 1: target_score = 4000, no time.
|
||||||
|
let g = generate_goal("2026-04-26", 1);
|
||||||
|
assert_eq!(g.target_score, Some(4_000));
|
||||||
|
assert!(g.max_time_secs.is_none());
|
||||||
|
|
||||||
|
// Variant 5: fallback — no time, no score (just win).
|
||||||
|
let g = generate_goal("2026-04-26", 5);
|
||||||
|
assert!(g.target_score.is_none());
|
||||||
|
assert!(g.max_time_secs.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! Application-level error type with automatic HTTP response conversion.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// All errors that can be returned by the server.
|
||||||
|
///
|
||||||
|
/// Each variant maps to a specific HTTP status code when converted to a
|
||||||
|
/// response via [`IntoResponse`].
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
/// The request is missing a valid `Authorization: Bearer` header, or the
|
||||||
|
/// JWT is expired / has an invalid signature.
|
||||||
|
#[error("unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
|
||||||
|
/// The supplied credentials (username / password) were incorrect.
|
||||||
|
#[error("invalid credentials")]
|
||||||
|
InvalidCredentials,
|
||||||
|
|
||||||
|
/// The requested username is already registered.
|
||||||
|
#[error("username already taken")]
|
||||||
|
UsernameTaken,
|
||||||
|
|
||||||
|
/// The client sent a malformed or invalid request body.
|
||||||
|
#[error("bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
|
||||||
|
/// A database error occurred.
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Database(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
/// Password hashing failed.
|
||||||
|
#[error("internal server error")]
|
||||||
|
BcryptError(#[from] bcrypt::BcryptError),
|
||||||
|
|
||||||
|
/// JSON serialization / deserialization failed.
|
||||||
|
#[error("serialization error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
/// A catch-all for unexpected internal failures.
|
||||||
|
#[error("internal server error")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match &self {
|
||||||
|
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||||
|
AppError::InvalidCredentials => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||||
|
AppError::UsernameTaken => (StatusCode::CONFLICT, self.to_string()),
|
||||||
|
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||||
|
AppError::Database(e) => {
|
||||||
|
tracing::error!("database error: {e}");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
|
||||||
|
}
|
||||||
|
AppError::BcryptError(e) => {
|
||||||
|
tracing::error!("bcrypt error: {e}");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
|
||||||
|
}
|
||||||
|
AppError::Json(e) => {
|
||||||
|
tracing::error!("json error: {e}");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
|
||||||
|
}
|
||||||
|
AppError::Internal(msg) => {
|
||||||
|
tracing::error!("internal error: {msg}");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = Json(json!({ "error": message }));
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
//! Leaderboard endpoints.
|
||||||
|
//!
|
||||||
|
//! `GET /api/leaderboard` — list all opted-in entries (requires auth).
|
||||||
|
//! `POST /api/leaderboard/opt-in` — opt in and set / update display name.
|
||||||
|
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use solitaire_sync::LeaderboardEntry;
|
||||||
|
|
||||||
|
use crate::{error::AppError, middleware::AuthenticatedUser};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Request shapes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Body for `POST /api/leaderboard/opt-in`.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct OptInRequest {
|
||||||
|
/// The display name the player wants shown on the leaderboard.
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Database row helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct LeaderboardRow {
|
||||||
|
display_name: Option<String>,
|
||||||
|
best_score: Option<i64>,
|
||||||
|
best_time_secs: Option<i64>,
|
||||||
|
recorded_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `GET /api/leaderboard` — return all opted-in leaderboard entries.
|
||||||
|
///
|
||||||
|
/// Returns entries sorted by `best_score` descending (nulls last).
|
||||||
|
pub async fn get_leaderboard(
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
_user: AuthenticatedUser,
|
||||||
|
) -> Result<Json<Vec<LeaderboardEntry>>, AppError> {
|
||||||
|
let rows = sqlx::query_as!(
|
||||||
|
LeaderboardRow,
|
||||||
|
r#"SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at
|
||||||
|
FROM leaderboard l
|
||||||
|
JOIN users u ON u.id = l.user_id
|
||||||
|
WHERE u.leaderboard_opt_in = 1
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,
|
||||||
|
l.best_score DESC,
|
||||||
|
CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,
|
||||||
|
l.best_time_secs ASC"#
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let entries: Result<Vec<LeaderboardEntry>, AppError> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| -> Result<LeaderboardEntry, AppError> {
|
||||||
|
let display_name = r
|
||||||
|
.display_name
|
||||||
|
.ok_or_else(|| AppError::Internal("missing display_name".into()))?;
|
||||||
|
let recorded_at_str = r
|
||||||
|
.recorded_at
|
||||||
|
.ok_or_else(|| AppError::Internal("missing recorded_at".into()))?;
|
||||||
|
let recorded_at = recorded_at_str
|
||||||
|
.parse::<chrono::DateTime<Utc>>()
|
||||||
|
.map_err(|e| AppError::Internal(format!("invalid recorded_at: {e}")))?;
|
||||||
|
Ok(LeaderboardEntry {
|
||||||
|
display_name,
|
||||||
|
best_score: r.best_score.map(|v| v as i32),
|
||||||
|
best_time_secs: r.best_time_secs.map(|v| v as u64),
|
||||||
|
recorded_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(entries?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/leaderboard/opt-in` — opt out, hiding the player's entry.
|
||||||
|
///
|
||||||
|
/// Sets `leaderboard_opt_in = 0` on the user row so the entry no longer
|
||||||
|
/// appears in `GET /api/leaderboard`. The leaderboard row itself is kept
|
||||||
|
/// so scores are preserved if the player opts back in later.
|
||||||
|
pub async fn opt_out(
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
|
||||||
|
user.user_id
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/leaderboard/opt-in` — opt in and upsert the player's entry.
|
||||||
|
///
|
||||||
|
/// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the
|
||||||
|
/// leaderboard entry with the supplied display name.
|
||||||
|
/// Maximum allowed length for a leaderboard display name.
|
||||||
|
const DISPLAY_NAME_MAX: usize = 32;
|
||||||
|
|
||||||
|
pub async fn opt_in(
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
Json(body): Json<OptInRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let display_name = body.display_name.trim();
|
||||||
|
if display_name.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("display_name must not be empty".into()));
|
||||||
|
}
|
||||||
|
if display_name.chars().count() > DISPLAY_NAME_MAX {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"display_name must be at most {DISPLAY_NAME_MAX} characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let display_name = display_name.to_string();
|
||||||
|
|
||||||
|
// Mark the user as opted in.
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE users SET leaderboard_opt_in = 1 WHERE id = ?",
|
||||||
|
user.user_id
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// Upsert leaderboard row (preserve best_score / best_time if already present).
|
||||||
|
sqlx::query!(
|
||||||
|
r#"INSERT INTO leaderboard (user_id, display_name, recorded_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
recorded_at = excluded.recorded_at"#,
|
||||||
|
user.user_id,
|
||||||
|
display_name,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//! Solitaire Quest sync server library.
|
||||||
|
//!
|
||||||
|
//! Exposes [`build_router`] so integration tests can construct the full Axum
|
||||||
|
//! application against an in-memory SQLite database without starting a real
|
||||||
|
//! TCP listener.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod challenge;
|
||||||
|
pub mod error;
|
||||||
|
pub mod leaderboard;
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod sync;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
middleware as axum_middleware,
|
||||||
|
routing::{delete, get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
|
||||||
|
|
||||||
|
/// Construct the full Axum [`Router`].
|
||||||
|
///
|
||||||
|
/// Separated from `main` so it can be instantiated in integration tests without
|
||||||
|
/// starting a real TCP listener.
|
||||||
|
pub fn build_router(pool: SqlitePool) -> Router {
|
||||||
|
build_router_inner(pool, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct the router without rate limiting.
|
||||||
|
///
|
||||||
|
/// Intended for integration tests only — do not use in production.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn build_test_router(pool: SqlitePool) -> Router {
|
||||||
|
build_router_inner(pool, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
|
||||||
|
// Protected routes require a valid JWT (injected by require_auth middleware).
|
||||||
|
let protected = Router::new()
|
||||||
|
.route("/api/sync/pull", get(sync::pull))
|
||||||
|
.route("/api/sync/push", post(sync::push))
|
||||||
|
.route("/api/leaderboard", get(leaderboard::get_leaderboard))
|
||||||
|
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
|
||||||
|
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
|
||||||
|
.route("/api/account", delete(auth::delete_account))
|
||||||
|
.layer(axum_middleware::from_fn(middleware::require_auth));
|
||||||
|
|
||||||
|
// Auth endpoints — rate-limited in production, unrestricted in tests.
|
||||||
|
let auth_routes = Router::new()
|
||||||
|
.route("/api/auth/register", post(auth::register))
|
||||||
|
.route("/api/auth/login", post(auth::login))
|
||||||
|
.route("/api/auth/refresh", post(auth::refresh));
|
||||||
|
|
||||||
|
let auth_routes = if rate_limit {
|
||||||
|
// Rate limiter: 10 requests per minute per IP.
|
||||||
|
// burst_size = 10, replenish every 6 seconds = 10/min steady-state.
|
||||||
|
let governor_conf = Arc::new(
|
||||||
|
GovernorConfigBuilder::default()
|
||||||
|
.per_second(6)
|
||||||
|
.burst_size(10)
|
||||||
|
.finish()
|
||||||
|
.expect("invalid governor config"),
|
||||||
|
);
|
||||||
|
auth_routes.layer(GovernorLayer {
|
||||||
|
config: governor_conf,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
auth_routes
|
||||||
|
};
|
||||||
|
|
||||||
|
// Public endpoints (no auth, no rate limit beyond defaults).
|
||||||
|
let public = Router::new()
|
||||||
|
.route("/api/daily-challenge", get(challenge::daily_challenge))
|
||||||
|
.route("/health", get(health));
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.merge(protected)
|
||||||
|
.merge(auth_routes)
|
||||||
|
.merge(public)
|
||||||
|
// Reject request bodies larger than 1 MB.
|
||||||
|
.layer(DefaultBodyLimit::max(1024 * 1024))
|
||||||
|
.with_state(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /health` — simple liveness probe, no auth required.
|
||||||
|
async fn health() -> axum::Json<serde_json::Value> {
|
||||||
|
axum::Json(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -1,2 +1,61 @@
|
|||||||
// Full server implementation added in Phase 8C.
|
//! Solitaire Quest sync server entry point.
|
||||||
fn main() {}
|
//!
|
||||||
|
//! Reads configuration from environment variables (via `dotenvy`), initialises
|
||||||
|
//! the SQLite database, runs migrations, then starts the Axum HTTP server.
|
||||||
|
//!
|
||||||
|
//! ## Required environment variables
|
||||||
|
//!
|
||||||
|
//! | Variable | Description |
|
||||||
|
//! |----------------|---------------------------------------------------|
|
||||||
|
//! | `DATABASE_URL` | SQLite connection string, e.g. `sqlite://sol.db` |
|
||||||
|
//! | `JWT_SECRET` | HS256 signing secret (min 32 chars recommended) |
|
||||||
|
//!
|
||||||
|
//! ## Optional
|
||||||
|
//!
|
||||||
|
//! | Variable | Default | Description |
|
||||||
|
//! |---------------|---------|-------------------------------|
|
||||||
|
//! | `SERVER_PORT` | `8080` | TCP port to listen on |
|
||||||
|
|
||||||
|
use solitaire_server::build_router;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// Load .env file if present (silently ignored when absent).
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
// Initialise structured logging.
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let port: u16 = std::env::var("SERVER_PORT")
|
||||||
|
.unwrap_or_else(|_| "8080".into())
|
||||||
|
.parse()
|
||||||
|
.expect("SERVER_PORT must be a valid port number");
|
||||||
|
|
||||||
|
// Connect to SQLite and run pending migrations.
|
||||||
|
let pool = SqlitePool::connect(&db_url)
|
||||||
|
.await
|
||||||
|
.expect("failed to connect to database");
|
||||||
|
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("database migration failed");
|
||||||
|
|
||||||
|
tracing::info!("database ready at {db_url}");
|
||||||
|
|
||||||
|
let app = build_router(pool);
|
||||||
|
|
||||||
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
|
tracing::info!("listening on {addr}");
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr)
|
||||||
|
.await
|
||||||
|
.expect("failed to bind TCP listener");
|
||||||
|
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.expect("server error");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//! Axum middleware for JWT authentication.
|
||||||
|
//!
|
||||||
|
//! Extracts and validates the `Authorization: Bearer <token>` header, then
|
||||||
|
//! injects the authenticated `user_id` into request extensions so handlers
|
||||||
|
//! can access it via `Extension<AuthenticatedUser>`.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{FromRequestParts, Request},
|
||||||
|
http::request::Parts,
|
||||||
|
middleware::Next,
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// The claims encoded in our JWT access tokens.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
/// Subject — the user's UUID string.
|
||||||
|
pub sub: String,
|
||||||
|
/// Expiry timestamp (Unix seconds).
|
||||||
|
pub exp: usize,
|
||||||
|
/// Token kind: `"access"` or `"refresh"`.
|
||||||
|
pub kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The authenticated user identity injected into request extensions after
|
||||||
|
/// successful JWT validation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthenticatedUser {
|
||||||
|
/// The authenticated user's UUID, as a string.
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Axum middleware function that validates the Bearer JWT and injects
|
||||||
|
/// [`AuthenticatedUser`] into request extensions.
|
||||||
|
///
|
||||||
|
/// Returns `401 Unauthorized` if the token is missing, expired, or invalid.
|
||||||
|
pub async fn require_auth(
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let secret = std::env::var("JWT_SECRET")
|
||||||
|
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
||||||
|
|
||||||
|
let token = extract_bearer_token(req.headers())
|
||||||
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
let claims = validate_access_token(&token, &secret)?;
|
||||||
|
|
||||||
|
req.extensions_mut().insert(AuthenticatedUser {
|
||||||
|
user_id: claims.sub,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the raw token string from `Authorization: Bearer <token>`.
|
||||||
|
fn extract_bearer_token(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
|
let value = headers.get("Authorization")?.to_str().ok()?;
|
||||||
|
let token = value.strip_prefix("Bearer ")?;
|
||||||
|
Some(token.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode and validate a JWT access token, returning its claims on success.
|
||||||
|
pub fn validate_access_token(token: &str, secret: &str) -> Result<Claims, AppError> {
|
||||||
|
let key = DecodingKey::from_secret(secret.as_bytes());
|
||||||
|
let mut validation = Validation::default();
|
||||||
|
validation.validate_exp = true;
|
||||||
|
|
||||||
|
let data = decode::<Claims>(token, &key, &validation)
|
||||||
|
.map_err(|_| AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
if data.claims.kind != "access" {
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(data.claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode and validate a JWT refresh token, returning its claims on success.
|
||||||
|
pub fn validate_refresh_token(token: &str, secret: &str) -> Result<Claims, AppError> {
|
||||||
|
let key = DecodingKey::from_secret(secret.as_bytes());
|
||||||
|
let mut validation = Validation::default();
|
||||||
|
validation.validate_exp = true;
|
||||||
|
|
||||||
|
let data = decode::<Claims>(token, &key, &validation)
|
||||||
|
.map_err(|_| AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
if data.claims.kind != "refresh" {
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(data.claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Axum extractor — allows handlers to receive AuthenticatedUser directly
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
//! Sync pull and push handlers.
|
||||||
|
//!
|
||||||
|
//! `GET /api/sync/pull` — return the server's stored payload for this user.
|
||||||
|
//! `POST /api/sync/push` — receive the client's payload, merge, store, return.
|
||||||
|
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use solitaire_sync::{
|
||||||
|
merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{error::AppError, middleware::AuthenticatedUser};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Database row helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct SyncRow {
|
||||||
|
stats_json: Option<String>,
|
||||||
|
achievements_json: Option<String>,
|
||||||
|
progress_json: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the stored `SyncPayload` for `user_id` from the database.
|
||||||
|
/// Returns `None` if this user has not pushed any data yet.
|
||||||
|
async fn load_sync_row(pool: &SqlitePool, user_id: &str) -> Result<Option<SyncRow>, AppError> {
|
||||||
|
let row = sqlx::query_as!(
|
||||||
|
SyncRow,
|
||||||
|
"SELECT stats_json, achievements_json, progress_json FROM sync_state WHERE user_id = ?",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a stored `SyncRow` into a `SyncPayload`.
|
||||||
|
fn row_to_payload(row: &SyncRow, user_id: &str) -> Result<SyncPayload, AppError> {
|
||||||
|
let stats_json = row.stats_json.as_deref()
|
||||||
|
.ok_or_else(|| AppError::Internal("missing stats_json".into()))?;
|
||||||
|
let achievements_json = row.achievements_json.as_deref()
|
||||||
|
.ok_or_else(|| AppError::Internal("missing achievements_json".into()))?;
|
||||||
|
let progress_json = row.progress_json.as_deref()
|
||||||
|
.ok_or_else(|| AppError::Internal("missing progress_json".into()))?;
|
||||||
|
|
||||||
|
let stats: StatsSnapshot = serde_json::from_str(stats_json)?;
|
||||||
|
let achievements: Vec<AchievementRecord> = serde_json::from_str(achievements_json)?;
|
||||||
|
let progress: PlayerProgress = serde_json::from_str(progress_json)?;
|
||||||
|
|
||||||
|
Ok(SyncPayload {
|
||||||
|
user_id: user_id
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| AppError::Internal("stored user_id is not a valid UUID".into()))?,
|
||||||
|
stats,
|
||||||
|
achievements,
|
||||||
|
progress,
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a `SyncPayload` for `user_id` using an upsert.
|
||||||
|
async fn store_payload(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: &str,
|
||||||
|
payload: &SyncPayload,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let stats_json = serde_json::to_string(&payload.stats)?;
|
||||||
|
let achievements_json = serde_json::to_string(&payload.achievements)?;
|
||||||
|
let progress_json = serde_json::to_string(&payload.progress)?;
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"INSERT INTO sync_state (user_id, stats_json, achievements_json, progress_json, last_modified)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
stats_json = excluded.stats_json,
|
||||||
|
achievements_json = excluded.achievements_json,
|
||||||
|
progress_json = excluded.progress_json,
|
||||||
|
last_modified = excluded.last_modified"#,
|
||||||
|
user_id,
|
||||||
|
stats_json,
|
||||||
|
achievements_json,
|
||||||
|
progress_json,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `GET /api/sync/pull` — return the server's stored payload for this user.
|
||||||
|
///
|
||||||
|
/// If the user has never pushed any data, returns a default payload.
|
||||||
|
pub async fn pull(
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
) -> Result<Json<SyncResponse>, AppError> {
|
||||||
|
let stored_payload = match load_sync_row(&pool, &user.user_id).await? {
|
||||||
|
Some(row) => row_to_payload(&row, &user.user_id)?,
|
||||||
|
None => {
|
||||||
|
// First pull — no server data yet; return an empty default payload.
|
||||||
|
let uid = user
|
||||||
|
.user_id
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| AppError::Internal("invalid user_id UUID".into()))?;
|
||||||
|
SyncPayload {
|
||||||
|
user_id: uid,
|
||||||
|
stats: StatsSnapshot::default(),
|
||||||
|
achievements: vec![],
|
||||||
|
progress: PlayerProgress::default(),
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(SyncResponse {
|
||||||
|
merged: stored_payload,
|
||||||
|
server_time: Utc::now(),
|
||||||
|
conflicts: vec![],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sync/push` — merge the client's payload with the server's
|
||||||
|
/// stored payload, persist the result, and return it.
|
||||||
|
///
|
||||||
|
/// If the user has opted in to the leaderboard, the leaderboard row is also
|
||||||
|
/// updated with the merged `best_single_score` and `fastest_win_seconds` so
|
||||||
|
/// scores stay in sync without a separate submission step.
|
||||||
|
pub async fn push(
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
Json(client_payload): Json<SyncPayload>,
|
||||||
|
) -> Result<Json<SyncResponse>, AppError> {
|
||||||
|
// Reject payloads that claim to belong to a different user.
|
||||||
|
if client_payload.user_id.to_string() != user.user_id {
|
||||||
|
return Err(AppError::BadRequest("user_id mismatch".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let server_payload = match load_sync_row(&pool, &user.user_id).await? {
|
||||||
|
Some(row) => row_to_payload(&row, &user.user_id)?,
|
||||||
|
None => {
|
||||||
|
// First push — nothing to merge against; store directly.
|
||||||
|
store_payload(&pool, &user.user_id, &client_payload).await?;
|
||||||
|
update_leaderboard_if_opted_in(&pool, &user.user_id, &client_payload).await?;
|
||||||
|
return Ok(Json(SyncResponse {
|
||||||
|
merged: client_payload,
|
||||||
|
server_time: Utc::now(),
|
||||||
|
conflicts: vec![],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (merged, conflicts) = merge(&client_payload, &server_payload);
|
||||||
|
|
||||||
|
store_payload(&pool, &user.user_id, &merged).await?;
|
||||||
|
update_leaderboard_if_opted_in(&pool, &user.user_id, &merged).await?;
|
||||||
|
|
||||||
|
Ok(Json(SyncResponse {
|
||||||
|
merged,
|
||||||
|
server_time: Utc::now(),
|
||||||
|
conflicts,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the user is opted in to the leaderboard, update their row with the
|
||||||
|
/// better of the stored and incoming `best_single_score` / `fastest_win_seconds`.
|
||||||
|
///
|
||||||
|
/// Uses SQLite `MIN`/`MAX` in the UPDATE so the database never regresses
|
||||||
|
/// a score even if the client sends stale data.
|
||||||
|
async fn update_leaderboard_if_opted_in(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: &str,
|
||||||
|
payload: &SyncPayload,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// Only update if the user has opted in (leaderboard row exists).
|
||||||
|
let opted_in: Option<i64> = sqlx::query_scalar!(
|
||||||
|
"SELECT leaderboard_opt_in FROM users WHERE id = ?",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if opted_in != Some(1) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let best_score = payload.stats.best_single_score as i64;
|
||||||
|
let fastest = if payload.stats.fastest_win_seconds == u64::MAX {
|
||||||
|
// Sentinel "never won" value — don't store.
|
||||||
|
None::<i64>
|
||||||
|
} else {
|
||||||
|
Some(payload.stats.fastest_win_seconds as i64)
|
||||||
|
};
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"UPDATE leaderboard
|
||||||
|
SET best_score = MAX(COALESCE(best_score, 0), ?),
|
||||||
|
best_time_secs = CASE
|
||||||
|
WHEN ? IS NULL THEN best_time_secs
|
||||||
|
WHEN best_time_secs IS NULL THEN ?
|
||||||
|
ELSE MIN(best_time_secs, ?)
|
||||||
|
END,
|
||||||
|
recorded_at = ?
|
||||||
|
WHERE user_id = ?"#,
|
||||||
|
best_score,
|
||||||
|
fastest, fastest, fastest,
|
||||||
|
now,
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,3 +8,4 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//! Shared `AchievementRecord` definition — used by both the game client and
|
||||||
|
//! the sync server.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One player's unlock state for a single achievement.
|
||||||
|
///
|
||||||
|
/// The achievement *definition* (name, description, condition fn) lives in
|
||||||
|
/// `solitaire_core`. This record only tracks runtime unlock state and is
|
||||||
|
/// what gets persisted and synced.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AchievementRecord {
|
||||||
|
/// Matches the `id` field of the corresponding `AchievementDef` in
|
||||||
|
/// `solitaire_core`.
|
||||||
|
pub id: String,
|
||||||
|
/// Whether the achievement has been unlocked.
|
||||||
|
pub unlocked: bool,
|
||||||
|
/// The UTC timestamp at which the achievement was first unlocked.
|
||||||
|
/// `None` when not yet unlocked.
|
||||||
|
pub unlock_date: Option<DateTime<Utc>>,
|
||||||
|
/// Whether the unlock reward (XP, cosmetic, etc.) has been granted.
|
||||||
|
pub reward_granted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AchievementRecord {
|
||||||
|
/// Construct an initial record for an achievement that is not yet unlocked.
|
||||||
|
pub fn locked(id: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
unlocked: false,
|
||||||
|
unlock_date: None,
|
||||||
|
reward_granted: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark this record unlocked at the given timestamp.
|
||||||
|
///
|
||||||
|
/// No-op if already unlocked — preserves the earliest `unlock_date` so
|
||||||
|
/// that merging two unlock records always keeps the older timestamp.
|
||||||
|
pub fn unlock(&mut self, at: DateTime<Utc>) {
|
||||||
|
if self.unlocked {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.unlocked = true;
|
||||||
|
self.unlock_date = Some(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locked_creates_an_unlocked_record() {
|
||||||
|
let r = AchievementRecord::locked("first_win");
|
||||||
|
assert_eq!(r.id, "first_win");
|
||||||
|
assert!(!r.unlocked);
|
||||||
|
assert!(r.unlock_date.is_none());
|
||||||
|
assert!(!r.reward_granted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unlock_sets_unlocked_and_stores_timestamp() {
|
||||||
|
let mut r = AchievementRecord::locked("first_win");
|
||||||
|
let ts = Utc::now();
|
||||||
|
r.unlock(ts);
|
||||||
|
assert!(r.unlocked);
|
||||||
|
assert_eq!(r.unlock_date, Some(ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unlock_is_idempotent_and_preserves_earliest_date() {
|
||||||
|
let mut r = AchievementRecord::locked("first_win");
|
||||||
|
let early = DateTime::UNIX_EPOCH;
|
||||||
|
let later = Utc::now();
|
||||||
|
r.unlock(early);
|
||||||
|
r.unlock(later); // should be a no-op
|
||||||
|
assert_eq!(r.unlock_date, Some(early), "earliest unlock date must be preserved");
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
-3
@@ -1,17 +1,125 @@
|
|||||||
|
//! Shared API types and merge logic for Solitaire Quest.
|
||||||
|
//!
|
||||||
|
//! This crate is the contract between the game client (`solitaire_data`) and
|
||||||
|
//! the sync server (`solitaire_server`). Changing any public type here is a
|
||||||
|
//! breaking change on both sides — version carefully.
|
||||||
|
//!
|
||||||
|
//! **No Bevy. No network. No file I/O.** Only `serde`, `uuid`, and `chrono`.
|
||||||
|
|
||||||
|
pub mod achievements;
|
||||||
|
pub mod merge;
|
||||||
|
pub mod progress;
|
||||||
|
pub mod stats;
|
||||||
|
|
||||||
|
pub use achievements::AchievementRecord;
|
||||||
|
pub use merge::merge;
|
||||||
|
pub use progress::{level_for_xp, PlayerProgress};
|
||||||
|
pub use stats::StatsSnapshot;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Payload sent from client to server (and returned after server merge).
|
// ---------------------------------------------------------------------------
|
||||||
/// Full fields are added in Phase 8 (Sync System).
|
// Sync wire types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Full sync payload sent from the client to the server and returned after
|
||||||
|
/// server-side merge. Contains all data needed to reconcile two instances.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct SyncPayload {
|
pub struct SyncPayload {
|
||||||
|
/// Identifies the owning player. Must match the authenticated user.
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
|
/// Cumulative game statistics.
|
||||||
|
pub stats: StatsSnapshot,
|
||||||
|
/// Per-achievement unlock records.
|
||||||
|
pub achievements: Vec<AchievementRecord>,
|
||||||
|
/// XP, level, cosmetic unlocks, and daily/weekly progress.
|
||||||
|
pub progress: PlayerProgress,
|
||||||
|
/// Wall-clock time of the last local modification.
|
||||||
pub last_modified: DateTime<Utc>,
|
pub last_modified: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response returned by the sync server after merging.
|
/// Response returned by the sync server after a pull or push operation.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct SyncResponse {
|
pub struct SyncResponse {
|
||||||
|
/// The merged payload that the client should save locally.
|
||||||
|
pub merged: SyncPayload,
|
||||||
|
/// The server's current wall-clock time (useful for clock-skew detection).
|
||||||
pub server_time: DateTime<Utc>,
|
pub server_time: DateTime<Utc>,
|
||||||
|
/// Fields where local and remote values differed and could not be merged
|
||||||
|
/// deterministically. Returned for display purposes — data is never
|
||||||
|
/// silently discarded.
|
||||||
|
pub conflicts: Vec<ConflictReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Describes a single field where local and remote values diverged in a way
|
||||||
|
/// that the merge function could not resolve automatically.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ConflictReport {
|
||||||
|
/// Dot-separated field path, e.g. `"win_streak_current"`.
|
||||||
|
pub field: String,
|
||||||
|
/// Human-readable representation of the local value.
|
||||||
|
pub local_value: String,
|
||||||
|
/// Human-readable representation of the remote value.
|
||||||
|
pub remote_value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Daily challenge / leaderboard types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Describes today's daily challenge, returned by `GET /api/daily-challenge`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ChallengeGoal {
|
||||||
|
/// Date this challenge applies to, formatted as `"YYYY-MM-DD"`.
|
||||||
|
pub date: String,
|
||||||
|
/// Deterministic RNG seed for this date's deal — identical for all players.
|
||||||
|
pub seed: u64,
|
||||||
|
/// Human-readable description of the goal, e.g. "Win in under 5 minutes".
|
||||||
|
pub description: String,
|
||||||
|
/// Optional target score required to complete the challenge.
|
||||||
|
pub target_score: Option<i32>,
|
||||||
|
/// Optional maximum allowed time in seconds to complete the challenge.
|
||||||
|
pub max_time_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single row from the server leaderboard, returned by `GET /api/leaderboard`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct LeaderboardEntry {
|
||||||
|
/// Display name chosen by the player at opt-in time.
|
||||||
|
pub display_name: String,
|
||||||
|
/// The player's best single-game score.
|
||||||
|
pub best_score: Option<i32>,
|
||||||
|
/// The player's fastest win time in seconds.
|
||||||
|
pub best_time_secs: Option<u64>,
|
||||||
|
/// When this entry was last recorded.
|
||||||
|
pub recorded_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Errors returned by the sync server in `application/json` error bodies.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
|
||||||
|
pub enum ApiError {
|
||||||
|
/// The request could not be authenticated (missing or invalid JWT).
|
||||||
|
#[error("unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
/// The supplied credentials were incorrect.
|
||||||
|
#[error("invalid credentials")]
|
||||||
|
InvalidCredentials,
|
||||||
|
/// A username that was requested for registration is already taken.
|
||||||
|
#[error("username already taken")]
|
||||||
|
UsernameTaken,
|
||||||
|
/// The request payload was too large (> 1 MB).
|
||||||
|
#[error("payload too large")]
|
||||||
|
PayloadTooLarge,
|
||||||
|
/// The request body could not be parsed.
|
||||||
|
#[error("bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
/// An unexpected server-side error occurred.
|
||||||
|
#[error("internal server error")]
|
||||||
|
Internal,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,645 @@
|
|||||||
|
//! Pure merge logic for sync payloads.
|
||||||
|
//!
|
||||||
|
//! All functions are free of I/O and side effects — safe to call from any
|
||||||
|
//! context including unit tests and the Bevy main thread.
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload};
|
||||||
|
use crate::progress::level_for_xp;
|
||||||
|
|
||||||
|
/// Merge two [`SyncPayload`]s into a single authoritative result.
|
||||||
|
///
|
||||||
|
/// The merge strategy is additive and conflict-free for most fields:
|
||||||
|
/// - Counters: take the maximum (games_played, games_won, etc.)
|
||||||
|
/// - Best records: take the minimum for times, maximum for scores/xp
|
||||||
|
/// - Achievements: union by id, preserving the earliest `unlock_date`
|
||||||
|
/// - Cosmetic unlocks: union of both vectors
|
||||||
|
/// - Level: recomputed from merged `total_xp`
|
||||||
|
///
|
||||||
|
/// Fields that cannot be merged deterministically (e.g. diverged streak
|
||||||
|
/// counts) are recorded in [`ConflictReport`] entries returned alongside
|
||||||
|
/// the merged payload. Data is never silently discarded.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```
|
||||||
|
/// use solitaire_sync::{SyncPayload, StatsSnapshot, PlayerProgress, merge};
|
||||||
|
/// use uuid::Uuid;
|
||||||
|
///
|
||||||
|
/// let a = SyncPayload {
|
||||||
|
/// user_id: Uuid::nil(),
|
||||||
|
/// stats: StatsSnapshot { games_played: 5, ..Default::default() },
|
||||||
|
/// achievements: vec![],
|
||||||
|
/// progress: PlayerProgress::default(),
|
||||||
|
/// last_modified: chrono::Utc::now(),
|
||||||
|
/// };
|
||||||
|
/// let b = SyncPayload {
|
||||||
|
/// user_id: Uuid::nil(),
|
||||||
|
/// stats: StatsSnapshot { games_played: 3, ..Default::default() },
|
||||||
|
/// achievements: vec![],
|
||||||
|
/// progress: PlayerProgress::default(),
|
||||||
|
/// last_modified: chrono::Utc::now(),
|
||||||
|
/// };
|
||||||
|
/// let (merged, conflicts) = merge(&a, &b);
|
||||||
|
/// assert_eq!(merged.stats.games_played, 5);
|
||||||
|
/// assert!(conflicts.is_empty());
|
||||||
|
/// ```
|
||||||
|
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> (SyncPayload, Vec<ConflictReport>) {
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
let stats = merge_stats(&local.stats, &remote.stats, &mut conflicts);
|
||||||
|
let achievements = merge_achievements(&local.achievements, &remote.achievements);
|
||||||
|
let progress = merge_progress(&local.progress, &remote.progress, &mut conflicts);
|
||||||
|
|
||||||
|
let merged = SyncPayload {
|
||||||
|
user_id: local.user_id,
|
||||||
|
stats,
|
||||||
|
achievements,
|
||||||
|
progress,
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(merged, conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stats
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn merge_stats(
|
||||||
|
local: &StatsSnapshot,
|
||||||
|
remote: &StatsSnapshot,
|
||||||
|
conflicts: &mut Vec<ConflictReport>,
|
||||||
|
) -> StatsSnapshot {
|
||||||
|
// win_streak_current cannot be merged deterministically — record conflict
|
||||||
|
// but take the higher value as a best-effort resolution.
|
||||||
|
if local.win_streak_current != remote.win_streak_current {
|
||||||
|
conflicts.push(ConflictReport {
|
||||||
|
field: "win_streak_current".to_string(),
|
||||||
|
local_value: local.win_streak_current.to_string(),
|
||||||
|
remote_value: remote.win_streak_current.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let merged_games_won = local.games_won.max(remote.games_won);
|
||||||
|
let merged_games_played = local.games_played.max(remote.games_played);
|
||||||
|
|
||||||
|
// Recompute average time from the merged totals. If no wins yet, keep 0.
|
||||||
|
let avg_time_seconds = if merged_games_won == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
// Use whichever side has more wins to approximate total time, then blend.
|
||||||
|
// We don't have total_time stored, so we reconstruct it from avg * count.
|
||||||
|
let local_total = local.avg_time_seconds as u128 * local.games_won as u128;
|
||||||
|
let remote_total = remote.avg_time_seconds as u128 * remote.games_won as u128;
|
||||||
|
// Take max total time (conservative — avoids underestimating total play time).
|
||||||
|
let best_total = local_total.max(remote_total);
|
||||||
|
(best_total / merged_games_won as u128) as u64
|
||||||
|
};
|
||||||
|
|
||||||
|
StatsSnapshot {
|
||||||
|
games_played: merged_games_played,
|
||||||
|
games_won: merged_games_won,
|
||||||
|
games_lost: local.games_lost.max(remote.games_lost),
|
||||||
|
win_streak_current: local.win_streak_current.max(remote.win_streak_current),
|
||||||
|
win_streak_best: local.win_streak_best.max(remote.win_streak_best),
|
||||||
|
avg_time_seconds,
|
||||||
|
fastest_win_seconds: local.fastest_win_seconds.min(remote.fastest_win_seconds),
|
||||||
|
lifetime_score: local.lifetime_score.max(remote.lifetime_score),
|
||||||
|
best_single_score: local.best_single_score.max(remote.best_single_score),
|
||||||
|
draw_one_wins: local.draw_one_wins.max(remote.draw_one_wins),
|
||||||
|
draw_three_wins: local.draw_three_wins.max(remote.draw_three_wins),
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Achievements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Union of local and remote achievement records.
|
||||||
|
///
|
||||||
|
/// - Achievements never disappear from the merged set.
|
||||||
|
/// - If both sides have an achievement unlocked, the *earliest* `unlock_date`
|
||||||
|
/// is preserved.
|
||||||
|
/// - If only one side has an achievement unlocked, it is carried forward.
|
||||||
|
fn merge_achievements(
|
||||||
|
local: &[AchievementRecord],
|
||||||
|
remote: &[AchievementRecord],
|
||||||
|
) -> Vec<AchievementRecord> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let mut map: HashMap<&str, AchievementRecord> = HashMap::new();
|
||||||
|
|
||||||
|
// Insert all local records first.
|
||||||
|
for rec in local {
|
||||||
|
map.insert(rec.id.as_str(), rec.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge in remote records.
|
||||||
|
for remote_rec in remote {
|
||||||
|
match map.get_mut(remote_rec.id.as_str()) {
|
||||||
|
Some(existing) => {
|
||||||
|
// Merge: once unlocked, never lock again.
|
||||||
|
if remote_rec.unlocked && !existing.unlocked {
|
||||||
|
// Remote is unlocked but local isn't — adopt remote unlock.
|
||||||
|
existing.unlocked = true;
|
||||||
|
existing.unlock_date = remote_rec.unlock_date;
|
||||||
|
existing.reward_granted = remote_rec.reward_granted;
|
||||||
|
} else if remote_rec.unlocked && existing.unlocked {
|
||||||
|
// Both unlocked — keep the earlier date.
|
||||||
|
match (existing.unlock_date, remote_rec.unlock_date) {
|
||||||
|
(Some(local_dt), Some(remote_dt)) if remote_dt < local_dt => {
|
||||||
|
existing.unlock_date = Some(remote_dt);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
// reward_granted: true if either side granted it.
|
||||||
|
existing.reward_granted = existing.reward_granted || remote_rec.reward_granted;
|
||||||
|
}
|
||||||
|
// If only local is unlocked — nothing changes.
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Remote has an achievement that local doesn't know about.
|
||||||
|
map.insert(remote_rec.id.as_str(), remote_rec.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result: Vec<AchievementRecord> = map.into_values().collect();
|
||||||
|
result.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Progress
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn merge_progress(
|
||||||
|
local: &PlayerProgress,
|
||||||
|
remote: &PlayerProgress,
|
||||||
|
conflicts: &mut Vec<ConflictReport>,
|
||||||
|
) -> PlayerProgress {
|
||||||
|
// daily_challenge_streak cannot be merged deterministically.
|
||||||
|
if local.daily_challenge_streak != remote.daily_challenge_streak {
|
||||||
|
conflicts.push(ConflictReport {
|
||||||
|
field: "daily_challenge_streak".to_string(),
|
||||||
|
local_value: local.daily_challenge_streak.to_string(),
|
||||||
|
remote_value: remote.daily_challenge_streak.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_xp = local.total_xp.max(remote.total_xp);
|
||||||
|
|
||||||
|
// Union cosmetic unlocks.
|
||||||
|
let unlocked_card_backs = union_usize_vecs(&local.unlocked_card_backs, &remote.unlocked_card_backs);
|
||||||
|
let unlocked_backgrounds =
|
||||||
|
union_usize_vecs(&local.unlocked_backgrounds, &remote.unlocked_backgrounds);
|
||||||
|
|
||||||
|
// Keep the most recently completed daily challenge date (latest).
|
||||||
|
let daily_challenge_last_completed =
|
||||||
|
match (local.daily_challenge_last_completed, remote.daily_challenge_last_completed) {
|
||||||
|
(Some(l), Some(r)) => Some(l.max(r)),
|
||||||
|
(Some(l), None) => Some(l),
|
||||||
|
(None, Some(r)) => Some(r),
|
||||||
|
(None, None) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Take the higher streak as a best-effort resolution.
|
||||||
|
let daily_challenge_streak =
|
||||||
|
local.daily_challenge_streak.max(remote.daily_challenge_streak);
|
||||||
|
|
||||||
|
// weekly_goal_progress: use whichever side has the more recent ISO week key.
|
||||||
|
// When both sides share the same week, merge per-goal counts with max so
|
||||||
|
// progress made on either device is never lost.
|
||||||
|
let (weekly_goal_week_iso, weekly_goal_progress) =
|
||||||
|
match (&local.weekly_goal_week_iso, &remote.weekly_goal_week_iso) {
|
||||||
|
(Some(l), Some(r)) if l == r => {
|
||||||
|
let mut merged = local.weekly_goal_progress.clone();
|
||||||
|
for (id, &rv) in &remote.weekly_goal_progress {
|
||||||
|
let lv = merged.entry(id.clone()).or_insert(0);
|
||||||
|
*lv = (*lv).max(rv);
|
||||||
|
}
|
||||||
|
(local.weekly_goal_week_iso.clone(), merged)
|
||||||
|
}
|
||||||
|
(Some(l), Some(r)) if r > l => {
|
||||||
|
(remote.weekly_goal_week_iso.clone(), remote.weekly_goal_progress.clone())
|
||||||
|
}
|
||||||
|
(Some(_), Some(_)) => {
|
||||||
|
(local.weekly_goal_week_iso.clone(), local.weekly_goal_progress.clone())
|
||||||
|
}
|
||||||
|
(Some(_), None) => {
|
||||||
|
(local.weekly_goal_week_iso.clone(), local.weekly_goal_progress.clone())
|
||||||
|
}
|
||||||
|
(None, Some(_)) => {
|
||||||
|
(remote.weekly_goal_week_iso.clone(), remote.weekly_goal_progress.clone())
|
||||||
|
}
|
||||||
|
(None, None) => (None, Default::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Challenge index: take the higher (further ahead in challenge progression).
|
||||||
|
let challenge_index = local.challenge_index.max(remote.challenge_index);
|
||||||
|
|
||||||
|
PlayerProgress {
|
||||||
|
total_xp,
|
||||||
|
level: level_for_xp(total_xp),
|
||||||
|
daily_challenge_last_completed,
|
||||||
|
daily_challenge_streak,
|
||||||
|
weekly_goal_progress,
|
||||||
|
weekly_goal_week_iso,
|
||||||
|
unlocked_card_backs,
|
||||||
|
unlocked_backgrounds,
|
||||||
|
challenge_index,
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the sorted union of two `Vec<usize>` slices with duplicates removed.
|
||||||
|
fn union_usize_vecs(a: &[usize], b: &[usize]) -> Vec<usize> {
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
let set: BTreeSet<usize> = a.iter().chain(b.iter()).copied().collect();
|
||||||
|
set.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload};
|
||||||
|
|
||||||
|
fn make_payload(stats: StatsSnapshot, achievements: Vec<AchievementRecord>, progress: PlayerProgress) -> SyncPayload {
|
||||||
|
SyncPayload {
|
||||||
|
user_id: Uuid::nil(),
|
||||||
|
stats,
|
||||||
|
achievements,
|
||||||
|
progress,
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_payload() -> SyncPayload {
|
||||||
|
make_payload(StatsSnapshot::default(), vec![], PlayerProgress::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Idempotency
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_is_idempotent_for_equal_payloads() {
|
||||||
|
let mut a = default_payload();
|
||||||
|
a.stats.games_played = 10;
|
||||||
|
a.stats.games_won = 5;
|
||||||
|
a.stats.fastest_win_seconds = 120;
|
||||||
|
a.stats.lifetime_score = 5000;
|
||||||
|
a.progress.total_xp = 2000;
|
||||||
|
a.progress.unlocked_card_backs = vec![0, 1];
|
||||||
|
|
||||||
|
let (merged, conflicts) = merge(&a, &a);
|
||||||
|
|
||||||
|
assert_eq!(merged.stats.games_played, 10);
|
||||||
|
assert_eq!(merged.stats.games_won, 5);
|
||||||
|
assert_eq!(merged.stats.fastest_win_seconds, 120);
|
||||||
|
assert_eq!(merged.stats.lifetime_score, 5000);
|
||||||
|
assert_eq!(merged.progress.total_xp, 2000);
|
||||||
|
assert_eq!(merged.progress.unlocked_card_backs, vec![0, 1]);
|
||||||
|
// Identical payloads produce no conflicts.
|
||||||
|
assert!(conflicts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Stats merge
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stats_games_played_takes_max() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.stats.games_played = 20;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.stats.games_played = 15;
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.stats.games_played, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stats_games_won_takes_max() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.stats.games_won = 7;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.stats.games_won = 12;
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.stats.games_won, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stats_fastest_win_takes_min() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.stats.fastest_win_seconds = 300;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.stats.fastest_win_seconds = 120;
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.stats.fastest_win_seconds, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stats_best_score_takes_max() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.stats.best_single_score = 4000;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.stats.best_single_score = 6000;
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.stats.best_single_score, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stats_games_lost_takes_max() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.stats.games_lost = 12;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.stats.games_lost = 8;
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.stats.games_lost, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stats_win_streak_best_takes_max() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.stats.win_streak_best = 5;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.stats.win_streak_best = 10;
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.stats.win_streak_best, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stats_lifetime_score_takes_max() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.stats.lifetime_score = 30_000;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.stats.lifetime_score = 45_000;
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.stats.lifetime_score, 45_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stats_draw_mode_wins_take_max() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.stats.draw_one_wins = 20;
|
||||||
|
local.stats.draw_three_wins = 5;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.stats.draw_one_wins = 15;
|
||||||
|
remote.stats.draw_three_wins = 8;
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.stats.draw_one_wins, 20);
|
||||||
|
assert_eq!(merged.stats.draw_three_wins, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stats_avg_time_recomputed_from_merged_totals() {
|
||||||
|
// local: 4 wins averaging 100s each (total = 400s)
|
||||||
|
// remote: 6 wins averaging 200s each (total = 1200s)
|
||||||
|
// merged_games_won = max(4, 6) = 6
|
||||||
|
// best_total = max(400, 1200) = 1200
|
||||||
|
// avg = 1200 / 6 = 200
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.stats.games_won = 4;
|
||||||
|
local.stats.avg_time_seconds = 100;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.stats.games_won = 6;
|
||||||
|
remote.stats.avg_time_seconds = 200;
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.stats.games_won, 6);
|
||||||
|
assert_eq!(merged.stats.avg_time_seconds, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stats_avg_time_zero_when_no_wins() {
|
||||||
|
let local = default_payload();
|
||||||
|
let remote = default_payload();
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.stats.avg_time_seconds, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn differing_win_streak_current_generates_conflict() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.stats.win_streak_current = 3;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.stats.win_streak_current = 5;
|
||||||
|
|
||||||
|
let (merged, conflicts) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.stats.win_streak_current, 5);
|
||||||
|
assert!(
|
||||||
|
conflicts.iter().any(|c| c.field == "win_streak_current"),
|
||||||
|
"expected conflict report for win_streak_current"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_win_streak_current_produces_no_conflict() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.stats.win_streak_current = 4;
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.stats.win_streak_current = 4;
|
||||||
|
|
||||||
|
let (_, conflicts) = merge(&local, &remote);
|
||||||
|
assert!(
|
||||||
|
!conflicts.iter().any(|c| c.field == "win_streak_current"),
|
||||||
|
"no conflict expected for matching streaks"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Achievement merge
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn achievements_are_never_removed() {
|
||||||
|
let unlocked = {
|
||||||
|
let mut r = AchievementRecord::locked("first_win");
|
||||||
|
r.unlock(Utc::now());
|
||||||
|
r
|
||||||
|
};
|
||||||
|
let local = make_payload(StatsSnapshot::default(), vec![unlocked.clone()], PlayerProgress::default());
|
||||||
|
let remote = make_payload(StatsSnapshot::default(), vec![], PlayerProgress::default());
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert!(
|
||||||
|
merged.achievements.iter().any(|a| a.id == "first_win" && a.unlocked),
|
||||||
|
"unlocked achievement must survive merge even if absent from remote"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn achievements_remote_unlock_propagates_to_local() {
|
||||||
|
let locked = AchievementRecord::locked("century");
|
||||||
|
let mut unlocked = AchievementRecord::locked("century");
|
||||||
|
unlocked.unlock(Utc::now());
|
||||||
|
|
||||||
|
let local = make_payload(StatsSnapshot::default(), vec![locked], PlayerProgress::default());
|
||||||
|
let remote = make_payload(StatsSnapshot::default(), vec![unlocked.clone()], PlayerProgress::default());
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
let ach = merged.achievements.iter().find(|a| a.id == "century").expect("must exist");
|
||||||
|
assert!(ach.unlocked);
|
||||||
|
assert_eq!(ach.unlock_date, unlocked.unlock_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn achievements_earliest_unlock_date_wins_on_conflict() {
|
||||||
|
let earlier = Utc::now() - Duration::hours(2);
|
||||||
|
let later = Utc::now();
|
||||||
|
|
||||||
|
let mut local_rec = AchievementRecord::locked("speed_demon");
|
||||||
|
local_rec.unlock(later);
|
||||||
|
let mut remote_rec = AchievementRecord::locked("speed_demon");
|
||||||
|
remote_rec.unlock(earlier);
|
||||||
|
|
||||||
|
let local = make_payload(StatsSnapshot::default(), vec![local_rec], PlayerProgress::default());
|
||||||
|
let remote = make_payload(StatsSnapshot::default(), vec![remote_rec], PlayerProgress::default());
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
let ach = merged.achievements.iter().find(|a| a.id == "speed_demon").expect("must exist");
|
||||||
|
assert_eq!(ach.unlock_date, Some(earlier), "earlier date must win");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn achievements_union_includes_both_sides() {
|
||||||
|
let mut a1 = AchievementRecord::locked("first_win");
|
||||||
|
a1.unlock(Utc::now());
|
||||||
|
let mut a2 = AchievementRecord::locked("century");
|
||||||
|
a2.unlock(Utc::now());
|
||||||
|
|
||||||
|
let local = make_payload(StatsSnapshot::default(), vec![a1], PlayerProgress::default());
|
||||||
|
let remote = make_payload(StatsSnapshot::default(), vec![a2], PlayerProgress::default());
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.achievements.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Progress merge
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_total_xp_takes_max() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.progress.total_xp = 1500;
|
||||||
|
local.progress.level = crate::progress::level_for_xp(1500);
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.progress.total_xp = 2500;
|
||||||
|
remote.progress.level = crate::progress::level_for_xp(2500);
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.progress.total_xp, 2500);
|
||||||
|
assert_eq!(merged.progress.level, crate::progress::level_for_xp(2500));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_unlocked_card_backs_are_union() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.progress.unlocked_card_backs = vec![0, 1];
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.progress.unlocked_card_backs = vec![0, 2];
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert!(merged.progress.unlocked_card_backs.contains(&0));
|
||||||
|
assert!(merged.progress.unlocked_card_backs.contains(&1));
|
||||||
|
assert!(merged.progress.unlocked_card_backs.contains(&2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_unlocked_backgrounds_are_union() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.progress.unlocked_backgrounds = vec![0, 3];
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.progress.unlocked_backgrounds = vec![0, 4];
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert!(merged.progress.unlocked_backgrounds.contains(&3));
|
||||||
|
assert!(merged.progress.unlocked_backgrounds.contains(&4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn differing_daily_challenge_streak_generates_conflict() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.progress.daily_challenge_streak = 5;
|
||||||
|
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"),
|
||||||
|
"expected conflict for daily_challenge_streak"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn level_is_recomputed_from_merged_xp() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.progress.total_xp = 4500; // level 9
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.progress.total_xp = 5500; // level 10
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.progress.total_xp, 5500);
|
||||||
|
assert_eq!(merged.progress.level, crate::progress::level_for_xp(5500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Weekly goal merge
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weekly_goals_same_week_takes_per_goal_max() {
|
||||||
|
let week = "2026-W17".to_string();
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.progress.weekly_goal_week_iso = Some(week.clone());
|
||||||
|
local.progress.weekly_goal_progress.insert("weekly_5_wins".to_string(), 3);
|
||||||
|
local.progress.weekly_goal_progress.insert("weekly_3_fast".to_string(), 1);
|
||||||
|
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.progress.weekly_goal_week_iso = Some(week.clone());
|
||||||
|
remote.progress.weekly_goal_progress.insert("weekly_5_wins".to_string(), 2);
|
||||||
|
remote.progress.weekly_goal_progress.insert("weekly_3_no_undo".to_string(), 2);
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.progress.weekly_goal_week_iso, Some(week));
|
||||||
|
// local had 3, remote had 2 — take max
|
||||||
|
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_5_wins"), Some(&3));
|
||||||
|
// only in local
|
||||||
|
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_3_fast"), Some(&1));
|
||||||
|
// only in remote
|
||||||
|
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_3_no_undo"), Some(&2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weekly_goals_newer_remote_week_wins() {
|
||||||
|
let mut local = default_payload();
|
||||||
|
local.progress.weekly_goal_week_iso = Some("2026-W16".to_string());
|
||||||
|
local.progress.weekly_goal_progress.insert("weekly_5_wins".to_string(), 5);
|
||||||
|
|
||||||
|
let mut remote = default_payload();
|
||||||
|
remote.progress.weekly_goal_week_iso = Some("2026-W17".to_string());
|
||||||
|
remote.progress.weekly_goal_progress.insert("weekly_5_wins".to_string(), 1);
|
||||||
|
|
||||||
|
let (merged, _) = merge(&local, &remote);
|
||||||
|
assert_eq!(merged.progress.weekly_goal_week_iso, Some("2026-W17".to_string()));
|
||||||
|
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! Shared `PlayerProgress` definition — used by both the game client and the
|
||||||
|
//! sync server.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Duration, NaiveDate, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// XP-to-level calculation per ARCHITECTURE.md §13.
|
||||||
|
///
|
||||||
|
/// - Levels 1–10: `level = floor(total_xp / 500)`
|
||||||
|
/// - Levels 11+: `level = 10 + floor((total_xp - 5_000) / 1_000)`
|
||||||
|
pub fn level_for_xp(xp: u64) -> u32 {
|
||||||
|
if xp < 5_000 {
|
||||||
|
(xp / 500) as u32
|
||||||
|
} else {
|
||||||
|
10 + ((xp - 5_000) / 1_000) as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted player progression state.
|
||||||
|
///
|
||||||
|
/// Mutation helpers such as `add_xp`, `record_daily_completion`, etc. are
|
||||||
|
/// defined as inherent methods directly on this type.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PlayerProgress {
|
||||||
|
/// Total XP accumulated across all games.
|
||||||
|
pub total_xp: u64,
|
||||||
|
/// Current player level, recomputed from `total_xp`.
|
||||||
|
pub level: u32,
|
||||||
|
/// Date of the last completed daily challenge, if any.
|
||||||
|
pub daily_challenge_last_completed: Option<NaiveDate>,
|
||||||
|
/// Current daily-challenge streak length.
|
||||||
|
pub daily_challenge_streak: u32,
|
||||||
|
/// Per-goal progress counters for the current ISO week.
|
||||||
|
pub weekly_goal_progress: HashMap<String, u32>,
|
||||||
|
/// ISO week key (e.g. `"2026-W17"`) the `weekly_goal_progress` counters
|
||||||
|
/// belong to. Cleared when a new week begins.
|
||||||
|
#[serde(default)]
|
||||||
|
pub weekly_goal_week_iso: Option<String>,
|
||||||
|
/// Indices of card-back designs the player has unlocked (index 0 is always unlocked).
|
||||||
|
pub unlocked_card_backs: Vec<usize>,
|
||||||
|
/// Indices of background designs the player has unlocked (index 0 is always unlocked).
|
||||||
|
pub unlocked_backgrounds: Vec<usize>,
|
||||||
|
/// Index of the next Challenge-mode seed to serve to this player.
|
||||||
|
#[serde(default)]
|
||||||
|
pub challenge_index: u32,
|
||||||
|
/// Wall-clock time of the last modification (used for conflict detection).
|
||||||
|
pub last_modified: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlayerProgress {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
total_xp: 0,
|
||||||
|
level: 0,
|
||||||
|
daily_challenge_last_completed: None,
|
||||||
|
daily_challenge_streak: 0,
|
||||||
|
weekly_goal_progress: HashMap::new(),
|
||||||
|
weekly_goal_week_iso: None,
|
||||||
|
unlocked_card_backs: vec![0],
|
||||||
|
unlocked_backgrounds: vec![0],
|
||||||
|
challenge_index: 0,
|
||||||
|
last_modified: DateTime::UNIX_EPOCH,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlayerProgress {
|
||||||
|
/// Add XP and recompute level. Returns the previous level so callers can
|
||||||
|
/// detect level-up events.
|
||||||
|
pub fn add_xp(&mut self, amount: u64) -> u32 {
|
||||||
|
let prev_level = self.level;
|
||||||
|
self.total_xp = self.total_xp.saturating_add(amount);
|
||||||
|
self.level = level_for_xp(self.total_xp);
|
||||||
|
self.last_modified = Utc::now();
|
||||||
|
prev_level
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` if a level-up just occurred (current level > `prev_level`).
|
||||||
|
pub fn leveled_up_from(&self, prev_level: u32) -> bool {
|
||||||
|
self.level > prev_level
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset weekly-goal progress when the ISO week has rolled over.
|
||||||
|
/// No-op if the stored week key already matches `current`.
|
||||||
|
pub fn roll_weekly_goals_if_new_week(&mut self, current: &str) -> bool {
|
||||||
|
if self.weekly_goal_week_iso.as_deref() == Some(current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.weekly_goal_progress.clear();
|
||||||
|
self.weekly_goal_week_iso = Some(current.to_string());
|
||||||
|
self.last_modified = Utc::now();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increment progress for `goal_id` by 1, capped at `target`.
|
||||||
|
///
|
||||||
|
/// Returns `true` if this call brought the counter from below `target`
|
||||||
|
/// to at-or-above `target` (i.e. just completed the goal).
|
||||||
|
pub fn record_weekly_progress(&mut self, goal_id: &str, target: u32) -> bool {
|
||||||
|
let entry = self.weekly_goal_progress.entry(goal_id.to_string()).or_insert(0);
|
||||||
|
if *entry >= target {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*entry = entry.saturating_add(1);
|
||||||
|
self.last_modified = Utc::now();
|
||||||
|
*entry >= target
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a daily-challenge completion for `date`.
|
||||||
|
///
|
||||||
|
/// - First completion ever, or a gap of more than one day: streak resets to 1.
|
||||||
|
/// - Completion the day after the previous: streak increments.
|
||||||
|
/// - Same day as the previous: no-op (idempotent).
|
||||||
|
///
|
||||||
|
/// Returns `true` if this call recorded a fresh completion.
|
||||||
|
pub fn record_daily_completion(&mut self, date: NaiveDate) -> bool {
|
||||||
|
match self.daily_challenge_last_completed {
|
||||||
|
Some(last) if last == date => return false,
|
||||||
|
Some(last) if last + Duration::days(1) == date => {
|
||||||
|
self.daily_challenge_streak = self.daily_challenge_streak.saturating_add(1);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.daily_challenge_streak = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.daily_challenge_last_completed = Some(date);
|
||||||
|
self.last_modified = Utc::now();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! Shared `StatsSnapshot` definition — used by both the game client and the
|
||||||
|
//! sync server to represent cumulative player statistics.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Cumulative game statistics that travel across the sync boundary.
|
||||||
|
///
|
||||||
|
/// Game-logic mutation helpers that depend on `solitaire_core` types (e.g.
|
||||||
|
/// `update_on_win`) are provided via the `StatsExt` extension trait in
|
||||||
|
/// `solitaire_data`. File I/O helpers also live in `solitaire_data::storage`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct StatsSnapshot {
|
||||||
|
/// Total number of games started (won + lost + abandoned).
|
||||||
|
pub games_played: u32,
|
||||||
|
/// Number of games won.
|
||||||
|
pub games_won: u32,
|
||||||
|
/// Number of games lost or abandoned.
|
||||||
|
pub games_lost: u32,
|
||||||
|
/// Current win streak length.
|
||||||
|
pub win_streak_current: u32,
|
||||||
|
/// All-time best win streak.
|
||||||
|
pub win_streak_best: u32,
|
||||||
|
/// Rolling average of win times in seconds.
|
||||||
|
pub avg_time_seconds: u64,
|
||||||
|
/// Fastest single win time in seconds. `u64::MAX` when no wins recorded yet.
|
||||||
|
pub fastest_win_seconds: u64,
|
||||||
|
/// Sum of all winning scores.
|
||||||
|
pub lifetime_score: u64,
|
||||||
|
/// Highest score achieved in a single game.
|
||||||
|
pub best_single_score: u32,
|
||||||
|
/// Wins achieved in Draw-One mode.
|
||||||
|
pub draw_one_wins: u32,
|
||||||
|
/// Wins achieved in Draw-Three mode.
|
||||||
|
pub draw_three_wins: u32,
|
||||||
|
/// Wall-clock time of the last modification (used for conflict detection).
|
||||||
|
pub last_modified: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StatsSnapshot {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
games_played: 0,
|
||||||
|
games_won: 0,
|
||||||
|
games_lost: 0,
|
||||||
|
win_streak_current: 0,
|
||||||
|
win_streak_best: 0,
|
||||||
|
avg_time_seconds: 0,
|
||||||
|
fastest_win_seconds: u64::MAX,
|
||||||
|
lifetime_score: 0,
|
||||||
|
best_single_score: 0,
|
||||||
|
draw_one_wins: 0,
|
||||||
|
draw_three_wins: 0,
|
||||||
|
last_modified: DateTime::UNIX_EPOCH,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatsSnapshot {
|
||||||
|
/// Record an abandoned game (player started a new game without winning).
|
||||||
|
pub fn record_abandoned(&mut self) {
|
||||||
|
self.games_played += 1;
|
||||||
|
self.games_lost += 1;
|
||||||
|
self.win_streak_current = 0;
|
||||||
|
self.last_modified = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Win percentage as 0–100, or `None` if no games played.
|
||||||
|
pub fn win_rate(&self) -> Option<f32> {
|
||||||
|
if self.games_played == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(self.games_won as f32 / self.games_played as f32 * 100.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_rate_is_none_before_any_game() {
|
||||||
|
let s = StatsSnapshot::default();
|
||||||
|
assert!(s.win_rate().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_rate_100_when_all_games_won() {
|
||||||
|
let s = StatsSnapshot {
|
||||||
|
games_played: 5,
|
||||||
|
games_won: 5,
|
||||||
|
..StatsSnapshot::default()
|
||||||
|
};
|
||||||
|
let rate = s.win_rate().expect("should have a rate");
|
||||||
|
assert!((rate - 100.0).abs() < 0.01, "expected 100.0, got {rate}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_rate_50_when_half_won() {
|
||||||
|
let s = StatsSnapshot {
|
||||||
|
games_played: 10,
|
||||||
|
games_won: 5,
|
||||||
|
..StatsSnapshot::default()
|
||||||
|
};
|
||||||
|
let rate = s.win_rate().expect("should have a rate");
|
||||||
|
assert!((rate - 50.0).abs() < 0.01, "expected 50.0, got {rate}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_rate_0_when_no_wins() {
|
||||||
|
let s = StatsSnapshot {
|
||||||
|
games_played: 3,
|
||||||
|
games_won: 0,
|
||||||
|
..StatsSnapshot::default()
|
||||||
|
};
|
||||||
|
let rate = s.win_rate().expect("should have a rate");
|
||||||
|
assert!((rate - 0.0).abs() < 0.01, "expected 0.0, got {rate}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fastest_win_seconds_defaults_to_max() {
|
||||||
|
let s = StatsSnapshot::default();
|
||||||
|
assert_eq!(s.fastest_win_seconds, u64::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user