Compare commits
71 Commits
v0.9.0
...
de52c8a7b7
| Author | SHA1 | Date | |
|---|---|---|---|
| de52c8a7b7 | |||
| dcfa976dad | |||
| 71999e1062 | |||
| 5f5aba8dff | |||
| 9bfca929cb | |||
| 534870a68a | |||
| 0066ca6205 | |||
| 54e024c1b0 | |||
| 3a01318fbd | |||
| 79d391724e | |||
| ba019c0ba7 | |||
| 18d7c121a3 | |||
| cb93bd9265 | |||
| 6723416a55 | |||
| afb08799e8 | |||
| 3b619b8950 | |||
| 37681cf33e | |||
| 99064ce808 | |||
| de4dba6f98 | |||
| 75fc3aa3d6 | |||
| deb034c5fb | |||
| 242b5fef21 | |||
| 3f922ede28 | |||
| 8da62bd05f | |||
| 73cad7e205 | |||
| e14852c093 | |||
| 6240156fee | |||
| 1d9fb1884a | |||
| 97f38085e3 | |||
| 62cd1cf924 | |||
| b10e1a5a87 | |||
| 366fd6d127 | |||
| 7a77c66f6d | |||
| adece12cf1 | |||
| 2cfbc32715 | |||
| 56b37fc653 | |||
| 3ffde038c5 | |||
| ece2a55ffb | |||
| abda354562 | |||
| fbe984cf64 | |||
| efec6f22d5 | |||
| 7cda2a9f1a | |||
| 2b04718f33 | |||
| 505f0ebda3 | |||
| 0f40e717e1 | |||
| 08202f9351 | |||
| e22fcadb22 | |||
| 11d53245cf | |||
| f27a002c91 | |||
| ce8ba6a8c4 | |||
| 66695683eb | |||
| 18ac5adef5 | |||
| 41d75b50de | |||
| 4997356cb5 | |||
| 4bd562671e | |||
| 8221ebc803 | |||
| 4d6f8bccb7 | |||
| 800dfb50ce | |||
| 735d8766a2 | |||
| ccfeb055e5 | |||
| 8f957d919f | |||
| 2407686e13 | |||
| 1ec2593137 | |||
| ffc79447d4 | |||
| 71c0c273a1 | |||
| 21d0c289b5 | |||
| 648cd44387 | |||
| c8553dc8c5 | |||
| eedddb979e | |||
| 59a023ed5e | |||
| 8cd28cfb29 |
@@ -0,0 +1,88 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: "-D warnings"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test & Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: clippy
|
||||||
|
|
||||||
|
- name: Install Linux audio/display dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libasound2-dev \
|
||||||
|
libudev-dev \
|
||||||
|
libwayland-dev \
|
||||||
|
libxkbcommon-dev
|
||||||
|
|
||||||
|
- name: Cache cargo registry and build artifacts
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Clippy (all crates, zero warnings)
|
||||||
|
run: cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
|
- name: Test (headless crates only — no display required)
|
||||||
|
run: |
|
||||||
|
cargo test -p solitaire_core
|
||||||
|
cargo test -p solitaire_sync
|
||||||
|
cargo test -p solitaire_data
|
||||||
|
cargo test -p solitaire_server
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Release Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install Linux audio/display dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libasound2-dev \
|
||||||
|
libudev-dev \
|
||||||
|
libwayland-dev \
|
||||||
|
libxkbcommon-dev
|
||||||
|
|
||||||
|
- name: Cache cargo registry and build artifacts
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-release-
|
||||||
|
|
||||||
|
- name: Build release binaries
|
||||||
|
run: cargo build --workspace --release
|
||||||
@@ -5,3 +5,4 @@
|
|||||||
.env
|
.env
|
||||||
*.tmp
|
*.tmp
|
||||||
data/
|
data/
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "36bab3ca8f126c66a6f4865d2699702689518bd3a796c374f932aba0434a4c94"
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Solitaire Quest — Architecture Document
|
# Solitaire Quest — Architecture Document
|
||||||
|
|
||||||
> **Version:** 1.1
|
> **Version:** 1.1
|
||||||
> **Language:** Rust (Edition 2021)
|
> **Language:** Rust (Edition 2024)
|
||||||
> **Engine:** Bevy (latest stable)
|
> **Engine:** Bevy (latest stable)
|
||||||
> **Last Updated:** 2026-04-20
|
> **Last Updated:** 2026-04-29
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,28 +16,25 @@
|
|||||||
5. [Game Engine Architecture](#5-game-engine-architecture)
|
5. [Game Engine Architecture](#5-game-engine-architecture)
|
||||||
6. [Persistence & Sync Architecture](#6-persistence--sync-architecture)
|
6. [Persistence & Sync Architecture](#6-persistence--sync-architecture)
|
||||||
7. [Sync Server Architecture](#7-sync-server-architecture)
|
7. [Sync Server Architecture](#7-sync-server-architecture)
|
||||||
8. [Google Play Games Services (Android Future)](#8-google-play-games-services-android-future)
|
8. [Data Models](#8-data-models)
|
||||||
9. [Data Models](#9-data-models)
|
9. [API Reference](#9-api-reference)
|
||||||
10. [API Reference](#10-api-reference)
|
10. [Merge Strategy](#10-merge-strategy)
|
||||||
11. [Merge Strategy](#11-merge-strategy)
|
11. [Achievement System](#11-achievement-system)
|
||||||
12. [Achievement System](#12-achievement-system)
|
12. [Progression System](#12-progression-system)
|
||||||
13. [Progression System](#13-progression-system)
|
13. [Audio System](#13-audio-system)
|
||||||
14. [Audio System](#14-audio-system)
|
14. [Asset Pipeline](#14-asset-pipeline)
|
||||||
15. [Asset Pipeline](#15-asset-pipeline)
|
15. [Platform Targets](#15-platform-targets)
|
||||||
16. [Platform Targets](#16-platform-targets)
|
16. [Build & Development Guide](#16-build--development-guide)
|
||||||
17. [Build & Development Guide](#17-build--development-guide)
|
17. [Deployment Guide](#17-deployment-guide)
|
||||||
18. [Deployment Guide](#18-deployment-guide)
|
18. [Security Model](#18-security-model)
|
||||||
19. [Security Model](#19-security-model)
|
19. [Testing Strategy](#19-testing-strategy)
|
||||||
20. [Testing Strategy](#20-testing-strategy)
|
20. [Decision Log](#20-decision-log)
|
||||||
21. [Decision Log](#21-decision-log)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Project Overview
|
## 1. Project Overview
|
||||||
|
|
||||||
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops (iOS/Android as a stretch goal). It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
|
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
|
||||||
|
|
||||||
On Android (stretch goal), sync is enhanced with Google Play Games Services (GPGS) for native achievement popups, leaderboards, and cloud saves — sitting on top of the same underlying sync payload so data stays consistent regardless of which backend was used last.
|
|
||||||
|
|
||||||
### Sync Backend by Platform
|
### Sync Backend by Platform
|
||||||
|
|
||||||
@@ -46,8 +43,6 @@ On Android (stretch goal), sync is enhanced with Google Play Games Services (GPG
|
|||||||
| macOS | Self-hosted server | Full feature set |
|
| macOS | Self-hosted server | Full feature set |
|
||||||
| Windows | Self-hosted server | Full feature set |
|
| Windows | Self-hosted server | Full feature set |
|
||||||
| Linux | Self-hosted server | Full feature set |
|
| Linux | Self-hosted server | Full feature set |
|
||||||
| Android (stretch) | Google Play Games Services | + server as fallback |
|
|
||||||
| iOS (stretch) | Self-hosted server | GPGS not supported on iOS |
|
|
||||||
|
|
||||||
### Design Principles
|
### Design Principles
|
||||||
|
|
||||||
@@ -56,6 +51,7 @@ On Android (stretch goal), sync is enhanced with Google Play Games Services (GPG
|
|||||||
- **No panics in game logic.** Every state transition returns `Result<_, MoveError>`. Panics are only acceptable in startup/configuration code.
|
- **No panics in game logic.** Every state transition returns `Result<_, MoveError>`. Panics are only acceptable in startup/configuration code.
|
||||||
- **One language, one repo.** The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace.
|
- **One language, one repo.** The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace.
|
||||||
- **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s.
|
- **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s.
|
||||||
|
- **UI-first interaction.** Every player-triggered action — new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, etc. — must be reachable from a visible UI control. Keyboard shortcuts exist only as optional accelerators for power users; they are never the sole entry point. A player using only mouse or touch must be able to perform every action. New gameplay features ship with the UI control alongside the system that backs it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,26 +68,25 @@ solitaire_quest/
|
|||||||
├── Dockerfile # Multi-stage server build
|
├── Dockerfile # Multi-stage server build
|
||||||
├── docker-compose.yml # Server + Caddy reverse proxy
|
├── docker-compose.yml # Server + Caddy reverse proxy
|
||||||
│
|
│
|
||||||
├── assets/ # All runtime assets (loaded via Bevy AssetServer)
|
├── assets/ # Loaded at runtime via AssetServer (audio is embedded via include_bytes!())
|
||||||
│ ├── cards/
|
│ ├── cards/
|
||||||
│ │ ├── faces/ # Card face sprites (suit + rank)
|
│ │ ├── faces/{RANK}{SUIT}.png # 52 card faces — xCards @2x artwork (LGPL-3.0)
|
||||||
│ │ └── backs/ # Card back designs (back_0.png … back_4.png)
|
│ │ └── backs/back_0.png – back_4.png # back_0 = xCards bicycle_blue; back_1–4 are generated patterns
|
||||||
│ ├── backgrounds/ # Table backgrounds (bg_0.png … bg_4.png)
|
│ ├── backgrounds/bg_0.png – bg_4.png # generated textures
|
||||||
│ ├── fonts/ # .ttf font files
|
│ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
|
||||||
│ └── audio/
|
│ └── audio/
|
||||||
│ ├── card_deal.ogg
|
│ ├── card_deal.wav
|
||||||
│ ├── card_flip.ogg
|
│ ├── card_flip.wav
|
||||||
│ ├── card_place.ogg
|
│ ├── card_place.wav
|
||||||
│ ├── card_invalid.ogg
|
│ ├── card_invalid.wav
|
||||||
│ ├── win_fanfare.ogg
|
│ ├── win_fanfare.wav
|
||||||
│ └── ambient_loop.ogg
|
│ └── ambient_loop.wav
|
||||||
│
|
│
|
||||||
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
|
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
|
||||||
├── solitaire_sync/ # Shared API types — used by client and server
|
├── solitaire_sync/ # Shared API types — used by client and server
|
||||||
├── solitaire_data/ # Persistence, sync client, settings
|
├── solitaire_data/ # Persistence, sync client, settings
|
||||||
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||||
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
||||||
├── solitaire_gpgs/ # Google Play Games Services bridge (Android only, stub until stretch goal)
|
|
||||||
└── solitaire_app/ # Main binary entry point
|
└── solitaire_app/ # Main binary entry point
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -135,22 +130,7 @@ Owns:
|
|||||||
- `SyncBackend` enum and backend selection
|
- `SyncBackend` enum and backend selection
|
||||||
- Solitaire Server sync client (JWT auth, auto-refresh)
|
- Solitaire Server sync client (JWT auth, auto-refresh)
|
||||||
- OS keychain integration (`keyring`)
|
- OS keychain integration (`keyring`)
|
||||||
- `SyncProvider` trait — implemented by both `SolitaireServerClient` and `GpgsClient` (Android)
|
- `SyncProvider` trait — implemented by `SolitaireServerClient`
|
||||||
|
|
||||||
### `solitaire_gpgs` *(stub — implement when targeting Android)*
|
|
||||||
**Dependencies:** `solitaire_sync`, `jni` (Android only), `solitaire_data` trait impls.
|
|
||||||
|
|
||||||
Android-only crate, compiled only when `target_os = "android"`. Bridges the Google Play Games Services Java SDK via JNI.
|
|
||||||
|
|
||||||
Owns:
|
|
||||||
- `GpgsClient` implementing the `SyncProvider` trait from `solitaire_data`
|
|
||||||
- GPGS Saved Games API calls (load/save cloud save slot)
|
|
||||||
- GPGS Achievements API calls (unlock, reveal, increment)
|
|
||||||
- GPGS Leaderboards API calls (submit score, load scores)
|
|
||||||
- Google Sign-In token management (via JNI into Android SDK)
|
|
||||||
- Conversion between GPGS cloud save blob ↔ `SyncPayload`
|
|
||||||
|
|
||||||
> **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_kira_audio`, `solitaire_core`, `solitaire_data`.
|
**Dependencies:** `bevy`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`.
|
||||||
@@ -165,6 +145,7 @@ Owns:
|
|||||||
- All Bevy UI 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
|
||||||
|
- Card, background, and font asset loading via Bevy `AssetServer` (audio is the lone exception — embedded via `include_bytes!()` in `audio_plugin.rs`)
|
||||||
|
|
||||||
### `solitaire_server`
|
### `solitaire_server`
|
||||||
**Dependencies:** `solitaire_sync`, `axum`, `sqlx`, `jsonwebtoken`, `bcrypt`, `tower-governor`, `tracing`, `tokio`, `dotenvy`.
|
**Dependencies:** `solitaire_sync`, `axum`, `sqlx`, `jsonwebtoken`, `bcrypt`, `tower-governor`, `tracing`, `tokio`, `dotenvy`.
|
||||||
@@ -223,8 +204,7 @@ SyncPlugin::on_startup()
|
|||||||
│ spawns AsyncComputeTask
|
│ spawns AsyncComputeTask
|
||||||
▼
|
▼
|
||||||
solitaire_data::sync_pull() ← dispatches to active SyncProvider
|
solitaire_data::sync_pull() ← dispatches to active SyncProvider
|
||||||
│ SolitaireServerClient (desktop / iOS)
|
│ SolitaireServerClient
|
||||||
│ GpgsClient (Android, future)
|
|
||||||
▼
|
▼
|
||||||
solitaire_sync::merge(local, remote)
|
solitaire_sync::merge(local, remote)
|
||||||
│
|
│
|
||||||
@@ -245,7 +225,7 @@ SyncPlugin::on_exit()
|
|||||||
│ blocking push (acceptable on exit, not on main loop)
|
│ blocking push (acceptable on exit, not on main loop)
|
||||||
▼
|
▼
|
||||||
active SyncProvider::push(local)
|
active SyncProvider::push(local)
|
||||||
│ POST to server — or — GPGS Saved Games PUT (Android)
|
│ POST to server
|
||||||
▼
|
▼
|
||||||
Done
|
Done
|
||||||
```
|
```
|
||||||
@@ -256,10 +236,13 @@ Done
|
|||||||
|
|
||||||
### Bevy Plugins
|
### Bevy Plugins
|
||||||
|
|
||||||
| Plugin | Key | Responsibility |
|
The "Shortcut" column lists optional keyboard accelerators. Every action in this table must also be reachable from a visible UI control (button, menu item, on-screen affordance) per the UI-first design principle in §1; the shortcut is a power-user convenience, not the sole entry point.
|
||||||
|
|
||||||
|
| Plugin | Shortcut | Responsibility |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop |
|
| `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop |
|
||||||
| `TablePlugin` | — | Pile markers, background, layout calculation |
|
| `TablePlugin` | — | Pile markers, background, layout calculation |
|
||||||
|
| `FontPlugin` | — | Loads FiraMono-Medium via `AssetServer` at startup; exposes `FontResource` handle |
|
||||||
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
|
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
|
||||||
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
|
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
|
||||||
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
|
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
|
||||||
@@ -268,7 +251,7 @@ Done
|
|||||||
| `CursorPlugin` | — | Custom cursor sprite during drag |
|
| `CursorPlugin` | — | Custom cursor sprite during drag |
|
||||||
| `SelectionPlugin` | — | Keyboard-driven card selection |
|
| `SelectionPlugin` | — | Keyboard-driven card selection |
|
||||||
| `GamePlugin` | N | Core game state resource, new-game flow, win/game-over overlays |
|
| `GamePlugin` | N | Core game state resource, new-game flow, win/game-over overlays |
|
||||||
| `HudPlugin` | — | Score, move counter, timer, auto-complete badge |
|
| `HudPlugin` | — | Score, move counter, timer, auto-complete badge, and the top-right action button bar (Undo / Pause / Help / New Game). Each button fires the same request event the corresponding hotkey does. |
|
||||||
| `StatsPlugin` | S | Stats overlay and persistence |
|
| `StatsPlugin` | S | Stats overlay and persistence |
|
||||||
| `ProgressPlugin` | — | XP/level system, persistence |
|
| `ProgressPlugin` | — | XP/level system, persistence |
|
||||||
| `AchievementPlugin` | A | Unlock evaluation, toast events, persistence |
|
| `AchievementPlugin` | A | Unlock evaluation, toast events, persistence |
|
||||||
@@ -309,6 +292,20 @@ struct StatsResource(StatsSnapshot);
|
|||||||
struct ProgressResource(PlayerProgress);
|
struct ProgressResource(PlayerProgress);
|
||||||
struct AchievementsResource(Vec<AchievementRecord>);
|
struct AchievementsResource(Vec<AchievementRecord>);
|
||||||
struct SettingsResource(Settings);
|
struct SettingsResource(Settings);
|
||||||
|
|
||||||
|
// Pre-loaded card face and back PNG handles
|
||||||
|
struct CardImageSet {
|
||||||
|
faces: [[Handle<Image>; 13]; 4], // [suit][rank]: Clubs=0..Spades=3, Ace=0..King=12
|
||||||
|
backs: [Handle<Image>; 5], // indexed by selected_card_back setting
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project-wide font handle (FiraMono-Medium loaded via AssetServer at startup)
|
||||||
|
struct FontResource(Handle<Font>);
|
||||||
|
|
||||||
|
// Pre-loaded background PNG handles
|
||||||
|
struct BackgroundImageSet {
|
||||||
|
handles: Vec<Handle<Image>>, // indices 0–4 match selected_background setting
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Bevy Events
|
### Key Bevy Events
|
||||||
@@ -382,7 +379,6 @@ Implementations:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `LocalOnlyProvider` | No-op (default) | All |
|
| `LocalOnlyProvider` | No-op (default) | All |
|
||||||
| `SolitaireServerClient` | Self-hosted server | All |
|
| `SolitaireServerClient` | Self-hosted server | All |
|
||||||
| `GpgsClient` *(future)* | Google Play Games Services | Android only |
|
|
||||||
|
|
||||||
Sync always runs on `bevy::tasks::AsyncComputeTaskPool` — the game thread is never blocked.
|
Sync always runs on `bevy::tasks::AsyncComputeTaskPool` — the game thread is never blocked.
|
||||||
|
|
||||||
@@ -397,9 +393,6 @@ pub enum SyncBackend {
|
|||||||
// JWT access + refresh tokens stored in OS keychain
|
// JWT access + refresh tokens stored in OS keychain
|
||||||
// key: "solitaire_quest_server_{username}"
|
// key: "solitaire_quest_server_{username}"
|
||||||
},
|
},
|
||||||
GooglePlayGames,
|
|
||||||
// No credentials stored locally — auth managed by Google Sign-In SDK via JNI
|
|
||||||
// Android only; selecting this on non-Android falls back to Local silently
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -411,10 +404,6 @@ On exit: `POST /api/sync/push` with payload
|
|||||||
On 401: automatically attempt `POST /api/auth/refresh`, retry once, then surface error to user.
|
On 401: automatically attempt `POST /api/auth/refresh`, retry once, then surface error to user.
|
||||||
Credentials stored in OS keychain via `keyring` — never in plaintext on disk.
|
Credentials stored in OS keychain via `keyring` — never in plaintext on disk.
|
||||||
|
|
||||||
### Google Play Games Sync *(Android — future, see Section 8)*
|
|
||||||
|
|
||||||
Implemented in `solitaire_gpgs` crate. Uses the GPGS Saved Games API with named slot `"solitaire_quest_sync"`. The `GpgsClient` struct implements `SyncProvider` — the `SyncPlugin` treats it identically to `SolitaireServerClient`. The same `solitaire_sync::merge()` function applies regardless of which provider returned the remote data.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Sync Server Architecture
|
## 7. Sync Server Architecture
|
||||||
@@ -501,89 +490,7 @@ This ensures all players worldwide get the same challenge for a given date, rega
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Google Play Games Services (Android Future)
|
## 8. Data Models
|
||||||
|
|
||||||
> **Status: Stub only.** Do not implement JNI bindings until Android is actively targeted. The `solitaire_gpgs` crate exists in the workspace with a trait stub so the compiler enforces the interface contract from day one.
|
|
||||||
|
|
||||||
### Why GPGS on Android
|
|
||||||
|
|
||||||
Google Play Games Services provides first-class Android features that would otherwise require significant backend work:
|
|
||||||
|
|
||||||
| Feature | GPGS Provides | Our Alternative |
|
|
||||||
|---|---|---|
|
|
||||||
| Cloud saves | Saved Games API | Self-hosted server |
|
|
||||||
| Achievements | Native popups + Play profile | In-game toasts only |
|
|
||||||
| Leaderboards | Hosted by Google, visible in Play app | Server leaderboard |
|
|
||||||
| Auth | Google Sign-In, no registration | Username + password |
|
|
||||||
|
|
||||||
On Android, GPGS is the **primary** sync provider. The self-hosted server is the **fallback** if the player is not signed in or has no server configured. Both can be active simultaneously — a win pushes to both, pull merges from whichever responded last.
|
|
||||||
|
|
||||||
### Compatibility Reality
|
|
||||||
|
|
||||||
| Platform | GPGS Support |
|
|
||||||
|---|---|
|
|
||||||
| Android | ✅ Full |
|
|
||||||
| Windows | ✅ GPGS for PC (optional, separate SDK) |
|
|
||||||
| macOS | ❌ Not supported |
|
|
||||||
| Linux | ❌ Not supported |
|
|
||||||
| iOS | ❌ Not supported |
|
|
||||||
|
|
||||||
macOS, Linux, and iOS users always use the self-hosted server. This is why the server is the primary design and GPGS is an enhancement layer.
|
|
||||||
|
|
||||||
### `solitaire_gpgs` Crate Design
|
|
||||||
|
|
||||||
The crate is compiled only on Android (`#[cfg(target_os = "android")]`). On all other platforms the crate exports only the stub.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// solitaire_gpgs/src/lib.rs
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
mod android;
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
mod stub;
|
|
||||||
|
|
||||||
pub use stub::GpgsClient; // stub on desktop
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
pub use android::GpgsClient; // real impl on Android
|
|
||||||
```
|
|
||||||
|
|
||||||
### JNI Bridge (Android implementation — future)
|
|
||||||
|
|
||||||
The real `GpgsClient` uses the `jni` crate to call into the GPGS Android SDK:
|
|
||||||
|
|
||||||
```
|
|
||||||
Rust GpgsClient
|
|
||||||
│ jni::JNIEnv
|
|
||||||
▼
|
|
||||||
Java: com.google.android.gms.games.PlayGames
|
|
||||||
├── getSnapshotsClient() → Saved Games (sync payload)
|
|
||||||
├── getAchievementsClient() → unlock / reveal
|
|
||||||
└── getLeaderboardsClient() → submit score
|
|
||||||
```
|
|
||||||
|
|
||||||
Steps required when Android work begins:
|
|
||||||
1. Add `cargo-mobile2` to the build toolchain
|
|
||||||
2. Implement `GpgsClient` with `jni` bindings in `solitaire_gpgs/src/android.rs`
|
|
||||||
3. Add `GpgsClient: SyncProvider` impl — pull/push map to Saved Games load/save
|
|
||||||
4. Mirror achievement unlocks: on `AchievementUnlockedEvent`, call GPGS unlock API alongside in-game toast
|
|
||||||
5. Submit scores to GPGS leaderboard on `GameWonEvent`
|
|
||||||
6. Add Google Sign-In button to the Settings screen (Android build only, `#[cfg]` gated)
|
|
||||||
|
|
||||||
### Dual-Sync on Android
|
|
||||||
|
|
||||||
When both GPGS and the self-hosted server are configured, the `SyncPlugin` runs both providers concurrently and merges all three payloads (local + GPGS + server) using the same `solitaire_sync::merge()` function applied twice:
|
|
||||||
|
|
||||||
```
|
|
||||||
local ──────┐
|
|
||||||
├── merge() ──► intermediate ──┐
|
|
||||||
gpgs ────────┘ ├── merge() ──► final
|
|
||||||
server ──────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Data Models
|
|
||||||
|
|
||||||
### Core Game Models (`solitaire_core`)
|
### Core Game Models (`solitaire_core`)
|
||||||
|
|
||||||
@@ -677,14 +584,14 @@ pub struct Settings {
|
|||||||
pub music_volume: f32,
|
pub music_volume: f32,
|
||||||
pub animation_speed: AnimSpeed,
|
pub animation_speed: AnimSpeed,
|
||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
pub sync_backend: SyncBackend, // Local | SolitaireServer | GooglePlayGames
|
pub sync_backend: SyncBackend, // Local | SolitaireServer
|
||||||
pub first_run_complete: bool,
|
pub first_run_complete: bool,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. API Reference
|
## 9. API Reference
|
||||||
|
|
||||||
All endpoints are under the base URL configured by the user (e.g., `https://solitaire.example.com`).
|
All endpoints are under the base URL configured by the user (e.g., `https://solitaire.example.com`).
|
||||||
|
|
||||||
@@ -727,9 +634,9 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Merge Strategy
|
## 10. Merge Strategy
|
||||||
|
|
||||||
Used identically by the `SolitaireServerClient`, `GpgsClient`, and server-side handler. Lives in `solitaire_sync` as a pure function with no I/O. Called once per provider when multiple backends are active simultaneously (e.g. GPGS + server on Android).
|
Used by both `SolitaireServerClient` and the server-side handler. Lives in `solitaire_sync` as a pure function with no I/O.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload {
|
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload {
|
||||||
@@ -769,7 +676,7 @@ pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Achievement System
|
## 11. Achievement System
|
||||||
|
|
||||||
### Definition Structure
|
### Definition Structure
|
||||||
|
|
||||||
@@ -814,13 +721,9 @@ pub struct AchievementDef {
|
|||||||
|
|
||||||
Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently.
|
Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently.
|
||||||
|
|
||||||
### GPGS Mirroring *(Android, future)*
|
|
||||||
|
|
||||||
When the `GpgsClient` is active, every `AchievementUnlockedEvent` also triggers a GPGS `achievements.unlock()` call via JNI so the achievement appears in the player's Google Play profile. A static map in `solitaire_gpgs` maps our achievement IDs to GPGS achievement IDs (assigned in the Google Play Console). Mirroring is fire-and-forget — failures are logged but never block the in-game toast.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Progression System
|
## 12. Progression System
|
||||||
|
|
||||||
### XP Sources
|
### XP Sources
|
||||||
|
|
||||||
@@ -849,18 +752,18 @@ Levels 11+: level = 10 + floor((total_xp - 5000) / 1000)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Audio System
|
## 13. Audio System
|
||||||
|
|
||||||
Audio uses `bevy_kira_audio`. All sound files are `.ogg` (good compression, cross-platform, royalty-free).
|
Audio uses `bevy_kira_audio`. All sound files are `.wav`.
|
||||||
|
|
||||||
| File | Trigger |
|
| File | Trigger |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `card_deal.ogg` | New game deal animation |
|
| `card_deal.wav` | New game deal animation |
|
||||||
| `card_flip.ogg` | Card flips face-up |
|
| `card_flip.wav` | Card flips face-up |
|
||||||
| `card_place.ogg` | Valid card placement |
|
| `card_place.wav` | Valid card placement |
|
||||||
| `card_invalid.ogg` | Invalid move attempt |
|
| `card_invalid.wav` | Invalid move attempt |
|
||||||
| `win_fanfare.ogg` | Game won |
|
| `win_fanfare.wav` | Game won |
|
||||||
| `ambient_loop.ogg` | Looping background music (restarts seamlessly) |
|
| `ambient_loop.wav` | Looping background music |
|
||||||
|
|
||||||
Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `bevy_kira_audio` channel volumes.
|
Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `bevy_kira_audio` channel volumes.
|
||||||
|
|
||||||
@@ -868,43 +771,66 @@ Audio systems listen for Bevy events and never block the game thread.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 15. Asset Pipeline
|
## 14. Asset Pipeline
|
||||||
|
|
||||||
All assets are loaded through Bevy's `AssetServer`. No bytes are hardcoded in source.
|
### Rendering approach
|
||||||
|
|
||||||
### Card Sprites
|
Cards are Bevy `Sprite` entities with `Handle<Image>` from `CardImageSet`. Face-up cards use one of 52 individual face PNGs selected by `faces[suit][rank]` — rank and suit are baked into each image and no `Text2d` overlay is spawned. Face-down cards use `backs/back_N.png` indexed by `settings.selected_card_back`. `Text2d` labels are only used as a fallback when `CardImageSet` is absent (e.g. tests with `MinimalPlugins`). `CardImageSet` is populated at startup by `card_plugin::load_card_images` via `AssetServer::load()`.
|
||||||
|
|
||||||
Card faces can be either:
|
Backgrounds are Bevy `Sprite` entities with `Handle<Image>` from `BackgroundImageSet`. `BackgroundImageSet` is populated at startup by `table_plugin::load_background_images` via `AssetServer::load()`.
|
||||||
- A texture atlas (`assets/cards/atlas.png` + `atlas.ron` layout) — faster to load, preferred
|
|
||||||
- Individual files (`assets/cards/faces/2_of_clubs.png`, etc.) — easier to author
|
|
||||||
|
|
||||||
Card backs: `assets/cards/backs/back_0.png` through `back_4.png`. Additional backs unlocked via achievements are in the same folder, gated by `PlayerProgress::unlocked_card_backs`.
|
The font `FiraMono-Medium` is loaded via `AssetServer::load("fonts/main.ttf")` at startup by `FontPlugin` and exposed as `FontResource` for use by all UI and text systems.
|
||||||
|
|
||||||
### Backgrounds
|
All three loaders take `Option<Res<AssetServer>>` so they degrade cleanly under `MinimalPlugins` in tests: when the server is absent, `CardImageSet`/`BackgroundImageSet` are inserted with empty handle slots and the plugins fall back to `Text2d` rank+suit overlays and solid-colour board backgrounds. The `assets/` directory must ship alongside the binary.
|
||||||
|
|
||||||
`assets/backgrounds/bg_0.png` through `bg_4.png`. Same unlock gating as card backs.
|
The `assets/` directory layout:
|
||||||
|
|
||||||
### Fonts
|
```
|
||||||
|
assets/
|
||||||
|
├── cards/
|
||||||
|
│ ├── faces/{rank}_{suit}.png # 52 individual card faces (120×168, generated by solitaire_assetgen)
|
||||||
|
│ └── backs/back_0.png – back_4.png # placeholder patterns
|
||||||
|
├── backgrounds/bg_0.png – bg_4.png # placeholder textures
|
||||||
|
├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
|
||||||
|
└── audio/
|
||||||
|
├── card_deal.wav
|
||||||
|
├── card_flip.wav
|
||||||
|
├── card_place.wav
|
||||||
|
├── card_invalid.wav
|
||||||
|
├── win_fanfare.wav
|
||||||
|
└── ambient_loop.wav
|
||||||
|
```
|
||||||
|
|
||||||
`assets/fonts/main.ttf` — used for card rank/suit text in Bevy UI.
|
### Audio
|
||||||
|
|
||||||
|
All sound effect WAV files are embedded at compile time via `include_bytes!()` in `audio_plugin.rs`. There is no runtime asset loading — the binary is fully self-contained.
|
||||||
|
|
||||||
|
| File | Trigger |
|
||||||
|
|---|---|
|
||||||
|
| `card_deal.wav` | New game deal animation |
|
||||||
|
| `card_flip.wav` | Card flips face-up |
|
||||||
|
| `card_place.wav` | Valid card placement |
|
||||||
|
| `card_invalid.wav` | Invalid move attempt |
|
||||||
|
| `win_fanfare.wav` | Game won |
|
||||||
|
| `ambient_loop.wav` | Looping background music |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 16. Platform Targets
|
## 15. Platform Targets
|
||||||
|
|
||||||
| Platform | Status | Primary Sync | Notes |
|
| Platform | Status | Primary Sync | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
|
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
|
||||||
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain; optional GPGS for PC (future) |
|
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
|
||||||
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
|
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
|
||||||
| Android | Stretch | Google Play Games + server | `cargo-mobile2`, touch input, GPGS via JNI |
|
| Android | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
|
||||||
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input; GPGS unavailable on iOS |
|
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
|
||||||
|
|
||||||
Minimum Bevy window size enforced: 800×600. Desktop windows are freely resizable; layout recomputes on `WindowResized`.
|
Minimum Bevy window size enforced: 800×600. Desktop windows are freely resizable; layout recomputes on `WindowResized`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 17. Build & Development Guide
|
## 16. Build & Development Guide
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
@@ -965,7 +891,7 @@ Add `--features bevy/dynamic_linking` during development to dramatically reduce
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 18. Deployment Guide
|
## 17. Deployment Guide
|
||||||
|
|
||||||
### Docker Compose (Recommended)
|
### Docker Compose (Recommended)
|
||||||
|
|
||||||
@@ -1010,7 +936,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 19. Security Model
|
## 18. Security Model
|
||||||
|
|
||||||
| Concern | Mitigation |
|
| Concern | Mitigation |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -1026,7 +952,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 20. Testing Strategy
|
## 19. Testing Strategy
|
||||||
|
|
||||||
### Unit Tests (`solitaire_core`)
|
### Unit Tests (`solitaire_core`)
|
||||||
|
|
||||||
@@ -1065,12 +991,10 @@ Using `axum::test` and an in-memory SQLite database:
|
|||||||
- [ ] Achievement toast appears and dismisses
|
- [ ] Achievement toast appears and dismisses
|
||||||
- [ ] Server sync: register, login, push, pull on second machine
|
- [ ] Server sync: register, login, push, pull on second machine
|
||||||
- [ ] Server sync: JWT refresh on 401 works transparently
|
- [ ] Server sync: JWT refresh on 401 works transparently
|
||||||
- [ ] GPGS sync (Android only): sign in, unlock achievement, verify appears in Play Games app
|
|
||||||
- [ ] Dual sync (Android only): GPGS + server both configured, payloads merge correctly
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 21. Decision Log
|
## 20. Decision Log
|
||||||
|
|
||||||
| Decision | Rationale | Date |
|
| Decision | Rationale | Date |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -1082,7 +1006,8 @@ Using `axum::test` and an in-memory SQLite database:
|
|||||||
| bcrypt cost 12 | Balances security and registration latency (~300ms on modern hardware); higher than default 10 | 2026-04-19 |
|
| bcrypt cost 12 | Balances security and registration latency (~300ms on modern hardware); higher than default 10 | 2026-04-19 |
|
||||||
| No email required for server accounts | Reduces PII collected; simplifies self-hosted deployments; password reset handled by server admin if needed | 2026-04-19 |
|
| No email required for server accounts | Reduces PII collected; simplifies self-hosted deployments; password reset handled by server admin if needed | 2026-04-19 |
|
||||||
| Self-hosted server as primary sync (not WebDAV) | A proper Rust server gives us auth, leaderboards, and daily challenge seeding for minimal extra effort over WebDAV, and removes a redundant backend | 2026-04-20 |
|
| Self-hosted server as primary sync (not WebDAV) | A proper Rust server gives us auth, leaderboards, and daily challenge seeding for minimal extra effort over WebDAV, and removes a redundant backend | 2026-04-20 |
|
||||||
| `SyncProvider` trait, not `SyncBackend` match arms | Allows adding Google Play Games Services cleanly; `SyncPlugin` stays backend-agnostic and testable | 2026-04-20 |
|
| `SyncProvider` trait, not `SyncBackend` match arms | `SyncPlugin` stays backend-agnostic and testable; new backends can be added without touching the plugin | 2026-04-20 |
|
||||||
| GPGS as Android enhancement, not replacement | GPGS has no macOS/Linux support; the server must remain universal, with GPGS layered on top for Android players | 2026-04-20 |
|
|
||||||
| Dropped WebDAV backend | Redundant once the self-hosted server exists; removing it reduces surface area and simplifies settings UI | 2026-04-20 |
|
| Dropped WebDAV backend | Redundant once the self-hosted server exists; removing it reduces surface area and simplifies settings UI | 2026-04-20 |
|
||||||
| `solitaire_gpgs` crate stubbed from day one | Enforces the `SyncProvider` interface contract at compile time even before Android work begins; avoids architectural rework later | 2026-04-20 |
|
| Dropped GPGS backend | Redundant with the self-hosted server; adds JNI complexity for no user-visible benefit on the target platforms | 2026-04-28 |
|
||||||
|
| Card, background, and font assets loaded via `AssetServer` | Reverses the earlier embed-via-`include_bytes!()` decision: PNGs and TTFs are loaded at runtime so artwork can be swapped (e.g. xCards @2x faces, alternate card backs, themed backgrounds) without a recompile, and binary size stays small. Loaders take `Option<Res<AssetServer>>` and fall back gracefully under `MinimalPlugins`. The `assets/` directory must ship alongside the binary. | 2026-04-29 |
|
||||||
|
| Audio assets remain embedded via `include_bytes!()` | Audio files are small, change rarely, and the embedded path eliminates a class of runtime-load errors during gameplay; the asset-pipeline reversal does not extend to audio | 2026-04-29 |
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
|
|||||||
solitaire_data/ # Persistence + SyncProvider trait + server client
|
solitaire_data/ # Persistence + SyncProvider trait + server client
|
||||||
solitaire_engine/ # Bevy ECS systems, components, plugins
|
solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||||
solitaire_server/ # Axum sync server binary
|
solitaire_server/ # Axum sync server binary
|
||||||
solitaire_gpgs/ # Google Play Games bridge — STUB ONLY until Android phase
|
|
||||||
solitaire_app/ # Thin binary entry point
|
solitaire_app/ # Thin binary entry point
|
||||||
assets/ # Source assets — embedded at compile time via include_bytes!()
|
assets/ # Source assets — embedded at compile time via include_bytes!()
|
||||||
```
|
```
|
||||||
@@ -48,12 +47,13 @@ cargo clippy -p solitaire_core -- -D warnings
|
|||||||
|
|
||||||
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
|
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
|
||||||
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
|
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
|
||||||
- Assets are embedded at compile time using `include_bytes!()`. No runtime asset loading via `AssetServer`.
|
- Audio assets are embedded at compile time using `include_bytes!()` in `audio_plugin.rs`.
|
||||||
|
- Card faces (52 PNGs in `assets/cards/faces/`), card backs (`assets/cards/backs/back_N.png`), board backgrounds (`assets/backgrounds/bg_N.png`), and the UI font (`assets/fonts/main.ttf`) are loaded at runtime via `AssetServer::load()` and stored as `Handle<Image>`/`Handle<Font>` in the `CardImageSet`, `BackgroundImageSet`, and `FontResource` resources. The `assets/` directory must ship alongside the binary.
|
||||||
|
- Asset-loading systems take `Option<Res<AssetServer>>` so they degrade cleanly under `MinimalPlugins` (tests). When `CardImageSet` is absent, `card_plugin` falls back to a `Text2d` rank+suit overlay; when `BackgroundImageSet` is absent, the board falls back to a solid colour.
|
||||||
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
|
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
|
||||||
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
|
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
|
||||||
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
|
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
|
||||||
- All sync backends implement the `SyncProvider` trait. The `SyncPlugin` is backend-agnostic — never `match` on `SyncBackend` inside a Bevy system.
|
- All sync backends implement the `SyncProvider` trait. The `SyncPlugin` is backend-agnostic — never `match` on `SyncBackend` inside a Bevy system.
|
||||||
- `solitaire_gpgs` is a stub until Android work begins. Do not write JNI bindings yet; keep the compile-time stub so the trait contract is enforced from day one.
|
|
||||||
- `cargo clippy --workspace -- -D warnings` must pass clean after every change.
|
- `cargo clippy --workspace -- -D warnings` must pass clean after every change.
|
||||||
- `cargo test --workspace` must pass after every change.
|
- `cargo test --workspace` must pass after every change.
|
||||||
|
|
||||||
@@ -77,6 +77,7 @@ cargo clippy -p solitaire_core -- -D warnings
|
|||||||
- Resources own shared state. Events communicate between systems. Components own per-entity data.
|
- Resources own shared state. Events communicate between systems. Components own per-entity data.
|
||||||
- All UI screens are built with Bevy UI (`bevy::ui`). Never mix UI layout and game logic in the same system.
|
- All UI screens are built with Bevy UI (`bevy::ui`). Never mix UI layout and game logic in the same system.
|
||||||
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
|
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
|
||||||
|
- **UI-first.** Every player-triggered action (new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, switch mode, etc.) must be reachable from a visible UI control. Keyboard shortcuts are optional accelerators — never the sole entry point. New gameplay features ship with the UI control alongside the system that backs it; do not merge a feature that is keyboard-only.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,42 +5,44 @@ members = [
|
|||||||
"solitaire_data",
|
"solitaire_data",
|
||||||
"solitaire_engine",
|
"solitaire_engine",
|
||||||
"solitaire_server",
|
"solitaire_server",
|
||||||
"solitaire_gpgs",
|
|
||||||
"solitaire_app",
|
"solitaire_app",
|
||||||
"solitaire_assetgen",
|
"solitaire_assetgen",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
license = "MIT"
|
||||||
|
rust-version = "1.95"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
thiserror = "1"
|
thiserror = "2"
|
||||||
rand = "0.8"
|
rand = "0.9"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
dirs = "5"
|
dirs = "6"
|
||||||
keyring = "2"
|
keyring = "4"
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
keyring-core = "1"
|
||||||
|
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||||
|
|
||||||
solitaire_core = { path = "solitaire_core" }
|
solitaire_core = { path = "solitaire_core" }
|
||||||
solitaire_sync = { path = "solitaire_sync" }
|
solitaire_sync = { path = "solitaire_sync" }
|
||||||
solitaire_data = { path = "solitaire_data" }
|
solitaire_data = { path = "solitaire_data" }
|
||||||
solitaire_engine = { path = "solitaire_engine" }
|
solitaire_engine = { path = "solitaire_engine" }
|
||||||
|
|
||||||
bevy = "0.15"
|
bevy = "0.18"
|
||||||
kira = "0.9"
|
kira = "0.12"
|
||||||
|
|
||||||
axum = "0.7"
|
axum = "0.8"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
|
||||||
bcrypt = "0.15"
|
bcrypt = "0.19"
|
||||||
tower_governor = "0.4"
|
tower_governor = "0.8"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ FROM rust:slim AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y pkg-config libssl-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Tell sqlx to use the cached query metadata instead of a live database.
|
# Tell sqlx to use the cached query metadata instead of a live database.
|
||||||
@@ -22,11 +18,11 @@ RUN cargo build --release -p solitaire_server
|
|||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y libssl3 ca-certificates \
|
&& apt-get install -y ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/solitaire_server /usr/local/bin/solitaire_server
|
COPY --from=builder /app/target/release/solitaire_server /usr/local/bin/solitaire_server
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE ${SERVER_PORT:-8080}
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/solitaire_server"]
|
ENTRYPOINT ["/usr/local/bin/solitaire_server"]
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 funman300
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Solitaire Quest
|
||||||
|
|
||||||
|
A cross-platform Klondike Solitaire game written in Rust, featuring a full progression system with XP, levels, achievements, daily challenges, and optional self-hosted sync so your stats follow you across machines.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Klondike Solitaire** — Draw One and Draw Three modes
|
||||||
|
- **Progression** — XP, levels, unlockable card backs and backgrounds
|
||||||
|
- **18 Achievements** — including secret ones
|
||||||
|
- **Daily Challenge** — server-seeded so every player worldwide gets the same deal
|
||||||
|
- **Leaderboard** — opt-in, powered by your own self-hosted server
|
||||||
|
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
|
||||||
|
- **Sync** — pull/push stats across devices via a self-hosted server
|
||||||
|
- **Color-blind mode** — blue tint on red-suit cards
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
**Prerequisites**
|
||||||
|
|
||||||
|
- Rust stable toolchain (`rustup install stable`)
|
||||||
|
- Linux: `libasound2-dev libudev-dev libxkbcommon-dev`
|
||||||
|
- macOS: Xcode Command Line Tools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Fast development build
|
||||||
|
cargo run -p solitaire_app --features bevy/dynamic_linking
|
||||||
|
|
||||||
|
# Release build
|
||||||
|
cargo build -p solitaire_app --release
|
||||||
|
./target/release/solitaire_app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| Left click / drag | Move cards |
|
||||||
|
| Right click | Highlight legal moves for a card |
|
||||||
|
| Space / D | Draw from stock |
|
||||||
|
| Z / Ctrl+Z | Undo |
|
||||||
|
| N | New game |
|
||||||
|
| S | Stats overlay |
|
||||||
|
| A | Achievements overlay |
|
||||||
|
| P | Profile overlay |
|
||||||
|
| O | Settings |
|
||||||
|
| L | Leaderboard |
|
||||||
|
| H | Help / controls |
|
||||||
|
| Enter | Auto-complete (when badge is lit) |
|
||||||
|
| Escape | Pause / clear selection |
|
||||||
|
| Arrow keys | Navigate card selection |
|
||||||
|
|
||||||
|
## Sync Server (optional)
|
||||||
|
|
||||||
|
To sync stats across machines, run the self-hosted server. See [README_SERVER.md](README_SERVER.md) for setup instructions.
|
||||||
|
|
||||||
|
Once the server is running, open **Settings → Sync Backend**, enter the server URL and your username, and register an account from within the game.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All tests
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
# Just game logic (no display required)
|
||||||
|
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
cargo clippy --workspace -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# Solitaire Quest — UX Overhaul Session Handoff
|
||||||
|
|
||||||
|
**Last updated:** 2026-04-30 — Phase 3 complete + Phase 4 in progress (Track B landed on disk, Track G subset in flight via background agent).
|
||||||
|
|
||||||
|
## ⚠️ In-progress work at pause time
|
||||||
|
|
||||||
|
Smoke-test passed; Phase 4 was started. Pushed HEAD is `534870a`. The working tree has **uncommitted** work that is NOT pushed:
|
||||||
|
|
||||||
|
### Track B — window polish (on disk, ready to commit)
|
||||||
|
|
||||||
|
- **File:** `solitaire_app/src/main.rs` (+44 lines)
|
||||||
|
- **What landed:**
|
||||||
|
- X11/Wayland WM_CLASS via `Window::name = Some("solitaire-quest".into())`
|
||||||
|
- Default position `WindowPosition::Centered(MonitorSelection::Primary)`
|
||||||
|
- `install_crash_log_hook()` wraps the default panic hook to also append a `crash.log` next to `settings.json`. Uses `std::time::SystemTime` (no new chrono dep). Falls through silently if the data dir is unavailable.
|
||||||
|
- **Skipped this round (deferred):**
|
||||||
|
- App icon hookup — no artwork asset exists yet; add the loader path when art lands.
|
||||||
|
- Persisted window geometry — needs a `Settings` schema migration.
|
||||||
|
- F11 fullscreen toggle — already wired in `input_plugin.rs:114`, no change needed.
|
||||||
|
- **Build status:** `cargo build -p solitaire_app` clean; `cargo clippy -p solitaire_app -- -D warnings` clean.
|
||||||
|
- **Suggested commit subject:** `feat(app): window polish — class name, centered position, crash-log hook`
|
||||||
|
|
||||||
|
### Track G subset — modal open animation + score-change feedback (in flight)
|
||||||
|
|
||||||
|
- A **background agent** (`general-purpose`, no worktree) was launched against this turn's tree to:
|
||||||
|
- Extend `spawn_modal` in `solitaire_engine/src/ui_modal.rs` with a `ModalEntering` component + `advance_modal_enter` system that animates scrim alpha 0 → `SCRIM` and card scale 0.96 → 1.0 over `MOTION_MODAL_SECS`. Respects `AnimSpeed::Instant` via `scaled_duration`. Animate-OUT path is intentionally out of scope.
|
||||||
|
- In `solitaire_engine/src/hud_plugin.rs`, add a `ScorePulse` 1.0→1.1→1.0 readout pulse over `MOTION_SCORE_PULSE_SECS` and a floating "+N" Text2d (only for ≥ +50 jumps) that drifts up ~40 px and fades over `MOTION_SCORE_PULSE_SECS * 2`.
|
||||||
|
- Tests for both behaviours.
|
||||||
|
- **State at pause:** the agent had partial edits in `solitaire_engine/src/ui_modal.rs` (visible via `git status`) — at least one unused-import warning was already surfacing. It had not reported back when this snapshot was taken.
|
||||||
|
- **Resume options for the next session:**
|
||||||
|
1. **Wait for the notification.** The agent runs in background; if Claude Code is still alive, the completion notification will fire.
|
||||||
|
2. **Inspect and finish manually.** `git diff solitaire_engine/src/ui_modal.rs solitaire_engine/src/hud_plugin.rs` to see what landed; finish or revert and restart with a tighter prompt.
|
||||||
|
3. **Discard and restart.** `git restore solitaire_engine/src/ui_modal.rs solitaire_engine/src/hud_plugin.rs` then relaunch the agent with the prompt below.
|
||||||
|
|
||||||
|
### Next-session workflow at pause
|
||||||
|
|
||||||
|
1. Verify the workspace builds cleanly with **all** in-flight changes: `cargo build --workspace && cargo clippy --workspace -- -D warnings && cargo test --workspace`. The Track B `main.rs` change is independent — even if Track G is reverted, B compiles on its own.
|
||||||
|
2. If Track B is clean and Track G is incomplete or broken: commit Track B first using the subject above, then deal with Track G.
|
||||||
|
3. If both are clean: commit each as a separate landing — one feature per commit per project convention.
|
||||||
|
4. Use:
|
||||||
|
```
|
||||||
|
git -c user.name=funman300 -c user.email=root@vscode.infinity commit -m "<subject>"
|
||||||
|
```
|
||||||
|
5. Push with `git push origin master` (requires interactive credentials on `git.aleshym.co`).
|
||||||
|
|
||||||
|
### Original Track G subset prompt (for relaunch if needed)
|
||||||
|
|
||||||
|
The agent's full brief is preserved here verbatim — paste into a fresh agent if the current one is unrecoverable:
|
||||||
|
|
||||||
|
```
|
||||||
|
Two UI/UX polish items from track G. Tree clean at HEAD `534870a`.
|
||||||
|
Sub-agents CANNOT git commit — stage your work; orchestrator commits.
|
||||||
|
|
||||||
|
G1. Modal open animation: extend spawn_modal in ui_modal.rs with a
|
||||||
|
ModalEntering component + advance_modal_enter system that animates
|
||||||
|
scrim alpha 0 → SCRIM and card scale 0.96 → 1.0 over MOTION_MODAL_SECS.
|
||||||
|
Use scaled_duration for AnimSpeed respect; ease-out curve t*(2-t).
|
||||||
|
Register the system in UiModalPlugin::build. Animate-OUT is OUT of
|
||||||
|
scope. Add ≥2 tests covering ModalEntering presence on spawn and
|
||||||
|
removal after duration elapses.
|
||||||
|
|
||||||
|
G2. Score-change feedback in hud_plugin.rs: ScorePulse component that
|
||||||
|
scales the score Text 1.0→1.1→1.0 over MOTION_SCORE_PULSE_SECS using
|
||||||
|
triangular curve. Plus a floating "+N" Text2d (only for ≥ +50 jumps)
|
||||||
|
in ACCENT_PRIMARY that drifts up 40 px and fades over
|
||||||
|
MOTION_SCORE_PULSE_SECS * 2. Add ≥2 tests for floater spawn on +50
|
||||||
|
and despawn after lifetime, plus ≥1 test that +5 does NOT spawn.
|
||||||
|
|
||||||
|
Hard requirements: workspace build + clippy --workspace -- -D warnings
|
||||||
|
+ test --workspace all green. Touch ONLY ui_modal.rs, hud_plugin.rs,
|
||||||
|
optionally ui_theme.rs for new tokens (don't think you'll need any).
|
||||||
|
DO NOT touch solitaire_app/src/main.rs (parallel work).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where we are (Phase 3)
|
||||||
|
|
||||||
|
Phase 3 of the UX overhaul brief is **done**. The whole engine has been migrated to the `ui_theme` design-token system + `ui_modal` scaffold. Animation system upgraded. Final literal sweep landed. The work spans 17 commits this session, from the foundation (`e14852c`) through to the final sweep (`54e024c`).
|
||||||
|
|
||||||
|
### Design direction (already saved as project memory)
|
||||||
|
|
||||||
|
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
|
||||||
|
- **Palette:** Midnight Purple base (`BG_BASE` `#1A0F2E` → `BG_ELEVATED` `#2D1B69` → `BG_ELEVATED_HI` `#3A2580` → `BG_ELEVATED_TOP` `#482F97`) + Balatro yellow primary accent (`ACCENT_PRIMARY` `#FFD23F`) + warm magenta secondary (`ACCENT_SECONDARY` `#FF6B9D`).
|
||||||
|
- See [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for the full direction.
|
||||||
|
|
||||||
|
### Top complaints from the original smoke test — all closed
|
||||||
|
|
||||||
|
1. **HUD too cluttered.** ✅ Closed by `73cad7e` — readouts now sit in a 4-tier vertical stack with progressive disclosure of penalty/bonus tiers.
|
||||||
|
2. **Y/N keyboard prompts feel like debug panels.** ✅ Closed across Confirm, GameOver, Pause, Forfeit, and Settings modals — every prompt now has real Primary/Secondary/Tertiary buttons with hover/press feedback.
|
||||||
|
|
||||||
|
## Foundation (done)
|
||||||
|
|
||||||
|
- **`solitaire_engine/src/ui_theme.rs`** — every design token: colours, 5-rung typography scale, 4-multiple spacing scale, three radius rungs, monotonically-ordered z-index hierarchy, motion durations with `scaled_duration(speed)` helper.
|
||||||
|
- **`solitaire_engine/src/ui_modal.rs`** — `spawn_modal` scaffold + `spawn_modal_header` / `spawn_modal_body_text` / `spawn_modal_actions` / `spawn_modal_button` helpers + `ButtonVariant` enum (Primary / Secondary / Tertiary) + `paint_modal_buttons` system. `UiModalPlugin` registered in `solitaire_app/src/main.rs`.
|
||||||
|
|
||||||
|
## Commits this session (Phase 3, latest first)
|
||||||
|
|
||||||
|
```
|
||||||
|
54e024c chore(engine): final literal-to-token sweep
|
||||||
|
3a01318 feat(engine): upgrade animations — curves, scoped settle, deal jitter, cascade rotation
|
||||||
|
79d3917 chore(data): derive Copy on AnimSpeed
|
||||||
|
ba019c0 feat(engine): convert SettingsPanel to modal scaffold + Done button
|
||||||
|
18d7c12 feat(engine): convert OnboardingPlugin to 3-slide modal flow
|
||||||
|
cb93bd9 fix(engine): pin modals via GlobalZIndex and surface forfeit-no-op toast
|
||||||
|
6723416 feat(engine): convert PauseScreen to modal + add ForfeitConfirmScreen
|
||||||
|
afb0879 docs: add SESSION_HANDOFF.md mid-overhaul checkpoint
|
||||||
|
3b619b8 feat(engine): convert HomeScreen to modal scaffold + Done button
|
||||||
|
37681cf feat(engine): convert LeaderboardScreen to modal scaffold + Done button
|
||||||
|
99064ce feat(engine): convert ProfileScreen to modal scaffold + Done button
|
||||||
|
de4dba6 feat(engine): convert AchievementsScreen to modal scaffold + Done button
|
||||||
|
75fc3aa feat(engine): convert StatsScreen to modal scaffold + Done button
|
||||||
|
deb034c feat(engine): convert HelpScreen to real-button modal with kbd-chip rows
|
||||||
|
242b5fe feat(engine): convert GameOverScreen to real-button modal
|
||||||
|
3f922ed feat(engine): convert ConfirmNewGameScreen to real-button modal
|
||||||
|
8da62bd feat(engine): add ui_modal primitive (scaffold + button variants)
|
||||||
|
73cad7e feat(engine): restructure HUD into 4-tier layout, adopt design tokens
|
||||||
|
e14852c feat(engine): add ui_theme.rs design-token module
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test status:** `cargo build --workspace` clean, `cargo clippy --workspace -- -D warnings` clean, **819 tests pass / 0 failed / 8 ignored**.
|
||||||
|
|
||||||
|
## Smoke-test checklist
|
||||||
|
|
||||||
|
The whole overhaul is on disk. Worth running through once end-to-end:
|
||||||
|
|
||||||
|
1. **Run the game.** `cargo run -p solitaire_app --features bevy/dynamic_linking`.
|
||||||
|
2. **HUD layout** reads as 4 stacked tiers (Score / Mode / Penalty / Selection) with the new midnight-purple palette.
|
||||||
|
3. **Open every overlay** — `S` (Stats), `A` (Achievements), `P` (Profile), `O` (Settings), `L` (Leaderboard), `M` (Home), `F1` (Help). Each is a centred card on a uniform scrim with a yellow `Done` / `Close` primary button. Hover/press states on every button.
|
||||||
|
4. **Settings.** Four sections (Audio / Gameplay / Cosmetic / Sync). Body scrolls within the modal on small windows; `Done` button stays fixed at the bottom regardless of scroll. Card-back / Background pickers tint the selected swatch with `STATE_SUCCESS`.
|
||||||
|
5. **Confirm flow.** Click `New Game` while a game is in progress — the abandon-current-game modal has real Cancel/Confirm buttons. `Y/Enter` and the yellow primary button start a new game; `N/Esc` and the secondary button cancel.
|
||||||
|
6. **Pause + Forfeit.** Press `Esc` — pause modal shows real Resume / Forfeit buttons. Forfeit button opens a Cancel/Forfeit confirmation modal stacked above the pause modal (z-index ordered correctly via `GlobalZIndex`).
|
||||||
|
7. **First-run onboarding.** Delete `settings.json` (or set `first_run_complete = false`) — three-slide flow shows: Welcome → How to play → Keyboard shortcuts. Navigate with `Next` / `Back` buttons or `→` / `←` accelerators. `Esc` skips on slide 0.
|
||||||
|
8. **Animations.**
|
||||||
|
- Slide a card to a pile — motion curves through `SmoothSnap` (slight overshoot + settle), not linear lerp.
|
||||||
|
- Drop a card on a valid destination — only the moved cards bounce; the rest of the table stays still.
|
||||||
|
- Start a new game — deal stagger is no longer mechanically uniform; cards land with subtle ±10% timing variation.
|
||||||
|
- Win a game — cascade now uses `Expressive` curve with per-card ±15° Z-rotation, screen shake driven by the new `MOTION_WIN_SHAKE_*` tokens.
|
||||||
|
9. **Resize the window** — cards still snap, no "snap-back-and-forth" jitter.
|
||||||
|
10. **Win modal** — restyled with the design tokens: midnight-purple card, yellow `Play Again` button.
|
||||||
|
|
||||||
|
## Open follow-ups (not blockers)
|
||||||
|
|
||||||
|
- **Home / Help redundancy.** Home is still a kbd-reference modal that mostly duplicates Help. Three options: (1) keep as-is, (2) convert into a true mode launcher (Classic / Daily / Zen / Challenge / Time Attack cards, locked options visibly disabled below level 5), (3) drop entirely now that the action bar covers everything Home does. Worth asking the user which direction they want.
|
||||||
|
- **Forfeit countdown toast** is now superseded by the Forfeit modal (`6723416`). Confirm the toast path is no longer reachable when smoke-testing.
|
||||||
|
- **Sub-rung pixel sizes** (1 px borders, 64/80/110/150/160 px fixed widths, 28/36/50 px specific spacings) were intentionally left as literals during the step-10 sweep — they're below the smallest `SPACE_*` rung. If the design system grows a "fine" spacing tier in the future, those become candidates for migration.
|
||||||
|
|
||||||
|
## Resume prompt for the next session
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a senior Rust + Bevy developer working toward a public release
|
||||||
|
of Solitaire Quest. Working directory: /home/manage/Rusty_Solitare.
|
||||||
|
Branch: master. Apply that lens to every decision: prefer shipping
|
||||||
|
quality (polish, packaging, defaults, credits, crash safety) over
|
||||||
|
greenfield features. If something is half-done, the question is
|
||||||
|
"finish for v1 or cut for v1?" not "what else can we add?".
|
||||||
|
|
||||||
|
State: HEAD=0066ca6. Phase 3 of the UX overhaul is shipped. cargo
|
||||||
|
build / clippy --workspace -- -D warnings / test --workspace all
|
||||||
|
green — 819 tests pass / 0 fail / 8 ignored.
|
||||||
|
|
||||||
|
READ FIRST (in order, before doing anything):
|
||||||
|
1. SESSION_HANDOFF.md — full state, smoke-test checklist, follow-ups
|
||||||
|
2. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
||||||
|
3. ARCHITECTURE.md §1, §15, §17 — design principles, platform
|
||||||
|
targets, deployment guide
|
||||||
|
4. ~/.claude/projects/-home-manage-Rusty-Solitare/memory/MEMORY.md
|
||||||
|
— saved feedback / project context
|
||||||
|
|
||||||
|
GATING SIGNAL — ASK FIRST, DON'T ASSUME:
|
||||||
|
Before proposing new work, ask: "Did the smoke-test (items 1-10 in
|
||||||
|
SESSION_HANDOFF.md) pass, or did anything regress?" If a regression
|
||||||
|
exists, fix it before opening any new thread.
|
||||||
|
|
||||||
|
LIKELY NEXT DIRECTIONS — surface for the user to choose, don't pick
|
||||||
|
unilaterally. All framed through "what does v1 release need?":
|
||||||
|
|
||||||
|
A. Home modal decision (open in SESSION_HANDOFF.md).
|
||||||
|
- keep as kbd-reference (duplicates Help — release-blocking
|
||||||
|
confusion?)
|
||||||
|
- repurpose as mode launcher (Classic / Daily / Zen / Challenge /
|
||||||
|
Time Attack cards, locked options below level 5)
|
||||||
|
- drop (action bar already covers every action)
|
||||||
|
|
||||||
|
B. Window + release polish — `solitaire_app/src/main.rs:34-48`
|
||||||
|
currently sets only title + resolution + min size. For public
|
||||||
|
release the window needs:
|
||||||
|
- app icon (taskbar / dock / alt-tab) — Bevy `Window::window_icon`
|
||||||
|
or platform `set_window_icon`; ship a .png/.ico asset.
|
||||||
|
- window class / app id (`Window::name`) so X11/Wayland and
|
||||||
|
Windows group taskbar entries correctly.
|
||||||
|
- persist size + position across launches (Settings already
|
||||||
|
saves to JSON; add `window_geometry` field).
|
||||||
|
- F11 (or a Settings toggle) wired to real fullscreen mode.
|
||||||
|
- centered default position on first launch (Bevy supports
|
||||||
|
`WindowPosition::Centered`).
|
||||||
|
- present_mode + vsync verification — make sure Linux/macOS
|
||||||
|
don't ship at uncapped 4000 fps.
|
||||||
|
- panic hook (`std::panic::set_hook`) that writes a crash
|
||||||
|
report next to the save files instead of silently exiting.
|
||||||
|
- macOS Info.plist / Windows .ico bundling — ARCHITECTURE.md
|
||||||
|
§17 currently only covers server deploy.
|
||||||
|
|
||||||
|
C. Sound-design audit. The scoped settle bounce (3a01318) means
|
||||||
|
audio_plugin.rs trigger sites may fire less often than before;
|
||||||
|
verify card_place / card_flip / card_invalid still feel right.
|
||||||
|
|
||||||
|
D. Sync flow end-to-end on a real second machine. Server
|
||||||
|
scaffolding exists but the register → push → pull → restore-on-
|
||||||
|
other-device round trip hasn't been exercised against the new
|
||||||
|
Settings sync section.
|
||||||
|
|
||||||
|
E. Achievement unlock completeness. ARCHITECTURE.md §11 lists 18.
|
||||||
|
The three hidden ones (speed_and_skill, comeback, zen_winner)
|
||||||
|
are most likely to be untested. For release, every advertised
|
||||||
|
achievement needs to actually fire.
|
||||||
|
|
||||||
|
F. Release-readiness backlog:
|
||||||
|
- README / store-page copy / screenshots
|
||||||
|
- LICENSE + third-party credits (xCards art, FiraMono, Bevy)
|
||||||
|
- SemVer + a v0.1.0 git tag
|
||||||
|
- itch.io / Steam packaging per platform (ARCHITECTURE.md §15)
|
||||||
|
- App signing — macOS notarization, Windows Authenticode,
|
||||||
|
Linux AppImage
|
||||||
|
- Telemetry / crash reporting — opt-in, off by default; or
|
||||||
|
confirm we ship without and rely on player reports
|
||||||
|
|
||||||
|
G. UI/UX professional polish — Phase 3 shipped the design system;
|
||||||
|
v1 wants the difference between "consistent" and "feels
|
||||||
|
intentional":
|
||||||
|
- Microcopy pass: every button label, empty state, error
|
||||||
|
message, and onboarding line reviewed for voice + clarity.
|
||||||
|
Pick one verb per concept ("Done" vs "Close" vs "OK") and
|
||||||
|
apply it everywhere.
|
||||||
|
- Empty / loading / error states: Leaderboard before any
|
||||||
|
scores, Stats before any games, Sync UI before login.
|
||||||
|
Today these are likely blank panels.
|
||||||
|
- Modal open/close animation: `MOTION_MODAL_SECS` token exists
|
||||||
|
in `ui_theme.rs:255` but isn't wired up — modals
|
||||||
|
appear/disappear instantly. Add scale-from-0.96 + scrim fade
|
||||||
|
per the token's doc comment.
|
||||||
|
- Tooltips on HUD readouts and settings labels. Bevy has no
|
||||||
|
built-in tooltip; build a small one. Hover a number to learn
|
||||||
|
what it counts.
|
||||||
|
- Accessibility: verify the AAA-contrast claim on
|
||||||
|
`ACCENT_PRIMARY` over `BG_BASE` (ui_theme.rs:65). Confirm
|
||||||
|
`AnimSpeed::Instant` disables every new animation (slide
|
||||||
|
curve, scoped settle, deal jitter, cascade rotation). Add
|
||||||
|
focus rings on `Button` entities for keyboard navigation.
|
||||||
|
- Typography choice: FiraMono is one weight, monospace for
|
||||||
|
everything. Consider shipping a second proportional face for
|
||||||
|
body + headings, keep mono for numerics (HUD score, timer).
|
||||||
|
Or commit to mono and lean into the "calm coder" feel — pick
|
||||||
|
deliberately and document the decision.
|
||||||
|
- Onboarding artwork: the 3 slides are text + buttons. For
|
||||||
|
release, stylised illustrations (or simple animated card
|
||||||
|
props on each slide) elevate the first-launch feel.
|
||||||
|
- Score-change feedback: floating "+N" numbers when score
|
||||||
|
jumps; pulse on the readout when value crosses a milestone.
|
||||||
|
`MOTION_SCORE_PULSE_SECS` is already a token.
|
||||||
|
- Splash / loading screen: today the window goes straight to
|
||||||
|
gameplay. A 1-2 second branded splash signals "real game"
|
||||||
|
vs "rust prototype".
|
||||||
|
- Hit-target audit: every interactive element ≥ 32 px on
|
||||||
|
desktop. Settings has 28 px icon buttons (`ICON_BUTTON_PX`
|
||||||
|
in settings_plugin.rs); revisit.
|
||||||
|
- Win-moment design: the cascade is good; consider a score-
|
||||||
|
breakdown reveal, streak callout, "share your time"
|
||||||
|
affordance for v1.
|
||||||
|
|
||||||
|
WORKFLOW NOTES:
|
||||||
|
- Commits use:
|
||||||
|
git -c user.name=funman300 -c user.email=root@vscode.infinity commit -m "..."
|
||||||
|
- Sub-agents can Edit/Write but CANNOT `git commit`. Brief them to
|
||||||
|
stage + verify only; orchestrator commits on their behalf.
|
||||||
|
See memory/feedback_agent_commit_limit.md.
|
||||||
|
- Remote push needs interactive credentials on git.aleshym.co; the
|
||||||
|
user runs `git push origin master` themselves.
|
||||||
|
- Every commit must pass build / clippy / test. Pause-and-verify
|
||||||
|
is the user's preferred cadence — one feature per commit.
|
||||||
|
|
||||||
|
OPEN AT THE START: ask (1) did smoke-test pass, (2) which of A–G to
|
||||||
|
pursue first. Do not assume.
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,247 @@
|
|||||||
|
# Android Port Investigation
|
||||||
|
|
||||||
|
> **Date:** 2026-04-28
|
||||||
|
> **Author:** Claude Code
|
||||||
|
> **Scope:** Feasibility analysis for porting Solitaire Quest to Android using cargo-mobile2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
A working Android port is feasible but not trivial. The core game logic (`solitaire_core`, `solitaire_sync`) compiles to Android without changes. Every other crate requires at least minor surgery. The biggest blockers are the `keyring` crate (no Android backend), the `kira`/`AudioManager` audio stack (`DefaultBackend` uses CPAL which targets desktop), and the `dirs` crate returning `None` on Android in its current usage. Touch input already has a solid foundation in `input_plugin.rs`. Estimated effort from a clean Android toolchain is **12–18 developer-days** to reach a playable-but-rough state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Bevy on Android — Current Status
|
||||||
|
|
||||||
|
Bevy's Android support is community-maintained via the `winit` backend and is usable but carries known rough edges as of the 0.15/0.16 generation.
|
||||||
|
|
||||||
|
**What works:**
|
||||||
|
- Basic rendering via Vulkan (through `wgpu`). OpenGL ES fallback is available for older devices.
|
||||||
|
- Touch input events: Bevy's `TouchInput` events and the `Touches` resource are populated from Android `MotionEvent`s via `winit`. The existing `touch_start_drag`, `touch_follow_drag`, `touch_end_drag`, and `handle_touch_stock_tap` systems in `input_plugin.rs` will function correctly — this was already written with multi-touch in mind and uses `TouchPhase::Started/Moved/Ended/Canceled` cleanly.
|
||||||
|
- Bevy UI (the `bevy::ui` module used for all overlays).
|
||||||
|
- `WindowResized` events fire correctly, so the layout system will recompute for any screen size.
|
||||||
|
|
||||||
|
**What does not work / needs attention:**
|
||||||
|
- **`bevy/dynamic_linking`**: The dynamic linking feature must be stripped from any Android build profile. Dynamic linking is a desktop-only development shortcut; Android requires static linking.
|
||||||
|
- **Fixed window size**: `main.rs` sets `resolution: (1280u32, 800u32)`. On Android the window is always the full display. This value is harmlessly overridden by the OS, but `min_width`/`min_height` constraints should be removed or set to 0 for Android to avoid Winit warnings.
|
||||||
|
- **`F11` fullscreen toggle** (`handle_fullscreen` in `input_plugin.rs`): `WindowMode::BorderlessFullscreen` is desktop-only. On Android it should be a no-op.
|
||||||
|
- **Keyboard shortcuts**: The entire `handle_keyboard_core`, `handle_keyboard_hint`, `handle_keyboard_forfeit` systems are desktop-only workflows. They will not crash, but they are dead code on Android. No touchscreen replacement for Undo (U), New Game (N), Draw (D/Space), Hint (H), Forfeit (G) exists yet — these need an on-screen UI.
|
||||||
|
- **`CursorPlugin`**: The custom cursor sprite plugin is irrelevant on Android (no cursor). Harmless to leave registered, but it uses `PrimaryWindow` cursor APIs that may panic or warn on Android.
|
||||||
|
|
||||||
|
**cargo-mobile2 integration for Bevy:**
|
||||||
|
The standard path is:
|
||||||
|
1. Install `cargo-mobile2`: `cargo install --locked cargo-mobile2`
|
||||||
|
2. Run `cargo mobile init` in the workspace root. This generates an `android/` directory with the Gradle project, `AndroidManifest.xml`, and JNI glue.
|
||||||
|
3. cargo-mobile2 targets the `solitaire_app` binary crate (the thin entry point). The generated `lib.rs` shim calls `android_main` via `bevy::winit`'s Android entry point.
|
||||||
|
4. The `solitaire_app` crate needs a `[lib]` target added alongside the existing `[[bin]]`, with `crate-type = ["cdylib"]`, used only when building for Android.
|
||||||
|
|
||||||
|
**Required `Cargo.toml` changes (workspace level):**
|
||||||
|
```toml
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
# android_logger and ndk-glue wiring are handled by cargo-mobile2's generated shim.
|
||||||
|
# No direct ndk-glue dependency is needed in app code when using Bevy + cargo-mobile2.
|
||||||
|
```
|
||||||
|
|
||||||
|
**NDK version:** Android NDK r25c or r26 LTS is the tested range for `wgpu`/Vulkan on Android. NDK r27+ may work but has had compatibility reports with CPAL. Set `ANDROID_NDK_ROOT` to the NDK root; the minimum API level should be 26 (Android 8.0) for Vulkan stability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Audio — `kira` + `DefaultBackend`
|
||||||
|
|
||||||
|
**The problem:**
|
||||||
|
`solitaire_engine/src/audio_plugin.rs` creates an `AudioManager<DefaultBackend>`. `kira`'s `DefaultBackend` is an alias for `CpalBackend`, which wraps CPAL. CPAL's Android backend uses OpenSL ES and is functional but historically fragile. As of kira 0.9+, `kira` no longer bundles its own CPAL backend by default in the same way — the `DefaultBackend` feature must be enabled explicitly and requires `cpal` with the Android feature.
|
||||||
|
|
||||||
|
**Current code behavior:**
|
||||||
|
The `AudioPlugin::build` already handles the "no audio device" case gracefully:
|
||||||
|
```rust
|
||||||
|
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
|
||||||
|
if manager.is_none() {
|
||||||
|
warn!("audio device unavailable; SFX disabled");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
This means if the audio manager fails to initialise on Android, the game continues silently. This is acceptable as a first-pass fallback.
|
||||||
|
|
||||||
|
**What is needed for working audio on Android:**
|
||||||
|
- Add `kira` dependency with `cpal` backend enabled for Android: The `kira` workspace dependency currently specifies `version = "0.12"`. Verify that `kira/Cargo.toml` exposes a `cpal` feature (or that `DefaultBackend` compiles on Android targets with NDK). If not, a `CpalBackend` with `cpal = { features = ["oboe"] }` may be needed.
|
||||||
|
- The `NonSend` resource `AudioState` should compile fine — `NonSend` is legal in Bevy Android builds.
|
||||||
|
- `include_bytes!` for the WAV assets is compile-time and unaffected by platform.
|
||||||
|
|
||||||
|
**Recommendation:** Defer full audio verification to a device test. The graceful fallback means a silent-but-working first build is achievable without resolving this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `keyring` Crate — No Android Backend
|
||||||
|
|
||||||
|
**The problem:**
|
||||||
|
`keyring = "2"` is used in `solitaire_data/src/auth_tokens.rs` to store JWT access and refresh tokens in the OS keychain. The `keyring` crate's Android backend does not exist — as of v2.x, supported backends are: macOS Keychain, Windows Credential Manager, Linux Secret Service (D-Bus), and iOS Keychain. There is no Android KeyStore backend.
|
||||||
|
|
||||||
|
On Android, `Entry::new(...)` will return `keyring::Error::NoStorageAccess`, which the existing code already maps to `TokenError::KeychainUnavailable`. So the code will not crash — it will simply fail every token store/load operation.
|
||||||
|
|
||||||
|
**Current failure mode:**
|
||||||
|
Every call to `store_tokens`, `load_access_token`, `load_refresh_token`, or `delete_tokens` will return `Err(TokenError::KeychainUnavailable(...))`. The sync client in `sync_client.rs` needs to be verified to handle this gracefully rather than propagating an error that disables sync entirely.
|
||||||
|
|
||||||
|
**Options for Android credential storage:**
|
||||||
|
|
||||||
|
| Option | Security | Effort | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **In-memory only (prompt re-login each session)** | N/A | 1 day | Simplest. On `TokenError::KeychainUnavailable`, the `SyncProvider` returns `SyncError::Auth`, user is prompted to log in. Already architecturally supported. |
|
||||||
|
| **Encrypted `SharedPreferences` equivalent via JNI** | Good | 4–6 days | Call Android's `EncryptedSharedPreferences` (Jetpack Security) via JNI. Significant JNI boilerplate. |
|
||||||
|
| **AES-256 file encryption using Android Keystore via JNI** | Excellent | 5–8 days | Proper Android keychain equivalent. Complex JNI. |
|
||||||
|
| **Store in app-private file, unencrypted** | Poor | 0.5 days | Only acceptable during development. Never ship. |
|
||||||
|
|
||||||
|
**Recommended approach (first pass):** Use the in-memory / re-login-each-session path. The existing `TokenError::KeychainUnavailable` variant already exists for exactly this reason (Linux without a running secret service). The `SyncPlugin` should detect this on startup and present a "Sync unavailable — please log in" message rather than a hard error. This requires:
|
||||||
|
1. Conditional compilation: when `cfg(target_os = "android")`, replace the `keyring` calls with a no-op in-memory store (a simple `Mutex<HashMap<String, String>>`).
|
||||||
|
2. A `#[cfg(not(target_os = "android"))]` guard on the `keyring` import/dependency in `solitaire_data/Cargo.toml`.
|
||||||
|
|
||||||
|
**Required `solitaire_data/Cargo.toml` change:**
|
||||||
|
```toml
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
|
keyring = { workspace = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
# keyring is replaced by in-memory storage; no dependency needed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `dirs` Crate — Data Directory on Android
|
||||||
|
|
||||||
|
**The problem:**
|
||||||
|
`storage.rs` and other persistence modules use `dirs::data_dir()` to locate `~/.local/share/solitaire_quest/` (or platform equivalent). On Android, `dirs::data_dir()` returns `None` because there is no `XDG_DATA_HOME` and the `dirs` crate does not implement an Android-specific path.
|
||||||
|
|
||||||
|
**Current code behavior:**
|
||||||
|
All persistence functions already handle `None` gracefully (returning default values or `Err`), consistent with the CLAUDE.md lesson about `dirs::data_dir()`. Stats and progress will silently not persist across sessions if `data_dir()` returns `None`.
|
||||||
|
|
||||||
|
**Fix required:**
|
||||||
|
Android apps should store private data in the app's internal storage directory, obtained via JNI: `context.getFilesDir()`. This requires either:
|
||||||
|
- A thin JNI helper (via `jni` crate) called once on startup to obtain the path and store it as a global.
|
||||||
|
- Or passing the path in via the `android_main` entry point using `cargo-mobile2`'s `AndroidApp` handle, which exposes `internal_data_path()`.
|
||||||
|
|
||||||
|
The `cargo-mobile2` + Bevy path exposes an `AndroidApp` via `bevy::winit`'s Android entry point. Bevy 0.13+ passes `AndroidApp` through `WinitPlugin`, and it is accessible via a Bevy resource. A startup system can extract `app.internal_data_path()` and insert a `PlatformDataDirResource` that the storage functions read instead of calling `dirs::data_dir()`.
|
||||||
|
|
||||||
|
**Effort:** 1–2 days to implement the override and thread it through all `storage.rs` / `progress.rs` / `settings.rs` / `achievements.rs` call sites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Touch Input — Current State and Gaps
|
||||||
|
|
||||||
|
**What already exists (strong foundation):**
|
||||||
|
|
||||||
|
The `InputPlugin` in `input_plugin.rs` has a complete parallel touch pipeline:
|
||||||
|
|
||||||
|
| System | Purpose | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `handle_touch_stock_tap` | Tap the stock pile to draw | Complete |
|
||||||
|
| `touch_start_drag` | Begin a touch drag on a face-up card | Complete |
|
||||||
|
| `touch_follow_drag` | Move card(s) with the active finger | Complete |
|
||||||
|
| `touch_end_drag` | Resolve the drag (move or reject) | Complete |
|
||||||
|
|
||||||
|
The touch systems use `TouchInput` events and the `Touches` resource, map touch IDs to `DragState.active_touch_id` to prevent multi-finger conflicts, and share the same `DragState`, `MoveRequestEvent`, `MoveRejectedEvent`, and `StateChangedEvent` infrastructure as the mouse pipeline. The drag threshold (`tuning.drag_threshold_px`) applies identically.
|
||||||
|
|
||||||
|
**Gaps for a production Android experience:**
|
||||||
|
|
||||||
|
1. **No double-tap equivalent for auto-move**: `handle_double_click` is mouse-only. Android users need a double-tap to trigger the same "move to best destination" logic. The `handle_double_click` system checks `buttons.just_pressed(MouseButton::Left)` and will be inert on Android. Estimated: 1 day.
|
||||||
|
|
||||||
|
2. **No touch equivalent for keyboard actions**: Undo, New Game, Draw (when stock is visible but tapping it is awkward), Hint, and Forfeit have no on-screen buttons. These need an Android-specific UI bar or gesture (e.g. two-finger tap for undo). Estimated: 2–3 days for a minimal floating action button strip.
|
||||||
|
|
||||||
|
3. **Drag threshold tuning**: The threshold is in `AnimationTuning` (`tuning.drag_threshold_px`). Touch screens typically need a larger threshold than mouse (physical screens have more accidental movement during a tap). The current value should be evaluated on a real device and likely increased for touch.
|
||||||
|
|
||||||
|
4. **No long-press for right-click equivalent**: The right-click highlight/hint glow (`HintHighlightTimer`) is triggered via right mouse button. Long-press detection is not yet implemented. This is a missing feature but not a blocker for basic play.
|
||||||
|
|
||||||
|
5. **`handle_double_click` uses `LocalDateTime`-based timing via `Time`**: This will work on Android, but `DOUBLE_CLICK_WINDOW = 0.35s` may feel too tight on touch. Should be configurable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Additional Issues Not in Scope of the Four Research Areas
|
||||||
|
|
||||||
|
**`CursorPlugin`:** Uses Bevy's cursor APIs which are desktop-only. Should be conditionally compiled out on Android with `#[cfg(not(target_os = "android"))]`.
|
||||||
|
|
||||||
|
**`reqwest` with `rustls-native-certs`:** The `reqwest` dependency uses `rustls` with native root certificates. On Android, `rustls-native-certs` reads system certificates differently (via the `android_system_properties` crate internally). This generally works but should be tested; Android's certificate store is in a non-standard location vs Linux.
|
||||||
|
|
||||||
|
**App lifecycle (suspend/resume):** Android can suspend the process at any time. Bevy handles `WindowEvent::Suspended` and `WindowEvent::Resumed` via `winit`, pausing the render loop. The `SyncPlugin`'s "push on exit" path (`AppExit` event) should also trigger on `WindowEvent::Suspended` to avoid data loss when the user backgrounds the app. This is a separate feature (1 day).
|
||||||
|
|
||||||
|
**No `sqlx` on Android:** `solitaire_server` is a server binary and is never built for Android. The `sqlx` dependency only exists in `solitaire_server/Cargo.toml` and will not affect Android builds of the client crates.
|
||||||
|
|
||||||
|
**`solitaire_assetgen`:** The asset generation tool is desktop-only and not part of the client build. Unaffected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Required Changes Per Crate
|
||||||
|
|
||||||
|
### `solitaire_core` and `solitaire_sync`
|
||||||
|
No changes required. Both are pure Rust with no platform dependencies.
|
||||||
|
|
||||||
|
### `solitaire_data`
|
||||||
|
| Change | Effort |
|
||||||
|
|---|---|
|
||||||
|
| Gate `keyring` dependency on `#[cfg(not(target_os = "android"))]` | 0.5 days |
|
||||||
|
| Implement `auth_tokens.rs` in-memory fallback for Android | 1 day |
|
||||||
|
| Add `internal_data_path()` override for `dirs::data_dir()` on Android | 1.5 days |
|
||||||
|
| Audit all `dirs::data_dir()` / `settings_file_path()` call sites to accept injected path | 0.5 days |
|
||||||
|
|
||||||
|
### `solitaire_engine`
|
||||||
|
| Change | Effort |
|
||||||
|
|---|---|
|
||||||
|
| Conditionally disable `CursorPlugin` on Android | 0.5 days |
|
||||||
|
| Disable `handle_fullscreen` on Android (or make it a no-op) | 0.25 days |
|
||||||
|
| Implement double-tap for auto-move (touch equivalent of `handle_double_click`) | 1 day |
|
||||||
|
| On-screen action bar for Undo, New Game, Hint (minimal floating buttons) | 2.5 days |
|
||||||
|
| Tune drag threshold for touch; expose as a platform-specific tuning constant | 0.5 days |
|
||||||
|
| Trigger sync push on `WindowEvent::Suspended` in `SyncPlugin` | 1 day |
|
||||||
|
| Verify `kira` audio on Android (test `DefaultBackend` / CPAL; implement fallback if needed) | 1–2 days |
|
||||||
|
|
||||||
|
### `solitaire_app`
|
||||||
|
| Change | Effort |
|
||||||
|
|---|---|
|
||||||
|
| Add `[lib]` target with `crate-type = ["cdylib"]` for Android builds | 0.25 days |
|
||||||
|
| Create `src/lib.rs` (or `src/android.rs`) Android entry point calling `android_main` | 0.5 days |
|
||||||
|
| Remove or guard fixed `resolution` / `resize_constraints` for Android | 0.25 days |
|
||||||
|
| Pass `AndroidApp::internal_data_path()` to a startup resource | 0.5 days |
|
||||||
|
|
||||||
|
### Build / Toolchain
|
||||||
|
| Change | Effort |
|
||||||
|
|---|---|
|
||||||
|
| Install cargo-mobile2, Android NDK r25c/r26, `aarch64-linux-android` target | 1 day |
|
||||||
|
| Run `cargo mobile init`, configure `android/` Gradle project | 0.5 days |
|
||||||
|
| Get a first build compiling (resolve linker / NDK issues) | 1–2 days |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Estimated Effort
|
||||||
|
|
||||||
|
| Phase | Description | Days |
|
||||||
|
|---|---|---|
|
||||||
|
| Toolchain setup | NDK, cargo-mobile2, first compile | 2–3 |
|
||||||
|
| `solitaire_data` Android adaptations | keyring fallback, data dir | 3 |
|
||||||
|
| `solitaire_app` Android entry point | cdylib, AndroidApp wiring | 1 |
|
||||||
|
| `solitaire_engine` guards and fixes | cursor, fullscreen, audio verify | 2–3 |
|
||||||
|
| Touch UX improvements | double-tap, action bar, threshold tuning | 4–5 |
|
||||||
|
| Testing on real device / emulator | iteration, lifecycle edge cases | 2–3 |
|
||||||
|
| **Total** | | **14–17 days** |
|
||||||
|
|
||||||
|
This produces a playable, functionally complete Android build. It does not include Play Store preparation (signing keys, metadata, icon set, permissions manifest tuning) which would add 1–2 more days.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Recommended First Step
|
||||||
|
|
||||||
|
**Get the workspace to compile for `aarch64-linux-android` without running.**
|
||||||
|
|
||||||
|
This surfaces all the real linker and dependency errors before writing any gameplay code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install toolchain
|
||||||
|
rustup target add aarch64-linux-android
|
||||||
|
cargo install --locked cargo-mobile2
|
||||||
|
|
||||||
|
# In the workspace root:
|
||||||
|
cargo mobile init # generates android/ directory
|
||||||
|
|
||||||
|
# Attempt a library build targeting Android
|
||||||
|
cargo build -p solitaire_app --target aarch64-linux-android 2>&1 | head -60
|
||||||
|
```
|
||||||
|
|
||||||
|
The first build will fail on `keyring` (no Android backend) and likely on `dirs`. Fixing those two in `solitaire_data` — gate `keyring` behind `cfg(not(target_os = "android"))` and stub the data directory — will probably get the workspace to a clean compile. From there, the path to a running APK is incremental.
|
||||||
|
|
||||||
|
Do not attempt to resolve audio or touch UX until the build compiles cleanly. Compile errors are the only true blockers; the rest are feature gaps.
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
# Sync Subsystem Manual Test Runbook
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Last Updated:** 2026-04-28
|
||||||
|
**Scope:** Cross-machine sync, JWT refresh, conflict resolution, account deletion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- Two machines (or VMs) referred to as **Machine A** and **Machine B** throughout this runbook. Both must be able to reach the sync server over the network.
|
||||||
|
- A running Solitaire Quest sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup.
|
||||||
|
- Verify the server is live before starting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://solitaire.example.com/health
|
||||||
|
# Expected: {"status":"ok","version":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accounts
|
||||||
|
|
||||||
|
- You will register two separate accounts (`alice` and `bob`) during the tests. You do not need to create them in advance.
|
||||||
|
|
||||||
|
### Tooling
|
||||||
|
|
||||||
|
- `curl` or a REST client (Insomnia/Postman) for manual API calls.
|
||||||
|
- `sqlite3` CLI if you need to inspect the server database directly.
|
||||||
|
- The game binary built in release mode on both machines:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build -p solitaire_app --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Baseline: Clear local data on both machines
|
||||||
|
|
||||||
|
Before starting, delete any existing local save files to ensure a clean state:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Linux
|
||||||
|
rm -rf ~/.local/share/solitaire_quest/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
rm -rf ~/Library/Application\ Support/solitaire_quest/
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
rmdir /s %APPDATA%\solitaire_quest\
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 1 — Full Sync Round-Trip (register, play, push, verify on second machine)
|
||||||
|
|
||||||
|
**Goal:** Confirm that stats played on Machine A appear on Machine B after sync.
|
||||||
|
|
||||||
|
### Step 1 — Register on Machine A
|
||||||
|
|
||||||
|
1. Launch the game on Machine A.
|
||||||
|
2. Open **Settings** (key: `O`) and locate the **Sync** section.
|
||||||
|
3. Enter the server URL and choose a username: `alice`.
|
||||||
|
4. Choose a password (at least 12 characters).
|
||||||
|
5. Tap **Register** (or **Login** if the account already exists).
|
||||||
|
6. The Settings screen should show **Status: syncing…** briefly, then **Status: last synced at HH:MM**.
|
||||||
|
7. Close the game.
|
||||||
|
|
||||||
|
Verify the registration succeeded directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://solitaire.example.com/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"alice","password":"<your-password>"}' | jq .
|
||||||
|
# Expected: {"access_token":"...","refresh_token":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Play games on Machine A
|
||||||
|
|
||||||
|
1. Launch the game on Machine A.
|
||||||
|
2. Win at least **three games** (Draw One or Draw Three — note which mode).
|
||||||
|
3. Check the Stats overlay (key: `S`) and note:
|
||||||
|
- `games_played`
|
||||||
|
- `games_won`
|
||||||
|
- `win_streak_current`
|
||||||
|
- `fastest_win_seconds`
|
||||||
|
4. Close the game normally (this triggers the push-on-exit path).
|
||||||
|
|
||||||
|
### Step 3 — Verify the push reached the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Log in to get a fresh token
|
||||||
|
TOKEN=$(curl -s -X POST https://solitaire.example.com/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"alice","password":"<your-password>"}' | jq -r .access_token)
|
||||||
|
|
||||||
|
# Pull the server's stored state
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
https://solitaire.example.com/api/sync/pull | jq .merged.stats
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm `games_won` matches what you recorded in Step 2.
|
||||||
|
|
||||||
|
### Step 4 — Pull on Machine B
|
||||||
|
|
||||||
|
1. Launch the game on **Machine B** (clean local data).
|
||||||
|
2. Open **Settings**, enter the same server URL, and log in as `alice` with the same password.
|
||||||
|
3. The plugin will pull on startup. Wait for **Status: last synced at HH:MM**.
|
||||||
|
4. Open the Stats overlay (key: `S`) and confirm the numbers from Step 2 are present.
|
||||||
|
|
||||||
|
**Pass criterion:** `games_won`, `games_played`, and `fastest_win_seconds` on Machine B match Machine A.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 2 — JWT Refresh on 401
|
||||||
|
|
||||||
|
**Goal:** Confirm that an expired access token is refreshed transparently without user interaction.
|
||||||
|
|
||||||
|
### Step 1 — Shorten the access token TTL on the server (test environment only)
|
||||||
|
|
||||||
|
Edit the server `.env` and set a short expiry, then restart:
|
||||||
|
|
||||||
|
```
|
||||||
|
JWT_ACCESS_EXPIRY_SECS=5
|
||||||
|
```
|
||||||
|
|
||||||
|
> If you cannot modify the server config, skip to the manual token corruption method in Step 1b.
|
||||||
|
|
||||||
|
### Step 1b (alternative) — Corrupt the stored access token directly
|
||||||
|
|
||||||
|
On the machine where you want to test (Linux example):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List keychain entries (uses secret-tool on GNOME)
|
||||||
|
secret-tool search service solitaire_quest_server
|
||||||
|
|
||||||
|
# Overwrite alice's access token with a deliberately invalid value
|
||||||
|
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "invalid.token.value"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Trigger a sync with the expired/invalid token
|
||||||
|
|
||||||
|
1. Launch the game.
|
||||||
|
2. Either wait for the startup pull (for the short-TTL method), or open **Settings** and tap **Sync Now**.
|
||||||
|
3. Observe the **Status** field.
|
||||||
|
|
||||||
|
**Pass criterion (transparent refresh):** Status briefly shows "syncing…" and then shows "last synced at HH:MM" — no auth error is displayed. The access token in the keychain has been silently replaced.
|
||||||
|
|
||||||
|
**Verify the new token is valid:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Extract the new token from the keychain
|
||||||
|
secret-tool lookup service solitaire_quest_server account alice_access | head -c 50
|
||||||
|
# Should look like a valid JWT (three base64 segments separated by dots)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3 — Test failed refresh (both tokens expired)
|
||||||
|
|
||||||
|
1. Corrupt both the access token and the refresh token in the keychain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "bad"
|
||||||
|
secret-tool store --label="alice_refresh" service solitaire_quest_server account alice_refresh <<< "bad"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Launch the game and trigger a sync.
|
||||||
|
|
||||||
|
**Pass criterion:** The Settings screen shows an error message matching: "Login expired — tap Sync Now after re-logging in". The game must not crash. No data must be lost (local files are untouched).
|
||||||
|
|
||||||
|
3. Restore: log in again via Settings to get fresh tokens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 3 — Conflict Scenario (offline play on both machines, then sync)
|
||||||
|
|
||||||
|
**Goal:** Confirm that progress made on both devices offline is merged correctly, with no data silently discarded.
|
||||||
|
|
||||||
|
### Step 1 — Take both machines offline
|
||||||
|
|
||||||
|
Disable network on both Machine A and Machine B (e.g. airplane mode, or block the server URL in `/etc/hosts`).
|
||||||
|
|
||||||
|
### Step 2 — Play on Machine A (offline)
|
||||||
|
|
||||||
|
1. Win 5 games. Note the resulting streak and `games_won`.
|
||||||
|
2. Close the game.
|
||||||
|
|
||||||
|
### Step 3 — Play on Machine B (offline)
|
||||||
|
|
||||||
|
1. Win 3 different games. Note the resulting streak and `games_won`.
|
||||||
|
2. Close the game.
|
||||||
|
|
||||||
|
At this point Machine A and Machine B have divergent state.
|
||||||
|
|
||||||
|
### Step 4 — Re-enable network, sync Machine A first
|
||||||
|
|
||||||
|
1. Restore network.
|
||||||
|
2. Launch the game on Machine A. The push-on-exit from Step 2 did not reach the server, so:
|
||||||
|
- Open Settings, tap **Sync Now** to force a pull.
|
||||||
|
- Close the game (triggers push-on-exit).
|
||||||
|
3. Verify the server has Machine A's state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
https://solitaire.example.com/api/sync/pull | jq '.merged.stats.games_won'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5 — Sync Machine B
|
||||||
|
|
||||||
|
1. Launch the game on Machine B.
|
||||||
|
2. The startup pull fetches the server's merged state (which now contains Machine A's wins).
|
||||||
|
3. Open Settings — wait for **Status: last synced at HH:MM**.
|
||||||
|
4. Open the Stats overlay.
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- `games_won` = max(Machine A wins, Machine B wins) — at minimum the higher of the two counts.
|
||||||
|
- No games are lost — both machines' win counts contribute.
|
||||||
|
- If the two machines had different `win_streak_current` values, a conflict should be recorded (visible if you inspect the server response directly):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
https://solitaire.example.com/api/sync/pull | jq '.conflicts'
|
||||||
|
```
|
||||||
|
|
||||||
|
- The `win_streak_current` conflict entry will show `local_value` and `remote_value`. The higher value is used as the best-effort resolution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 4 — Account Deletion
|
||||||
|
|
||||||
|
**Goal:** Confirm that `DELETE /api/account` removes all server-side data and that a subsequent authenticated request is rejected.
|
||||||
|
|
||||||
|
### Step 1 — Confirm data exists before deletion
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
https://solitaire.example.com/api/sync/pull | jq '.merged.stats.games_played'
|
||||||
|
# Expected: a non-zero number
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Delete the account via the API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X DELETE \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
https://solitaire.example.com/api/account | jq .
|
||||||
|
# Expected: {"ok":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3 — Verify all data is gone from the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Try to pull with the (now-invalid) token
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
https://solitaire.example.com/api/sync/pull
|
||||||
|
# Expected: HTTP 401 Unauthorized
|
||||||
|
|
||||||
|
# Try to log in again with the same credentials
|
||||||
|
curl -s -X POST https://solitaire.example.com/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"alice","password":"<your-password>"}' | jq .
|
||||||
|
# Expected: HTTP 401 or error body indicating invalid credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4 — Verify local data is NOT deleted
|
||||||
|
|
||||||
|
1. Open the game. The local files (`stats.json`, `progress.json`, etc.) must still be present and intact — account deletion only affects the server.
|
||||||
|
2. Check the Stats overlay and confirm local game history is visible.
|
||||||
|
3. The Settings screen may show an auth error on next sync attempt, which is expected.
|
||||||
|
|
||||||
|
### Step 5 — Re-register with the same username (optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://solitaire.example.com/api/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"alice","password":"<new-password>"}' | jq .
|
||||||
|
# Expected: {"access_token":"...","refresh_token":"..."} — fresh empty account
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pass criterion:** Re-registration succeeds, and a subsequent pull returns a payload with all-zero stats (completely fresh account, no residual data from the deleted account).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 5 — Server Errors Do Not Show "Login Expired"
|
||||||
|
|
||||||
|
**Goal:** Verify that a 500 Internal Server Error or 429 Too Many Requests shows a network error, not an auth error, to the user.
|
||||||
|
|
||||||
|
### Step 1 — Simulate a 500 with a reverse proxy rule
|
||||||
|
|
||||||
|
Add a temporary nginx/Caddy rule to return 500 for `/api/sync/*`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/sync/ {
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use a local proxy like `mitmproxy` to intercept and rewrite responses.
|
||||||
|
|
||||||
|
### Step 2 — Trigger a sync
|
||||||
|
|
||||||
|
Open Settings and tap **Sync Now**.
|
||||||
|
|
||||||
|
**Pass criterion:** The Status field shows "Can't reach server — check your connection" (network error message), NOT "Login expired — tap Sync Now after re-logging in" (auth error message).
|
||||||
|
|
||||||
|
Remove the nginx rule after this test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regression Checklist
|
||||||
|
|
||||||
|
After running all tests above, confirm:
|
||||||
|
|
||||||
|
- [ ] No crash occurred during any test on either machine.
|
||||||
|
- [ ] Local save files (`stats.json`, `progress.json`, `achievements.json`) are present and valid JSON after all tests.
|
||||||
|
- [ ] The game launches and plays normally after all sync operations (sync is additive — never blocks gameplay).
|
||||||
|
- [ ] The Stats overlay shows correct numbers on both machines after a successful sync round-trip.
|
||||||
|
- [ ] An expired token is refreshed transparently without the user having to log in again.
|
||||||
|
- [ ] A doubly-expired token surfaces a clear error message to the user.
|
||||||
|
- [ ] Account deletion removes all server data; local data is preserved.
|
||||||
|
- [ ] HTTP 5xx and 429 responses show a network error, not an auth error.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Maintainer: funman300 <funman300@gmail.com>
|
||||||
|
|
||||||
|
pkgname=solitaire-quest-server
|
||||||
|
pkgver=0.1.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc='Self-hosted sync server for Solitaire Quest (stats, achievements, leaderboards)'
|
||||||
|
url='https://github.com/funman300/solitaire-quest'
|
||||||
|
license=('MIT')
|
||||||
|
arch=('x86_64')
|
||||||
|
makedepends=('cargo' 'rust')
|
||||||
|
depends=(
|
||||||
|
'gcc-libs'
|
||||||
|
'glibc'
|
||||||
|
)
|
||||||
|
backup=('etc/solitaire-quest-server/server.env')
|
||||||
|
|
||||||
|
# Build from the local workspace (two levels above this PKGBUILD).
|
||||||
|
_srcdir="$startdir/../.."
|
||||||
|
source=(
|
||||||
|
'solitaire-quest-server.service'
|
||||||
|
'server.env'
|
||||||
|
)
|
||||||
|
b2sums=('SKIP'
|
||||||
|
'SKIP')
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
cd "$_srcdir"
|
||||||
|
cargo fetch --locked --target "$(rustc -Vv | grep host | cut -d' ' -f2)"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
export CARGO_TARGET_DIR=target
|
||||||
|
cd "$_srcdir"
|
||||||
|
cargo build --frozen --release -p solitaire_server
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
cd "$_srcdir"
|
||||||
|
cargo test --frozen -p solitaire_server -p solitaire_sync
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$_srcdir"
|
||||||
|
|
||||||
|
# Binary
|
||||||
|
install -Dm0755 -t "$pkgdir/usr/bin/" "target/release/solitaire_server"
|
||||||
|
|
||||||
|
# systemd service
|
||||||
|
install -Dm0644 "$srcdir/solitaire-quest-server.service" \
|
||||||
|
"$pkgdir/usr/lib/systemd/system/solitaire-quest-server.service"
|
||||||
|
|
||||||
|
# Environment file (contains JWT_SECRET, DATABASE_URL, SERVER_PORT)
|
||||||
|
install -Dm0640 "$srcdir/server.env" \
|
||||||
|
"$pkgdir/etc/solitaire-quest-server/server.env"
|
||||||
|
|
||||||
|
# License and docs
|
||||||
|
install -Dm0644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
install -Dm0644 README_SERVER.md \
|
||||||
|
"$pkgdir/usr/share/doc/$pkgname/README_SERVER.md"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Solitaire Quest Server — environment configuration
|
||||||
|
# This file is installed to /etc/solitaire-quest-server/server.env (mode 0640).
|
||||||
|
# Edit these values before starting the service.
|
||||||
|
|
||||||
|
# Path to the SQLite database file.
|
||||||
|
# The directory must be writable by the solitaire-quest service user.
|
||||||
|
DATABASE_URL=sqlite:///var/lib/solitaire-quest-server/solitaire.db
|
||||||
|
|
||||||
|
# HS256 signing secret for JWT tokens.
|
||||||
|
# Generate a strong secret with: openssl rand -hex 32
|
||||||
|
# REQUIRED — server will refuse to start if unset.
|
||||||
|
JWT_SECRET=changeme_generate_with_openssl_rand_hex_32
|
||||||
|
|
||||||
|
# TCP port the server listens on.
|
||||||
|
SERVER_PORT=8080
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Solitaire Quest Sync Server
|
||||||
|
Documentation=https://github.com/funman300/solitaire-quest/blob/main/README_SERVER.md
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=solitaire-quest
|
||||||
|
Group=solitaire-quest
|
||||||
|
EnvironmentFile=/etc/solitaire-quest-server/server.env
|
||||||
|
ExecStart=/usr/bin/solitaire_server
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
# Harden the service
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/solitaire-quest-server
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Maintainer: funman300 <funman300@gmail.com>
|
||||||
|
|
||||||
|
pkgname=solitaire-quest
|
||||||
|
pkgver=0.1.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc='Cross-platform Klondike Solitaire with progression, achievements, and optional sync'
|
||||||
|
url='https://github.com/funman300/solitaire-quest'
|
||||||
|
license=('MIT')
|
||||||
|
arch=('x86_64')
|
||||||
|
makedepends=('cargo' 'rust')
|
||||||
|
depends=(
|
||||||
|
'gcc-libs'
|
||||||
|
'glibc'
|
||||||
|
'alsa-lib'
|
||||||
|
'libxkbcommon'
|
||||||
|
'systemd-libs' # libudev.so — required by Bevy input
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build from the local workspace (two levels above this PKGBUILD).
|
||||||
|
_srcdir="$startdir/../.."
|
||||||
|
source=()
|
||||||
|
b2sums=()
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
cd "$_srcdir"
|
||||||
|
cargo fetch --locked --target "$(rustc -Vv | grep host | cut -d' ' -f2)"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
export CARGO_TARGET_DIR=target
|
||||||
|
cd "$_srcdir"
|
||||||
|
cargo build --frozen --release -p solitaire_app
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
cd "$_srcdir"
|
||||||
|
# Only test non-Bevy crates — Bevy integration tests require a GPU/display.
|
||||||
|
cargo test --frozen -p solitaire_core -p solitaire_sync
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$_srcdir"
|
||||||
|
install -Dm0755 -t "$pkgdir/usr/bin/" "target/release/solitaire_app"
|
||||||
|
install -Dm0644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solitaire_app"
|
name = "solitaire_app"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
@@ -11,3 +12,4 @@ path = "src/main.rs"
|
|||||||
bevy = { workspace = true }
|
bevy = { workspace = true }
|
||||||
solitaire_engine = { workspace = true }
|
solitaire_engine = { workspace = true }
|
||||||
solitaire_data = { workspace = true }
|
solitaire_data = { workspace = true }
|
||||||
|
keyring = { workspace = true }
|
||||||
|
|||||||
@@ -1,14 +1,37 @@
|
|||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::{MonitorSelection, WindowPosition};
|
||||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin,
|
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
|
||||||
ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, GamePlugin,
|
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
|
||||||
HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin,
|
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||||
PausePlugin, ProfilePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin,
|
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
|
||||||
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiModalPlugin, WeeklyGoalsPlugin,
|
||||||
|
WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// Install a panic hook that writes a crash log next to the save files
|
||||||
|
// before re-running the default hook (so stderr still gets the message
|
||||||
|
// and any debugger attached still sees the panic).
|
||||||
|
install_crash_log_hook();
|
||||||
|
|
||||||
|
// Initialise the platform keyring store before any token operations.
|
||||||
|
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||||
|
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||||
|
// If the platform has no OS keyring (e.g. a headless CI box), keyring
|
||||||
|
// operations will fail gracefully with TokenError::KeychainUnavailable.
|
||||||
|
if let Err(e) = keyring::use_native_store(true) {
|
||||||
|
eprintln!(
|
||||||
|
"warn: could not initialise OS keyring ({e}); \
|
||||||
|
server sync login will be unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Load settings before building the app so we can construct the right
|
// Load settings before building the app so we can construct the right
|
||||||
// sync provider. Falls back to defaults if no settings file exists yet.
|
// sync provider. Falls back to defaults if no settings file exists yet.
|
||||||
let settings: Settings = settings_file_path()
|
let settings: Settings = settings_file_path()
|
||||||
@@ -18,22 +41,44 @@ fn main() {
|
|||||||
|
|
||||||
App::new()
|
App::new()
|
||||||
.add_plugins(
|
.add_plugins(
|
||||||
DefaultPlugins.set(WindowPlugin {
|
DefaultPlugins
|
||||||
primary_window: Some(Window {
|
.set(WindowPlugin {
|
||||||
title: "Solitaire Quest".into(),
|
primary_window: Some(Window {
|
||||||
resolution: (1280.0, 800.0).into(),
|
title: "Solitaire Quest".into(),
|
||||||
|
// X11/Wayland WM_CLASS so taskbar managers group
|
||||||
|
// multiple windows of this app correctly.
|
||||||
|
name: Some("solitaire-quest".into()),
|
||||||
|
resolution: (1280u32, 800u32).into(),
|
||||||
|
position: WindowPosition::Centered(MonitorSelection::Primary),
|
||||||
|
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||||
|
min_width: 800.0,
|
||||||
|
min_height: 600.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
..default()
|
||||||
|
}),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
// The `assets/` directory lives at the workspace root, but
|
||||||
|
// Bevy resolves `AssetPlugin::file_path` relative to the
|
||||||
|
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
|
||||||
|
// Point one level up so `cargo run -p solitaire_app` finds
|
||||||
|
// card faces, backs, backgrounds, and the UI font.
|
||||||
|
.set(bevy::asset::AssetPlugin {
|
||||||
|
file_path: "../assets".to_string(),
|
||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
..default()
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
.add_plugins(FontPlugin)
|
||||||
.add_plugins(GamePlugin)
|
.add_plugins(GamePlugin)
|
||||||
.add_plugins(TablePlugin)
|
.add_plugins(TablePlugin)
|
||||||
.add_plugins(CardPlugin)
|
.add_plugins(CardPlugin)
|
||||||
.add_plugins(CursorPlugin)
|
.add_plugins(CursorPlugin)
|
||||||
.add_plugins(InputPlugin)
|
.add_plugins(InputPlugin)
|
||||||
|
.add_plugins(SelectionPlugin)
|
||||||
.add_plugins(AnimationPlugin)
|
.add_plugins(AnimationPlugin)
|
||||||
.add_plugins(FeedbackAnimPlugin)
|
.add_plugins(FeedbackAnimPlugin)
|
||||||
|
.add_plugins(CardAnimationPlugin)
|
||||||
.add_plugins(AutoCompletePlugin)
|
.add_plugins(AutoCompletePlugin)
|
||||||
.add_plugins(StatsPlugin::default())
|
.add_plugins(StatsPlugin::default())
|
||||||
.add_plugins(ProgressPlugin::default())
|
.add_plugins(ProgressPlugin::default())
|
||||||
@@ -52,5 +97,37 @@ fn main() {
|
|||||||
.add_plugins(OnboardingPlugin)
|
.add_plugins(OnboardingPlugin)
|
||||||
.add_plugins(SyncPlugin::new(sync_provider))
|
.add_plugins(SyncPlugin::new(sync_provider))
|
||||||
.add_plugins(LeaderboardPlugin)
|
.add_plugins(LeaderboardPlugin)
|
||||||
|
.add_plugins(WinSummaryPlugin)
|
||||||
|
.add_plugins(UiModalPlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wraps the default panic hook with one that also appends a crash log
|
||||||
|
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||||
|
/// still runs afterwards, so stderr output and debugger integration are
|
||||||
|
/// unchanged. If the data directory is unavailable, the wrapper silently
|
||||||
|
/// falls through — the default hook handles output either way.
|
||||||
|
fn install_crash_log_hook() {
|
||||||
|
let crash_log_path = settings_file_path().and_then(|p| {
|
||||||
|
p.parent()
|
||||||
|
.map(|parent| parent.join("crash.log"))
|
||||||
|
});
|
||||||
|
let default_hook = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
if let Some(path) = crash_log_path.as_ref()
|
||||||
|
&& let Ok(mut file) = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(path)
|
||||||
|
{
|
||||||
|
// Plain unix-seconds timestamp keeps the format trivially
|
||||||
|
// parseable and avoids pulling in chrono just for this.
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
|
||||||
|
}
|
||||||
|
default_hook(info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solitaire_assetgen"
|
name = "solitaire_assetgen"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
# Dev-only utility: synthesizes placeholder SFX WAV files into `assets/audio/`.
|
# Dev-only utility: synthesizes placeholder SFX WAV files into `assets/audio/`
|
||||||
|
# and placeholder PNG images into `assets/cards/` and `assets/backgrounds/`.
|
||||||
# Not depended on by any other workspace crate.
|
# Not depended on by any other workspace crate.
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
png = "0.17"
|
||||||
|
ab_glyph = "0.2"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "gen_sfx"
|
name = "gen_sfx"
|
||||||
path = "src/bin/gen_sfx.rs"
|
path = "src/bin/gen_sfx.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "gen_art"
|
||||||
|
path = "src/bin/gen_art.rs"
|
||||||
|
|||||||
@@ -0,0 +1,713 @@
|
|||||||
|
//! Generates PNG assets for Solitaire Quest.
|
||||||
|
//!
|
||||||
|
//! Produces:
|
||||||
|
//! - 52 card face PNGs (120×168) — one per card, with rank, suit symbol, and
|
||||||
|
//! pip or face-letter layout baked in.
|
||||||
|
//! - 5 card back PNGs (120×168) with distinctive coloured patterns.
|
||||||
|
//! - 5 background PNGs (120×168) with textured felt/wood patterns.
|
||||||
|
//!
|
||||||
|
//! Run with: `cargo run -p solitaire_assetgen --bin gen_art`
|
||||||
|
|
||||||
|
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::BufWriter;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Card dimensions and palette
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const W: u32 = 120;
|
||||||
|
const H: u32 = 168;
|
||||||
|
|
||||||
|
const BG: [u8; 4] = [0xFE, 0xFE, 0xF2, 0xFF];
|
||||||
|
const BORDER: [u8; 4] = [0x99, 0x99, 0x99, 0xFF];
|
||||||
|
const RED: [u8; 4] = [0xCC, 0x11, 0x11, 0xFF];
|
||||||
|
const DARK: [u8; 4] = [0x11, 0x11, 0x11, 0xFF];
|
||||||
|
|
||||||
|
fn suit_color(suit: u8) -> [u8; 4] {
|
||||||
|
if suit == 1 || suit == 2 { RED } else { DARK }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rank_str(rank: u8) -> &'static str {
|
||||||
|
["A","2","3","4","5","6","7","8","9","10","J","Q","K"][rank as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pixel canvas (120×168 RGBA)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Canvas {
|
||||||
|
data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Canvas {
|
||||||
|
fn new() -> Self {
|
||||||
|
let mut data = vec![0u8; (W * H * 4) as usize];
|
||||||
|
for i in 0..(W * H) as usize {
|
||||||
|
data[i * 4..i * 4 + 4].copy_from_slice(&BG);
|
||||||
|
}
|
||||||
|
Self { data }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill every pixel with a solid colour, erasing whatever was there before.
|
||||||
|
fn fill_solid(&mut self, c: [u8; 4]) {
|
||||||
|
for i in 0..(W * H) as usize {
|
||||||
|
self.data[i * 4..i * 4 + 4].copy_from_slice(&c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a 1-pixel-wide axis-aligned horizontal line.
|
||||||
|
fn hline(&mut self, y: i32, x0: i32, x1: i32, c: [u8; 4]) {
|
||||||
|
for x in x0..=x1 {
|
||||||
|
self.set(x, y, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a 1-pixel-wide axis-aligned vertical line.
|
||||||
|
fn vline(&mut self, x: i32, y0: i32, y1: i32, c: [u8; 4]) {
|
||||||
|
for y in y0..=y1 {
|
||||||
|
self.set(x, y, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a filled diamond outline (ring) of given half-extents and line thickness.
|
||||||
|
fn diamond_ring(&mut self, cx: f32, cy: f32, rx: f32, ry: f32, thickness: f32, c: [u8; 4]) {
|
||||||
|
for y in (cy - ry - 2.0) as i32..=(cy + ry + 2.0) as i32 {
|
||||||
|
for x in (cx - rx - 2.0) as i32..=(cx + rx + 2.0) as i32 {
|
||||||
|
let nx = (x as f32 - cx).abs() / rx;
|
||||||
|
let ny = (y as f32 - cy).abs() / ry;
|
||||||
|
let dist = nx + ny;
|
||||||
|
if dist <= 1.0 && dist >= 1.0 - (thickness / rx.min(ry)) {
|
||||||
|
self.set(x, y, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(&mut self, x: i32, y: i32, c: [u8; 4]) {
|
||||||
|
if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 { return; }
|
||||||
|
let i = (y as u32 * W + x as u32) as usize * 4;
|
||||||
|
let a = c[3] as f32 / 255.0;
|
||||||
|
if a >= 0.99 {
|
||||||
|
self.data[i..i + 4].copy_from_slice(&c);
|
||||||
|
} else if a > 0.01 {
|
||||||
|
self.data[i] = (self.data[i] as f32 * (1.0 - a) + c[0] as f32 * a) as u8;
|
||||||
|
self.data[i + 1] = (self.data[i + 1] as f32 * (1.0 - a) + c[1] as f32 * a) as u8;
|
||||||
|
self.data[i + 2] = (self.data[i + 2] as f32 * (1.0 - a) + c[2] as f32 * a) as u8;
|
||||||
|
self.data[i + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn circle(&mut self, cx: f32, cy: f32, r: f32, c: [u8; 4]) {
|
||||||
|
for y in (cy - r - 1.0) as i32..=(cy + r + 1.0) as i32 {
|
||||||
|
for x in (cx - r - 1.0) as i32..=(cx + r + 1.0) as i32 {
|
||||||
|
if (x as f32 - cx).powi(2) + (y as f32 - cy).powi(2) <= r * r {
|
||||||
|
self.set(x, y, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, c: [u8; 4]) {
|
||||||
|
for ry in y..y + h {
|
||||||
|
for rx in x..x + w {
|
||||||
|
self.set(rx, ry, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn triangle(&mut self, pts: [(f32, f32); 3], c: [u8; 4]) {
|
||||||
|
let min_x = pts.iter().map(|p| p.0).fold(f32::INFINITY, f32::min) as i32;
|
||||||
|
let max_x = pts.iter().map(|p| p.0).fold(f32::NEG_INFINITY, f32::max) as i32;
|
||||||
|
let min_y = pts.iter().map(|p| p.1).fold(f32::INFINITY, f32::min) as i32;
|
||||||
|
let max_y = pts.iter().map(|p| p.1).fold(f32::NEG_INFINITY, f32::max) as i32;
|
||||||
|
let (ax, ay) = pts[0];
|
||||||
|
let (bx, by) = pts[1];
|
||||||
|
let (ex, ey) = pts[2];
|
||||||
|
for y in min_y..=max_y {
|
||||||
|
for x in min_x..=max_x {
|
||||||
|
let px = x as f32 + 0.5;
|
||||||
|
let py = y as f32 + 0.5;
|
||||||
|
let d0 = (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
||||||
|
let d1 = (ex - bx) * (py - by) - (ey - by) * (px - bx);
|
||||||
|
let d2 = (ax - ex) * (py - ey) - (ay - ey) * (px - ex);
|
||||||
|
let neg = d0 < 0.0 || d1 < 0.0 || d2 < 0.0;
|
||||||
|
let pos = d0 > 0.0 || d1 > 0.0 || d2 > 0.0;
|
||||||
|
if !(neg && pos) {
|
||||||
|
self.set(x, y, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diamond(&mut self, cx: f32, cy: f32, rx: f32, ry: f32, c: [u8; 4]) {
|
||||||
|
for y in (cy - ry - 1.0) as i32..=(cy + ry + 1.0) as i32 {
|
||||||
|
for x in (cx - rx - 1.0) as i32..=(cx + rx + 1.0) as i32 {
|
||||||
|
let nx = (x as f32 - cx).abs() / rx;
|
||||||
|
let ny = (y as f32 - cy).abs() / ry;
|
||||||
|
if nx + ny <= 1.0 {
|
||||||
|
self.set(x, y, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Suit symbol drawing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn draw_suit(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, suit: u8, c: [u8; 4]) {
|
||||||
|
match suit {
|
||||||
|
0 => draw_club(cv, cx, cy, sz, c),
|
||||||
|
1 => draw_diamond_sym(cv, cx, cy, sz, c),
|
||||||
|
2 => draw_heart(cv, cx, cy, sz, c),
|
||||||
|
_ => draw_spade(cv, cx, cy, sz, c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_heart(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
|
||||||
|
let r = sz * 0.33;
|
||||||
|
let oy = cy - sz * 0.04;
|
||||||
|
cv.circle(cx - sz * 0.22, oy, r, c);
|
||||||
|
cv.circle(cx + sz * 0.22, oy, r, c);
|
||||||
|
cv.triangle([
|
||||||
|
(cx - sz * 0.52, oy + r * 0.4),
|
||||||
|
(cx + sz * 0.52, oy + r * 0.4),
|
||||||
|
(cx, cy + sz * 0.52),
|
||||||
|
], c);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_spade(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
|
||||||
|
cv.triangle([
|
||||||
|
(cx, cy - sz * 0.52),
|
||||||
|
(cx - sz * 0.52, cy + sz * 0.1),
|
||||||
|
(cx + sz * 0.52, cy + sz * 0.1),
|
||||||
|
], c);
|
||||||
|
cv.circle(cx - sz * 0.22, cy + sz * 0.06, sz * 0.3, c);
|
||||||
|
cv.circle(cx + sz * 0.22, cy + sz * 0.06, sz * 0.3, c);
|
||||||
|
// stem + base
|
||||||
|
cv.triangle([
|
||||||
|
(cx, cy + sz * 0.12),
|
||||||
|
(cx - sz * 0.13, cy + sz * 0.5),
|
||||||
|
(cx + sz * 0.13, cy + sz * 0.5),
|
||||||
|
], c);
|
||||||
|
cv.fill_rect(
|
||||||
|
(cx - sz * 0.26) as i32,
|
||||||
|
(cy + sz * 0.43) as i32,
|
||||||
|
(sz * 0.52) as i32,
|
||||||
|
(sz * 0.1) as i32,
|
||||||
|
c,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_diamond_sym(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
|
||||||
|
cv.diamond(cx, cy, sz * 0.44, sz * 0.57, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_club(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
|
||||||
|
let r = sz * 0.29;
|
||||||
|
cv.circle(cx, cy - sz * 0.22, r, c);
|
||||||
|
cv.circle(cx - sz * 0.28, cy + sz * 0.1, r, c);
|
||||||
|
cv.circle(cx + sz * 0.28, cy + sz * 0.1, r, c);
|
||||||
|
cv.fill_rect(
|
||||||
|
(cx - sz * 0.08) as i32,
|
||||||
|
(cy + sz * 0.22) as i32,
|
||||||
|
(sz * 0.16) as i32 + 1,
|
||||||
|
(sz * 0.27) as i32,
|
||||||
|
c,
|
||||||
|
);
|
||||||
|
cv.fill_rect(
|
||||||
|
(cx - sz * 0.26) as i32,
|
||||||
|
(cy + sz * 0.45) as i32,
|
||||||
|
(sz * 0.52) as i32,
|
||||||
|
(sz * 0.09) as i32,
|
||||||
|
c,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Text rendering via ab_glyph
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn draw_text(cv: &mut Canvas, font: &FontRef<'_>, text: &str, px: f32, left: f32, top: f32, c: [u8; 4]) {
|
||||||
|
let scale = PxScale::from(px);
|
||||||
|
let baseline = top + font.as_scaled(scale).ascent();
|
||||||
|
let mut x = left;
|
||||||
|
for ch in text.chars() {
|
||||||
|
let gid = font.glyph_id(ch);
|
||||||
|
let glyph = gid.with_scale_and_position(scale, ab_glyph::point(x, baseline));
|
||||||
|
let adv = font.as_scaled(scale).h_advance(gid);
|
||||||
|
if let Some(outlined) = font.outline_glyph(glyph) {
|
||||||
|
let bounds = outlined.px_bounds();
|
||||||
|
outlined.draw(|gx, gy, cov| {
|
||||||
|
if cov > 0.02 {
|
||||||
|
let alpha = (cov * c[3] as f32) as u8;
|
||||||
|
cv.set(
|
||||||
|
(bounds.min.x + gx as f32) as i32,
|
||||||
|
(bounds.min.y + gy as f32) as i32,
|
||||||
|
[c[0], c[1], c[2], alpha],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
x += adv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_w(font: &FontRef<'_>, text: &str, px: f32) -> f32 {
|
||||||
|
let scale = PxScale::from(px);
|
||||||
|
let sf = font.as_scaled(scale);
|
||||||
|
text.chars().map(|c| sf.h_advance(font.glyph_id(c))).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_h(font: &FontRef<'_>, px: f32) -> f32 {
|
||||||
|
let scale = PxScale::from(px);
|
||||||
|
let sf = font.as_scaled(scale);
|
||||||
|
sf.ascent() - sf.descent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pip layout (rank 0=Ace … 9=Ten; rank 10-12 are face cards)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn pip_positions(rank: u8) -> &'static [(f32, f32)] {
|
||||||
|
match rank {
|
||||||
|
0 => &[(0.5, 0.5)],
|
||||||
|
1 => &[(0.5, 0.2), (0.5, 0.8)],
|
||||||
|
2 => &[(0.5, 0.12), (0.5, 0.5), (0.5, 0.88)],
|
||||||
|
3 => &[(0.25, 0.18), (0.75, 0.18), (0.25, 0.82), (0.75, 0.82)],
|
||||||
|
4 => &[(0.25, 0.18), (0.75, 0.18), (0.5, 0.5), (0.25, 0.82), (0.75, 0.82)],
|
||||||
|
5 => &[(0.25, 0.12), (0.75, 0.12), (0.25, 0.5), (0.75, 0.5), (0.25, 0.88), (0.75, 0.88)],
|
||||||
|
6 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.31), (0.25, 0.5), (0.75, 0.5), (0.25, 0.9), (0.75, 0.9)],
|
||||||
|
7 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.28), (0.25, 0.48), (0.75, 0.48), (0.5, 0.70), (0.25, 0.9), (0.75, 0.9)],
|
||||||
|
8 => &[(0.25, 0.1), (0.75, 0.1), (0.25, 0.35), (0.75, 0.35), (0.5, 0.5), (0.25, 0.65), (0.75, 0.65), (0.25, 0.9), (0.75, 0.9)],
|
||||||
|
9 => &[(0.25, 0.09), (0.75, 0.09), (0.5, 0.27), (0.25, 0.44), (0.75, 0.44), (0.25, 0.56), (0.75, 0.56), (0.5, 0.73), (0.25, 0.91), (0.75, 0.91)],
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pip area within the card (avoids the corner labels).
|
||||||
|
const PIP_X: f32 = 22.0;
|
||||||
|
const PIP_Y: f32 = 46.0;
|
||||||
|
const PIP_W: f32 = 76.0;
|
||||||
|
const PIP_H: f32 = 80.0;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Card face generation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn make_card_face(font: &FontRef<'_>, rank: u8, suit: u8) -> Canvas {
|
||||||
|
let mut cv = Canvas::new();
|
||||||
|
let sc = suit_color(suit);
|
||||||
|
|
||||||
|
// Border (2 px)
|
||||||
|
for x in 0..W as i32 {
|
||||||
|
cv.set(x, 0, BORDER);
|
||||||
|
cv.set(x, 1, BORDER);
|
||||||
|
cv.set(x, H as i32 - 2, BORDER);
|
||||||
|
cv.set(x, H as i32 - 1, BORDER);
|
||||||
|
}
|
||||||
|
for y in 0..H as i32 {
|
||||||
|
cv.set(0, y, BORDER);
|
||||||
|
cv.set(1, y, BORDER);
|
||||||
|
cv.set(W as i32 - 2, y, BORDER);
|
||||||
|
cv.set(W as i32 - 1, y, BORDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rank_s = rank_str(rank);
|
||||||
|
let rank_px = 18.0f32;
|
||||||
|
let suit_sz = 11.0f32;
|
||||||
|
let rh = text_h(font, rank_px);
|
||||||
|
let rw = text_w(font, rank_s, rank_px);
|
||||||
|
let corner_h = rh + 2.0 + suit_sz * 1.5;
|
||||||
|
|
||||||
|
// Top-left corner
|
||||||
|
let tl_x = 6.0f32;
|
||||||
|
let tl_y = 5.0f32;
|
||||||
|
draw_text(&mut cv, font, rank_s, rank_px, tl_x, tl_y, sc);
|
||||||
|
draw_suit(&mut cv, tl_x + suit_sz * 0.62, tl_y + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc);
|
||||||
|
|
||||||
|
// Bottom-right corner (right-aligned rank, suit above it)
|
||||||
|
let br_rx = W as f32 - 6.0;
|
||||||
|
let br_by = H as f32 - 5.0;
|
||||||
|
let br_ty = br_by - corner_h;
|
||||||
|
draw_text(&mut cv, font, rank_s, rank_px, br_rx - rw, br_ty, sc);
|
||||||
|
draw_suit(&mut cv, br_rx - suit_sz * 0.62, br_ty + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc);
|
||||||
|
|
||||||
|
// Center content
|
||||||
|
if rank >= 10 {
|
||||||
|
// Face cards: large rank letter + suit symbol below
|
||||||
|
let big_px = 52.0f32;
|
||||||
|
let big_w = text_w(font, rank_s, big_px);
|
||||||
|
let big_h = text_h(font, big_px);
|
||||||
|
let big_x = (W as f32 - big_w) / 2.0;
|
||||||
|
let big_y = H as f32 * 0.28;
|
||||||
|
draw_text(&mut cv, font, rank_s, big_px, big_x, big_y, sc);
|
||||||
|
let sym_sz = 22.0f32;
|
||||||
|
draw_suit(&mut cv, W as f32 * 0.5, big_y + big_h + sym_sz * 1.0, sym_sz, suit, sc);
|
||||||
|
} else {
|
||||||
|
// Pip cards
|
||||||
|
let pip_sz = if rank == 0 {
|
||||||
|
24.0f32 // Ace: large single pip
|
||||||
|
} else if rank <= 5 {
|
||||||
|
14.0
|
||||||
|
} else {
|
||||||
|
12.0
|
||||||
|
};
|
||||||
|
for &(nx, ny) in pip_positions(rank) {
|
||||||
|
let cx = PIP_X + nx * PIP_W;
|
||||||
|
let cy = PIP_Y + ny * PIP_H;
|
||||||
|
draw_suit(&mut cv, cx, cy, pip_sz, suit, sc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cv
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PNG encoding helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn save_card_png(path: &Path, cv: &Canvas) {
|
||||||
|
save_png_wh(path, &cv.data, W, H);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_png_wh(path: &Path, data: &[u8], w: u32, h: u32) {
|
||||||
|
let file = File::create(path)
|
||||||
|
.unwrap_or_else(|e| panic!("cannot create {}: {e}", path.display()));
|
||||||
|
let mut bw = BufWriter::new(file);
|
||||||
|
let mut enc = png::Encoder::new(&mut bw, w, h);
|
||||||
|
enc.set_color(png::ColorType::Rgba);
|
||||||
|
enc.set_depth(png::BitDepth::Eight);
|
||||||
|
let mut writer = enc.write_header()
|
||||||
|
.unwrap_or_else(|e| panic!("png header error for {}: {e}", path.display()));
|
||||||
|
writer.write_image_data(data)
|
||||||
|
.unwrap_or_else(|e| panic!("png data error for {}: {e}", path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Card backs (120×168 with distinctive patterns)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// back_0 – blue: repeating diamond grid pattern
|
||||||
|
fn make_back_0() -> Canvas {
|
||||||
|
const BASE: [u8; 4] = [0x26, 0x4D, 0x8C, 0xFF];
|
||||||
|
const LIGHT: [u8; 4] = [0x5A, 0x80, 0xBF, 0xFF];
|
||||||
|
const HIGHLIGHT: [u8; 4] = [0xA0, 0xC0, 0xFF, 0xB0];
|
||||||
|
let mut cv = Canvas::new();
|
||||||
|
cv.fill_solid(BASE);
|
||||||
|
|
||||||
|
// 2-pixel border
|
||||||
|
let bw = 4i32;
|
||||||
|
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, LIGHT); cv.set(x, H as i32 - 1 - t, LIGHT); } }
|
||||||
|
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, LIGHT); cv.set(W as i32 - 1 - t, y, LIGHT); } }
|
||||||
|
|
||||||
|
// Diamond grid: row/col spacing
|
||||||
|
let gx = 18.0f32;
|
||||||
|
let gy = 18.0f32;
|
||||||
|
let rx = gx * 0.45;
|
||||||
|
let ry = gy * 0.45;
|
||||||
|
let mut row = 0;
|
||||||
|
let mut cy = 6.0f32 + gy * 0.5;
|
||||||
|
while cy < H as f32 - 4.0 {
|
||||||
|
let offset = if row % 2 == 0 { 0.0 } else { gx * 0.5 };
|
||||||
|
let mut cx = 6.0f32 + gx * 0.5 + offset;
|
||||||
|
while cx < W as f32 - 4.0 {
|
||||||
|
cv.diamond_ring(cx, cy, rx, ry, 1.5, LIGHT);
|
||||||
|
// tiny highlight dot at centre of each diamond
|
||||||
|
cv.circle(cx, cy, 1.5, HIGHLIGHT);
|
||||||
|
cx += gx;
|
||||||
|
}
|
||||||
|
cy += gy;
|
||||||
|
row += 1;
|
||||||
|
}
|
||||||
|
cv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// back_1 – red: diagonal crosshatch
|
||||||
|
fn make_back_1() -> Canvas {
|
||||||
|
const BASE: [u8; 4] = [0x8C, 0x1A, 0x1A, 0xFF];
|
||||||
|
const LINE: [u8; 4] = [0xCC, 0x55, 0x55, 0xC0];
|
||||||
|
const BORDER: [u8; 4] = [0xDD, 0x88, 0x88, 0xFF];
|
||||||
|
let mut cv = Canvas::new();
|
||||||
|
cv.fill_solid(BASE);
|
||||||
|
|
||||||
|
// Diagonal lines every 12 px (NW→SE)
|
||||||
|
let spacing = 12i32;
|
||||||
|
for k in (-(H as i32)..W as i32).step_by(spacing as usize) {
|
||||||
|
for t in 0..W as i32 {
|
||||||
|
let y = t + k;
|
||||||
|
cv.set(t, y, LINE);
|
||||||
|
// 1 px thick — also set neighbour for slightly bolder line
|
||||||
|
cv.set(t, y + 1, LINE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Diagonal lines (NE→SW)
|
||||||
|
for k in (0..(W as i32 + H as i32)).step_by(spacing as usize) {
|
||||||
|
for t in 0..W as i32 {
|
||||||
|
let y = k - t;
|
||||||
|
cv.set(t, y, LINE);
|
||||||
|
cv.set(t, y + 1, LINE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4-pixel border
|
||||||
|
let bw = 4i32;
|
||||||
|
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
|
||||||
|
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
|
||||||
|
cv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// back_2 – green: evenly spaced small circle array
|
||||||
|
fn make_back_2() -> Canvas {
|
||||||
|
const BASE: [u8; 4] = [0x0D, 0x66, 0x1A, 0xFF];
|
||||||
|
const DOT: [u8; 4] = [0x40, 0xCC, 0x55, 0xE0];
|
||||||
|
const BORDER: [u8; 4] = [0x55, 0xDD, 0x66, 0xFF];
|
||||||
|
let mut cv = Canvas::new();
|
||||||
|
cv.fill_solid(BASE);
|
||||||
|
|
||||||
|
// 4-pixel border
|
||||||
|
let bw = 4i32;
|
||||||
|
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
|
||||||
|
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
|
||||||
|
|
||||||
|
// Circle array (staggered rows)
|
||||||
|
let gx = 16.0f32;
|
||||||
|
let gy = 16.0f32;
|
||||||
|
let r = 3.5f32;
|
||||||
|
let mut row = 0;
|
||||||
|
let mut cy = 8.0f32 + gy * 0.5;
|
||||||
|
while cy < H as f32 - 6.0 {
|
||||||
|
let offset = if row % 2 == 0 { 0.0 } else { gx * 0.5 };
|
||||||
|
let mut cx = 8.0f32 + gx * 0.5 + offset;
|
||||||
|
while cx < W as f32 - 6.0 {
|
||||||
|
cv.circle(cx, cy, r, DOT);
|
||||||
|
cx += gx;
|
||||||
|
}
|
||||||
|
cy += gy;
|
||||||
|
row += 1;
|
||||||
|
}
|
||||||
|
cv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// back_3 – purple: concentric diamond outlines
|
||||||
|
fn make_back_3() -> Canvas {
|
||||||
|
const BASE: [u8; 4] = [0x59, 0x14, 0x85, 0xFF];
|
||||||
|
const RING: [u8; 4] = [0xA0, 0x60, 0xDD, 0xD0];
|
||||||
|
const BORDER: [u8; 4] = [0xBB, 0x77, 0xFF, 0xFF];
|
||||||
|
let mut cv = Canvas::new();
|
||||||
|
cv.fill_solid(BASE);
|
||||||
|
|
||||||
|
// Concentric diamonds from centre
|
||||||
|
let cx = W as f32 * 0.5;
|
||||||
|
let cy = H as f32 * 0.5;
|
||||||
|
let mut rx = 8.0f32;
|
||||||
|
let step = 12.0f32;
|
||||||
|
while rx < (W as f32).max(H as f32) {
|
||||||
|
let ry = rx * (H as f32 / W as f32);
|
||||||
|
cv.diamond_ring(cx, cy, rx, ry, 1.5, RING);
|
||||||
|
rx += step;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4-pixel border
|
||||||
|
let bw = 4i32;
|
||||||
|
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
|
||||||
|
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
|
||||||
|
cv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// back_4 – teal: horizontal stripes with thin decorative lines
|
||||||
|
fn make_back_4() -> Canvas {
|
||||||
|
const BASE: [u8; 4] = [0x0D, 0x66, 0x6B, 0xFF];
|
||||||
|
const STRIPE: [u8; 4] = [0x1A, 0x99, 0xA0, 0x90];
|
||||||
|
const DECO: [u8; 4] = [0x55, 0xCC, 0xD4, 0xA0];
|
||||||
|
const BORDER: [u8; 4] = [0x44, 0xBB, 0xC4, 0xFF];
|
||||||
|
let mut cv = Canvas::new();
|
||||||
|
cv.fill_solid(BASE);
|
||||||
|
|
||||||
|
// Horizontal stripes every 10 px (2 px wide)
|
||||||
|
let mut y = 6i32;
|
||||||
|
while y < H as i32 - 4 {
|
||||||
|
cv.hline(y, 5, W as i32 - 6, STRIPE);
|
||||||
|
cv.hline(y + 1, 5, W as i32 - 6, STRIPE);
|
||||||
|
y += 10;
|
||||||
|
}
|
||||||
|
// Thin decorative horizontal lines between stripes
|
||||||
|
let mut y = 10i32;
|
||||||
|
while y < H as i32 - 4 {
|
||||||
|
cv.hline(y, 14, W as i32 - 15, DECO);
|
||||||
|
y += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4-pixel border
|
||||||
|
let bw = 4i32;
|
||||||
|
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
|
||||||
|
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
|
||||||
|
cv
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backgrounds (120×168 textured patterns)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// bg_0 – dark green felt: subtle grid of faint lines giving a woven texture
|
||||||
|
fn make_bg_0() -> Canvas {
|
||||||
|
const BASE: [u8; 4] = [0x1A, 0x4D, 0x1A, 0xFF];
|
||||||
|
const WARP: [u8; 4] = [0x22, 0x60, 0x22, 0x90]; // slightly lighter horizontal threads
|
||||||
|
const WEFT: [u8; 4] = [0x15, 0x40, 0x15, 0x90]; // slightly darker vertical threads
|
||||||
|
let mut cv = Canvas::new();
|
||||||
|
cv.fill_solid(BASE);
|
||||||
|
// Horizontal warp lines every 4 px
|
||||||
|
for y in (0..H as i32).step_by(4) {
|
||||||
|
cv.hline(y, 0, W as i32 - 1, WARP);
|
||||||
|
}
|
||||||
|
// Vertical weft lines every 4 px
|
||||||
|
for x in (0..W as i32).step_by(4) {
|
||||||
|
cv.vline(x, 0, H as i32 - 1, WEFT);
|
||||||
|
}
|
||||||
|
cv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// bg_1 – wood brown: horizontal planks with grain lines
|
||||||
|
fn make_bg_1() -> Canvas {
|
||||||
|
const BASE: [u8; 4] = [0x40, 0x2D, 0x1A, 0xFF];
|
||||||
|
const PLANK_EDGE: [u8; 4] = [0x28, 0x1A, 0x0A, 0xFF]; // dark plank separator
|
||||||
|
const GRAIN: [u8; 4] = [0x55, 0x3D, 0x28, 0xA0]; // lighter grain streak
|
||||||
|
let mut cv = Canvas::new();
|
||||||
|
cv.fill_solid(BASE);
|
||||||
|
// Horizontal plank edges every 24 px
|
||||||
|
for y in (0..H as i32).step_by(24) {
|
||||||
|
cv.hline(y, 0, W as i32 - 1, PLANK_EDGE);
|
||||||
|
cv.hline(y + 1, 0, W as i32 - 1, PLANK_EDGE);
|
||||||
|
}
|
||||||
|
// Grain lines within each plank (every 3 px between plank edges)
|
||||||
|
for y in (0..H as i32).step_by(3) {
|
||||||
|
// Skip the plank edge rows
|
||||||
|
if y % 24 < 2 { continue; }
|
||||||
|
cv.hline(y, 2, W as i32 - 3, GRAIN);
|
||||||
|
}
|
||||||
|
cv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// bg_2 – navy: star-field dots scattered in a regular grid
|
||||||
|
fn make_bg_2() -> Canvas {
|
||||||
|
const BASE: [u8; 4] = [0x0D, 0x14, 0x38, 0xFF];
|
||||||
|
const STAR_A: [u8; 4] = [0xCC, 0xDD, 0xFF, 0xD0];
|
||||||
|
const STAR_B: [u8; 4] = [0x80, 0xA0, 0xDD, 0x80];
|
||||||
|
let mut cv = Canvas::new();
|
||||||
|
cv.fill_solid(BASE);
|
||||||
|
// Bright small stars on a staggered grid
|
||||||
|
let gx = 14.0f32;
|
||||||
|
let gy = 16.0f32;
|
||||||
|
let mut row = 0u32;
|
||||||
|
let mut cy = gy * 0.5;
|
||||||
|
while cy < H as f32 {
|
||||||
|
let offset = if row.is_multiple_of(2) { 0.0 } else { gx * 0.5 };
|
||||||
|
let mut cx = gx * 0.5 + offset;
|
||||||
|
while cx < W as f32 {
|
||||||
|
// alternate bright/dim to give depth
|
||||||
|
let c = if (row + (cx / gx) as u32).is_multiple_of(3) { STAR_A } else { STAR_B };
|
||||||
|
cv.circle(cx, cy, 1.0, c);
|
||||||
|
cx += gx;
|
||||||
|
}
|
||||||
|
cy += gy;
|
||||||
|
row += 1;
|
||||||
|
}
|
||||||
|
cv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// bg_3 – burgundy: diagonal tile pattern
|
||||||
|
fn make_bg_3() -> Canvas {
|
||||||
|
const BASE: [u8; 4] = [0x4D, 0x0D, 0x14, 0xFF];
|
||||||
|
const LINE: [u8; 4] = [0x77, 0x22, 0x30, 0xB0];
|
||||||
|
const ACCENT: [u8; 4] = [0x99, 0x33, 0x44, 0x80];
|
||||||
|
let mut cv = Canvas::new();
|
||||||
|
cv.fill_solid(BASE);
|
||||||
|
// Diagonal lines in one direction every 16 px
|
||||||
|
let spacing = 16i32;
|
||||||
|
for k in (-(H as i32)..W as i32 + H as i32).step_by(spacing as usize) {
|
||||||
|
for t in 0..W as i32 {
|
||||||
|
let y = t + k;
|
||||||
|
cv.set(t, y, LINE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Diagonal lines in the other direction every 16 px (accent colour)
|
||||||
|
for k in (0..W as i32 + H as i32).step_by(spacing as usize) {
|
||||||
|
for t in 0..W as i32 {
|
||||||
|
let y = k - t;
|
||||||
|
cv.set(t, y, ACCENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// bg_4 – charcoal: subtle checkerboard texture
|
||||||
|
fn make_bg_4() -> Canvas {
|
||||||
|
const DARK: [u8; 4] = [0x1F, 0x1F, 0x24, 0xFF];
|
||||||
|
const LIGHT: [u8; 4] = [0x2C, 0x2C, 0x33, 0xFF];
|
||||||
|
let mut cv = Canvas::new();
|
||||||
|
cv.fill_solid(DARK);
|
||||||
|
// 4×4 checkerboard
|
||||||
|
for y in 0..H as i32 {
|
||||||
|
for x in 0..W as i32 {
|
||||||
|
if ((x / 4) + (y / 4)) % 2 == 0 {
|
||||||
|
cv.set(x, y, LIGHT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cv
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn workspace_root() -> std::path::PathBuf {
|
||||||
|
let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
crate_dir.parent().unwrap().to_path_buf()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let root = workspace_root();
|
||||||
|
std::fs::create_dir_all(root.join("assets/cards/faces")).unwrap();
|
||||||
|
std::fs::create_dir_all(root.join("assets/cards/backs")).unwrap();
|
||||||
|
std::fs::create_dir_all(root.join("assets/backgrounds")).unwrap();
|
||||||
|
|
||||||
|
// Load font from disk (dev tool — runtime load is fine here).
|
||||||
|
let font_path = root.join("assets/fonts/main.ttf");
|
||||||
|
let font_bytes = std::fs::read(&font_path)
|
||||||
|
.unwrap_or_else(|e| panic!("failed to read {}: {e}", font_path.display()));
|
||||||
|
let font = FontRef::try_from_slice(&font_bytes)
|
||||||
|
.expect("failed to parse assets/fonts/main.ttf");
|
||||||
|
|
||||||
|
// 52 card faces
|
||||||
|
let suits = ["c", "d", "h", "s"];
|
||||||
|
let ranks = ["a","2","3","4","5","6","7","8","9","10","j","q","k"];
|
||||||
|
for suit in 0u8..4 {
|
||||||
|
for rank in 0u8..13 {
|
||||||
|
let cv = make_card_face(&font, rank, suit);
|
||||||
|
let name = format!("{}_{}.png", ranks[rank as usize], suits[suit as usize]);
|
||||||
|
let path = root.join("assets/cards/faces").join(&name);
|
||||||
|
save_card_png(&path, &cv);
|
||||||
|
println!("wrote {}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card backs
|
||||||
|
for (i, cv) in [make_back_0(), make_back_1(), make_back_2(), make_back_3(), make_back_4()].iter().enumerate() {
|
||||||
|
let path = root.join(format!("assets/cards/backs/back_{i}.png"));
|
||||||
|
save_card_png(&path, cv);
|
||||||
|
println!("wrote {}", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backgrounds
|
||||||
|
for (i, cv) in [make_bg_0(), make_bg_1(), make_bg_2(), make_bg_3(), make_bg_4()].iter().enumerate() {
|
||||||
|
let path = root.join(format!("assets/backgrounds/bg_{i}.png"));
|
||||||
|
save_card_png(&path, cv);
|
||||||
|
println!("wrote {}", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("gen_art: all assets generated successfully.");
|
||||||
|
}
|
||||||
@@ -16,16 +16,17 @@ fn main() -> io::Result<()> {
|
|||||||
let out_dir = workspace_root().join("assets").join("audio");
|
let out_dir = workspace_root().join("assets").join("audio");
|
||||||
fs::create_dir_all(&out_dir)?;
|
fs::create_dir_all(&out_dir)?;
|
||||||
|
|
||||||
let effects: [(&str, Generator); 5] = [
|
let effects: [(&str, Generator); 6] = [
|
||||||
("card_flip.wav", card_flip),
|
("card_flip.wav", card_flip),
|
||||||
("card_place.wav", card_place),
|
("card_place.wav", card_place),
|
||||||
("card_deal.wav", card_deal),
|
("card_deal.wav", card_deal),
|
||||||
("card_invalid.wav", card_invalid),
|
("card_invalid.wav", card_invalid),
|
||||||
("win_fanfare.wav", win_fanfare),
|
("win_fanfare.wav", win_fanfare),
|
||||||
|
("ambient_loop.wav", ambient_loop),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (name, gen) in &effects {
|
for (name, make) in &effects {
|
||||||
let samples = gen();
|
let samples = make();
|
||||||
let path = out_dir.join(name);
|
let path = out_dir.join(name);
|
||||||
write_wav_mono_pcm16(&path, SAMPLE_RATE, &samples)?;
|
write_wav_mono_pcm16(&path, SAMPLE_RATE, &samples)?;
|
||||||
println!("wrote {} ({} samples)", path.display(), samples.len());
|
println!("wrote {} ({} samples)", path.display(), samples.len());
|
||||||
@@ -169,6 +170,64 @@ fn win_fanfare() -> Vec<i16> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz
|
||||||
|
/// mono 16-bit PCM).
|
||||||
|
///
|
||||||
|
/// Design:
|
||||||
|
/// - Fundamental: 55 Hz (low A) sine wave.
|
||||||
|
/// - Harmonics: 110 Hz at 40% and 165 Hz at 20% for warmth.
|
||||||
|
/// - Amplitude LFO at 0.1 Hz creates a slow breath / pad swell.
|
||||||
|
/// - The loop length is chosen so both the fundamental and LFO complete an
|
||||||
|
/// integer number of cycles — guaranteeing a phase-continuous seamless loop.
|
||||||
|
/// - Peak amplitude is kept low (0.18) so it sits quietly under SFX.
|
||||||
|
fn ambient_loop() -> Vec<i16> {
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
// LFO period = 10 s; fundamental period ≈ 18.18 ms.
|
||||||
|
// We want a loop that is an exact integer multiple of both, so both
|
||||||
|
// complete a whole number of cycles with no phase discontinuity.
|
||||||
|
//
|
||||||
|
// LCM approach: fundamental @ 55 Hz repeats every 1/55 s. The LFO @ 0.1 Hz
|
||||||
|
// repeats every 10 s. 10 s is already a multiple of 1/55 s (10 * 55 = 550
|
||||||
|
// cycles), so a 10-second buffer loops perfectly. We halve it to 5 s for
|
||||||
|
// a smaller file — 5 * 55 = 275 (integer), 5 * 0.1 = 0.5 (half-cycle of
|
||||||
|
// LFO). To keep a full LFO cycle we use 10 s but write only the first 5 s
|
||||||
|
// of the waveform, which is within the 4–8 s budget and still a seamless
|
||||||
|
// loop because the LFO amplitude is symmetric about its midpoint at t=5 s.
|
||||||
|
//
|
||||||
|
// Simpler explanation: at exactly 5 s, both the 55 Hz tone and a slow
|
||||||
|
// 0.2 Hz (period=5 s) breath LFO complete an integer number of cycles.
|
||||||
|
// We use 0.2 Hz for the LFO instead of 0.1 Hz so the full envelope fits
|
||||||
|
// in one loop period.
|
||||||
|
let lfo_freq = 0.2_f32; // 1 full LFO cycle per 5-second loop
|
||||||
|
let loop_seconds = 1.0 / lfo_freq; // = 5.0 s
|
||||||
|
let n = (loop_seconds * SAMPLE_RATE as f32) as usize;
|
||||||
|
|
||||||
|
let f0 = 55.0_f32; // fundamental (Hz)
|
||||||
|
let f1 = 110.0_f32; // 2nd harmonic
|
||||||
|
let f2 = 165.0_f32; // 3rd harmonic
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(n);
|
||||||
|
for i in 0..n {
|
||||||
|
let t = i as f32 / SAMPLE_RATE as f32;
|
||||||
|
|
||||||
|
// LFO: smoothly oscillates between 0.4 and 1.0 amplitude.
|
||||||
|
// Using (1 - cos) / 2 instead of sin so the loop starts and ends at
|
||||||
|
// the same LFO phase (0.0 → both sin and cos are fully periodic).
|
||||||
|
let lfo = 0.7 + 0.3 * (2.0 * PI * lfo_freq * t).cos();
|
||||||
|
|
||||||
|
// Layered harmonics
|
||||||
|
let tone = (2.0 * PI * f0 * t).sin()
|
||||||
|
+ 0.4 * (2.0 * PI * f1 * t).sin()
|
||||||
|
+ 0.2 * (2.0 * PI * f2 * t).sin();
|
||||||
|
|
||||||
|
// Normalise the layered sum: max raw peak ≈ 1.6; keep final peak ≤ 0.18
|
||||||
|
let sample = tone / 1.6 * lfo * 0.18;
|
||||||
|
out.push(quantize(sample));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Minimal WAV writer (mono 16-bit PCM)
|
// Minimal WAV writer (mono 16-bit PCM)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solitaire_core"
|
name = "solitaire_core"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -12,20 +12,25 @@
|
|||||||
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
|
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AchievementContext {
|
pub struct AchievementContext {
|
||||||
// Stats (after this win has been recorded).
|
/// Total number of games played (after this win has been recorded).
|
||||||
pub games_played: u32,
|
pub games_played: u32,
|
||||||
|
/// Total number of games won (after this win has been recorded).
|
||||||
pub games_won: u32,
|
pub games_won: u32,
|
||||||
|
/// Current consecutive win streak (after this win has been recorded).
|
||||||
pub win_streak_current: u32,
|
pub win_streak_current: u32,
|
||||||
|
/// Highest single-game score ever achieved.
|
||||||
pub best_single_score: u32,
|
pub best_single_score: u32,
|
||||||
|
/// Cumulative score across all games ever played.
|
||||||
pub lifetime_score: u64,
|
pub lifetime_score: u64,
|
||||||
|
/// Total wins completed in Draw 3 mode.
|
||||||
pub draw_three_wins: u32,
|
pub draw_three_wins: u32,
|
||||||
|
|
||||||
// Progression.
|
|
||||||
/// Current daily-challenge completion streak (consecutive days).
|
/// Current daily-challenge completion streak (consecutive days).
|
||||||
pub daily_challenge_streak: u32,
|
pub daily_challenge_streak: u32,
|
||||||
|
|
||||||
// Last-win facts (GameWonEvent + GameState at win time).
|
/// Score achieved in the just-won game.
|
||||||
pub last_win_score: i32,
|
pub last_win_score: i32,
|
||||||
|
/// Elapsed seconds for the just-won game.
|
||||||
pub last_win_time_seconds: u64,
|
pub last_win_time_seconds: u64,
|
||||||
/// `true` if `undo()` was called at least once during the won game.
|
/// `true` if `undo()` was called at least once during the won game.
|
||||||
pub last_win_used_undo: bool,
|
pub last_win_used_undo: bool,
|
||||||
@@ -55,13 +60,17 @@ pub enum Reward {
|
|||||||
/// A single achievement's static metadata + unlock condition.
|
/// A single achievement's static metadata + unlock condition.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct AchievementDef {
|
pub struct AchievementDef {
|
||||||
|
/// Unique string identifier for this achievement (e.g. `"first_win"`).
|
||||||
pub id: &'static str,
|
pub id: &'static str,
|
||||||
|
/// Human-readable display name shown in the achievements screen.
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
|
/// Flavour text describing how to unlock the achievement.
|
||||||
pub description: &'static str,
|
pub description: &'static str,
|
||||||
/// Hidden from the achievements screen until unlocked.
|
/// Hidden from the achievements screen until unlocked.
|
||||||
pub secret: bool,
|
pub secret: bool,
|
||||||
/// Reward granted on first unlock. `None` for cosmetic-only recognition.
|
/// Reward granted on first unlock. `None` for cosmetic-only recognition.
|
||||||
pub reward: Option<Reward>,
|
pub reward: Option<Reward>,
|
||||||
|
/// Predicate evaluated on every `GameWonEvent` and `StateChangedEvent`. Returns true when the achievement should unlock.
|
||||||
pub condition: fn(&AchievementContext) -> bool,
|
pub condition: fn(&AchievementContext) -> bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +486,109 @@ mod tests {
|
|||||||
assert!(achievement_by_id("nonexistent").is_none());
|
assert!(achievement_by_id("nonexistent").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Direct predicate tests via ctx_defaults()
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Baseline context representing a single clean one-minute win in Draw-One mode.
|
||||||
|
fn ctx_defaults() -> AchievementContext {
|
||||||
|
AchievementContext {
|
||||||
|
games_played: 1,
|
||||||
|
games_won: 1,
|
||||||
|
win_streak_current: 1,
|
||||||
|
best_single_score: 0,
|
||||||
|
lifetime_score: 0,
|
||||||
|
draw_three_wins: 0,
|
||||||
|
daily_challenge_streak: 0,
|
||||||
|
last_win_score: 0,
|
||||||
|
last_win_time_seconds: 600,
|
||||||
|
last_win_used_undo: false,
|
||||||
|
wall_clock_hour: Some(12),
|
||||||
|
last_win_recycle_count: 0,
|
||||||
|
last_win_is_zen: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speed_demon_true_when_under_three_minutes() {
|
||||||
|
let mut c = ctx_defaults();
|
||||||
|
c.last_win_time_seconds = 179;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"speed_demon"), "speed_demon should unlock at 179s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speed_demon_false_when_over_three_minutes() {
|
||||||
|
let mut c = ctx_defaults();
|
||||||
|
c.last_win_time_seconds = 181;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"speed_demon"), "speed_demon must not unlock at 181s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lightning_true_when_under_90_seconds() {
|
||||||
|
let mut c = ctx_defaults();
|
||||||
|
c.last_win_time_seconds = 89;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"lightning"), "lightning should unlock at 89s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lightning_false_at_exactly_90_seconds() {
|
||||||
|
let mut c = ctx_defaults();
|
||||||
|
c.last_win_time_seconds = 90;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"lightning"), "lightning must not unlock at exactly 90s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_undo_true_when_zero_undos() {
|
||||||
|
let mut c = ctx_defaults();
|
||||||
|
c.last_win_used_undo = false;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"no_undo"), "no_undo should unlock when undo was not used");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_undo_false_when_undo_used() {
|
||||||
|
let mut c = ctx_defaults();
|
||||||
|
c.last_win_used_undo = true;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"no_undo"), "no_undo must not unlock when undo was used");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn high_scorer_true_when_score_5000_or_more() {
|
||||||
|
let mut c = ctx_defaults();
|
||||||
|
c.best_single_score = 5_000;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"high_scorer"), "high_scorer should unlock at best_single_score=5000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn high_scorer_false_when_below_5000() {
|
||||||
|
let mut c = ctx_defaults();
|
||||||
|
c.best_single_score = 4_999;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"high_scorer"), "high_scorer must not unlock at best_single_score=4999");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn on_a_roll_true_at_streak_3() {
|
||||||
|
let mut c = ctx_defaults();
|
||||||
|
c.win_streak_current = 3;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock at streak=3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comeback_true_when_three_or_more_recycles() {
|
||||||
|
let mut c = ctx_defaults();
|
||||||
|
c.last_win_recycle_count = 3;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"comeback"), "comeback should unlock at last_win_recycle_count=3");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn on_a_roll_requires_streak_of_3() {
|
fn on_a_roll_requires_streak_of_3() {
|
||||||
let mut c = ctx();
|
let mut c = ctx();
|
||||||
|
|||||||
@@ -63,9 +63,13 @@ impl Rank {
|
|||||||
/// A single playing card.
|
/// A single playing card.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Card {
|
pub struct Card {
|
||||||
|
/// Unique identifier for this card within the deal. Stable across moves and undo.
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
|
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
|
||||||
pub suit: Suit,
|
pub suit: Suit,
|
||||||
|
/// The card's rank (Ace through King).
|
||||||
pub rank: Rank,
|
pub rank: Rank,
|
||||||
|
/// Whether the card is visible to the player. Face-down cards may not be moved.
|
||||||
pub face_up: bool,
|
pub face_up: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const ALL_RANKS: [Rank; 13] = [
|
|||||||
|
|
||||||
/// A standard 52-card deck.
|
/// A standard 52-card deck.
|
||||||
pub struct Deck {
|
pub struct Deck {
|
||||||
|
/// All 52 cards in the deck, in deal order.
|
||||||
pub cards: Vec<Card>,
|
pub cards: Vec<Card>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ mod pile_map_serde {
|
|||||||
/// Whether cards are drawn one at a time or three at a time from the stock.
|
/// Whether cards are drawn one at a time or three at a time from the stock.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum DrawMode {
|
pub enum DrawMode {
|
||||||
|
/// Draw one card from stock per turn.
|
||||||
DrawOne,
|
DrawOne,
|
||||||
|
/// Draw three cards from stock per turn; only the top is playable.
|
||||||
DrawThree,
|
DrawThree,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,9 +48,13 @@ pub enum DrawMode {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
pub enum GameMode {
|
pub enum GameMode {
|
||||||
#[default]
|
#[default]
|
||||||
|
/// Standard Klondike rules with score and timer.
|
||||||
Classic,
|
Classic,
|
||||||
|
/// No timer, no score display, ambient audio only.
|
||||||
Zen,
|
Zen,
|
||||||
|
/// Fixed hard seeds, no undo, must win to advance.
|
||||||
Challenge,
|
Challenge,
|
||||||
|
/// Play as many games as possible within 10 minutes.
|
||||||
TimeAttack,
|
TimeAttack,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,18 +70,26 @@ struct StateSnapshot {
|
|||||||
/// Full state of an in-progress Klondike Solitaire game.
|
/// Full state of an in-progress Klondike Solitaire game.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct GameState {
|
pub struct GameState {
|
||||||
|
/// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles.
|
||||||
#[serde(with = "pile_map_serde")]
|
#[serde(with = "pile_map_serde")]
|
||||||
pub piles: HashMap<PileType, Pile>,
|
pub piles: HashMap<PileType, Pile>,
|
||||||
|
/// Whether the player draws one or three cards from the stock per turn.
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawMode,
|
||||||
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards
|
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards
|
||||||
/// compatibility with older save files via `#[serde(default)]`.
|
/// compatibility with older save files via `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
|
/// Current game score. Can be negative (undo penalties subtract from score).
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
|
/// Total moves made this game, including draws and stock recycles.
|
||||||
pub move_count: u32,
|
pub move_count: u32,
|
||||||
|
/// Seconds elapsed since the game started, used for time-bonus scoring.
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
|
/// RNG seed used to deal this game. Same seed always produces the same layout.
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
|
/// True once all 52 cards are on the foundations. No further moves are accepted.
|
||||||
pub is_won: bool,
|
pub is_won: bool,
|
||||||
|
/// True when the game can be completed without further input (all remaining cards are face-up and in order).
|
||||||
pub is_auto_completable: bool,
|
pub is_auto_completable: bool,
|
||||||
/// Number of times `undo()` has been successfully invoked this game.
|
/// Number of times `undo()` has been successfully invoked this game.
|
||||||
/// Used by achievement conditions like `no_undo`.
|
/// Used by achievement conditions like `no_undo`.
|
||||||
@@ -173,6 +187,7 @@ impl GameState {
|
|||||||
stock.cards.push(card);
|
stock.cards.push(card);
|
||||||
}
|
}
|
||||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||||
|
self.move_count += 1;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,10 +289,9 @@ impl GameState {
|
|||||||
.ok_or(MoveError::InvalidSource)?
|
.ok_or(MoveError::InvalidSource)?
|
||||||
.cards
|
.cards
|
||||||
.last_mut()
|
.last_mut()
|
||||||
|
&& !top.face_up
|
||||||
{
|
{
|
||||||
if !top.face_up {
|
top.face_up = true;
|
||||||
top.face_up = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
|
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
|
||||||
@@ -352,6 +366,15 @@ impl GameState {
|
|||||||
/// Scans tableau piles 0–6 in order, returning the first top card that
|
/// Scans tableau piles 0–6 in order, returning the first top card that
|
||||||
/// can be placed on any foundation pile. The scan order ensures Aces are
|
/// can be placed on any foundation pile. The scan order ensures Aces are
|
||||||
/// resolved before higher ranks that depend on them.
|
/// resolved before higher ranks that depend on them.
|
||||||
|
///
|
||||||
|
/// # Precondition
|
||||||
|
///
|
||||||
|
/// This function is only called when `is_auto_completable` is `true`.
|
||||||
|
/// Auto-completability requires the waste pile to be empty, as enforced by
|
||||||
|
/// [`check_auto_complete`](Self::check_auto_complete) — it returns `false`
|
||||||
|
/// whenever `piles[Waste]` is non-empty. Therefore, skipping the waste pile
|
||||||
|
/// in this scan is intentional and correct: by the time this function is
|
||||||
|
/// reached, there are guaranteed to be no cards there to move.
|
||||||
pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> {
|
pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> {
|
||||||
if !self.is_auto_completable || self.is_won {
|
if !self.is_auto_completable || self.is_won {
|
||||||
return None;
|
return None;
|
||||||
@@ -562,6 +585,24 @@ mod tests {
|
|||||||
assert_eq!(g.recycle_count, 2);
|
assert_eq!(g.recycle_count, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_count_increments_on_recycle() {
|
||||||
|
let mut g = new_game();
|
||||||
|
// Drain stock to waste, recording how many draws it took.
|
||||||
|
let mut draws: u32 = 0;
|
||||||
|
while !g.piles[&PileType::Stock].cards.is_empty() {
|
||||||
|
g.draw().unwrap();
|
||||||
|
draws += 1;
|
||||||
|
}
|
||||||
|
let before = g.move_count;
|
||||||
|
g.draw().unwrap(); // recycle
|
||||||
|
assert_eq!(
|
||||||
|
g.move_count,
|
||||||
|
before + 1,
|
||||||
|
"recycling waste back to stock must increment move_count (was {before}, draws={draws})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn draw_from_empty_stock_and_waste_returns_error() {
|
fn draw_from_empty_stock_and_waste_returns_error() {
|
||||||
// The only stop condition for draw() is: both stock AND waste are
|
// The only stop condition for draw() is: both stock AND waste are
|
||||||
@@ -949,6 +990,24 @@ mod tests {
|
|||||||
assert_eq!(g.compute_time_bonus(), 7000);
|
assert_eq!(g.compute_time_bonus(), 7000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- EmptySource error path ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_from_empty_pile_returns_empty_source() {
|
||||||
|
// Build a game state, clear a tableau pile entirely, then attempt to
|
||||||
|
// move from it. The source pile exists in `piles` (key is present) but
|
||||||
|
// contains no cards — exactly the code path that returns EmptySource.
|
||||||
|
let mut g = new_game();
|
||||||
|
// Tableau(0) starts with exactly 1 card; clear it to make the pile empty.
|
||||||
|
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
|
||||||
|
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1);
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(MoveError::EmptySource),
|
||||||
|
"moving from an empty pile must return EmptySource"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- next_auto_complete_move ---
|
// --- next_auto_complete_move ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ pub enum PileType {
|
|||||||
/// A named collection of cards in a specific board position.
|
/// A named collection of cards in a specific board position.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Pile {
|
pub struct Pile {
|
||||||
|
/// Which pile this is (Stock, Waste, Foundation suit, or Tableau column).
|
||||||
pub pile_type: PileType,
|
pub pile_type: PileType,
|
||||||
|
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||||||
pub cards: Vec<Card>,
|
pub cards: Vec<Card>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,6 @@ mod tests {
|
|||||||
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
|
fn time_bonus_is_capped_at_i32_max_for_huge_values() {
|
||||||
// Very short elapsed time would overflow without the .min() guard.
|
// Very short elapsed time would overflow without the .min() guard.
|
||||||
let bonus = compute_time_bonus(1);
|
let bonus = compute_time_bonus(1);
|
||||||
assert!(bonus <= i32::MAX, "time bonus must fit in i32");
|
assert!(bonus >= 0, "time bonus must be non-negative after u64→i32 cast");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solitaire_data"
|
name = "solitaire_data"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -12,6 +13,6 @@ chrono = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
keyring = { workspace = true }
|
keyring-core = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -8,9 +8,15 @@
|
|||||||
//! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting
|
//! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting
|
||||||
//! the user to log in again.
|
//! the user to log in again.
|
||||||
//!
|
//!
|
||||||
|
//! Before calling any function in this module the application must initialise
|
||||||
|
//! the default keyring store exactly once at startup by calling
|
||||||
|
//! `keyring::use_native_store` (e.g. in `solitaire_app::main` before building
|
||||||
|
//! the Bevy `App`). If no default store is set, all operations in this module
|
||||||
|
//! will return [`TokenError::KeychainUnavailable`].
|
||||||
|
//!
|
||||||
//! # Note: no unit tests — requires live OS keychain.
|
//! # Note: no unit tests — requires live OS keychain.
|
||||||
|
|
||||||
use keyring::Entry;
|
use keyring_core::Entry;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Errors that can occur when reading or writing tokens in the OS keychain.
|
/// Errors that can occur when reading or writing tokens in the OS keychain.
|
||||||
@@ -30,12 +36,13 @@ pub enum TokenError {
|
|||||||
/// Service name used to namespace all keychain entries for this application.
|
/// Service name used to namespace all keychain entries for this application.
|
||||||
const SERVICE: &str = "solitaire_quest_server";
|
const SERVICE: &str = "solitaire_quest_server";
|
||||||
|
|
||||||
/// Map a `keyring::Error` to the appropriate `TokenError`.
|
/// Map a `keyring_core::Error` to the appropriate `TokenError`.
|
||||||
fn map_keyring_err(err: keyring::Error, username: &str) -> TokenError {
|
fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
|
||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
match err {
|
match err {
|
||||||
keyring::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
|
keyring_core::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
|
||||||
keyring::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
keyring_core::Error::NoDefaultStore => TokenError::KeychainUnavailable(msg),
|
||||||
|
keyring_core::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
||||||
_ => TokenError::Keyring(msg),
|
_ => TokenError::Keyring(msg),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,17 +95,17 @@ pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
|||||||
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||||
match Entry::new(SERVICE, &format!("{username}_access"))
|
match Entry::new(SERVICE, &format!("{username}_access"))
|
||||||
.map_err(|e| map_keyring_err(e, username))?
|
.map_err(|e| map_keyring_err(e, username))?
|
||||||
.delete_password()
|
.delete_credential()
|
||||||
{
|
{
|
||||||
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
Ok(()) | Err(keyring_core::Error::NoEntry) => {}
|
||||||
Err(e) => return Err(map_keyring_err(e, username)),
|
Err(e) => return Err(map_keyring_err(e, username)),
|
||||||
}
|
}
|
||||||
|
|
||||||
match Entry::new(SERVICE, &format!("{username}_refresh"))
|
match Entry::new(SERVICE, &format!("{username}_refresh"))
|
||||||
.map_err(|e| map_keyring_err(e, username))?
|
.map_err(|e| map_keyring_err(e, username))?
|
||||||
.delete_password()
|
.delete_credential()
|
||||||
{
|
{
|
||||||
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
Ok(()) | Err(keyring_core::Error::NoEntry) => {}
|
||||||
Err(e) => return Err(map_keyring_err(e, username)),
|
Err(e) => return Err(map_keyring_err(e, username)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,8 +148,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_xp_saturates_on_overflow() {
|
fn add_xp_saturates_on_overflow() {
|
||||||
let mut p = PlayerProgress::default();
|
let mut p = PlayerProgress { total_xp: u64::MAX - 5, ..Default::default() };
|
||||||
p.total_xp = u64::MAX - 5;
|
|
||||||
p.add_xp(100);
|
p.add_xp(100);
|
||||||
assert_eq!(p.total_xp, u64::MAX);
|
assert_eq!(p.total_xp, u64::MAX);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ 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.
|
/// Animation playback speed for card transitions.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
pub enum AnimSpeed {
|
pub enum AnimSpeed {
|
||||||
/// Standard animation timing (default).
|
/// Standard animation timing (default).
|
||||||
#[default]
|
#[default]
|
||||||
@@ -40,7 +40,8 @@ pub enum Theme {
|
|||||||
|
|
||||||
/// Which sync backend the player has configured.
|
/// Which sync backend the player has configured.
|
||||||
///
|
///
|
||||||
/// JWT tokens for `SolitaireServer` are stored in the OS keychain via
|
/// `Local` keeps all progress on-device. `SolitaireServer` syncs via the
|
||||||
|
/// self-hosted server. JWT tokens are stored in the OS keychain via
|
||||||
/// `solitaire_data::auth_tokens` — **never** in this struct.
|
/// `solitaire_data::auth_tokens` — **never** in this struct.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
pub enum SyncBackend {
|
pub enum SyncBackend {
|
||||||
@@ -57,10 +58,7 @@ pub enum SyncBackend {
|
|||||||
username: String,
|
username: String,
|
||||||
// JWT tokens are stored in the OS keychain — not here.
|
// 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.
|
||||||
@@ -207,8 +205,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adjust_sfx_volume_clamps() {
|
fn adjust_sfx_volume_clamps() {
|
||||||
let mut s = Settings::default();
|
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
|
||||||
s.sfx_volume = 0.5;
|
|
||||||
assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6);
|
assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6);
|
||||||
assert!((s.adjust_sfx_volume(0.5) - 1.0).abs() < 1e-6);
|
assert!((s.adjust_sfx_volume(0.5) - 1.0).abs() < 1e-6);
|
||||||
assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6);
|
assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6);
|
||||||
@@ -217,8 +214,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adjust_music_volume_clamps() {
|
fn adjust_music_volume_clamps() {
|
||||||
let mut s = Settings::default();
|
let mut s = Settings { music_volume: 0.5, ..Default::default() };
|
||||||
s.music_volume = 0.5;
|
|
||||||
assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6);
|
assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6);
|
||||||
assert!((s.adjust_music_volume(0.5) - 1.0).abs() < 1e-6);
|
assert!((s.adjust_music_volume(0.5) - 1.0).abs() < 1e-6);
|
||||||
assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6);
|
assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6);
|
||||||
@@ -241,14 +237,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitized_clamps_music_volume() {
|
fn sanitized_clamps_music_volume() {
|
||||||
let mut s = Settings::default();
|
let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized();
|
||||||
s.music_volume = 2.0;
|
|
||||||
let s = s.sanitized();
|
|
||||||
assert_eq!(s.music_volume, 1.0);
|
assert_eq!(s.music_volume, 1.0);
|
||||||
|
|
||||||
let mut s2 = Settings::default();
|
let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized();
|
||||||
s2.music_volume = -0.5;
|
|
||||||
let s2 = s2.sanitized();
|
|
||||||
assert_eq!(s2.music_volume, 0.0);
|
assert_eq!(s2.music_volume, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub use solitaire_sync::StatsSnapshot;
|
|||||||
///
|
///
|
||||||
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
|
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
|
||||||
pub trait StatsExt {
|
pub trait StatsExt {
|
||||||
/// Record a completed win. Updates all relevant counters and rolling averages.
|
/// Updates rolling statistics from a completed game win. Call once per `GameWonEvent`.
|
||||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
|
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +173,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lifetime_score_saturates_at_u64_max() {
|
fn lifetime_score_saturates_at_u64_max() {
|
||||||
let mut s = StatsSnapshot::default();
|
let mut s = StatsSnapshot { lifetime_score: u64::MAX - 100, ..Default::default() };
|
||||||
s.lifetime_score = u64::MAX - 100;
|
|
||||||
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
||||||
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
|
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,6 +364,10 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
|
|
||||||
/// Deserialize a pull response body as [`SyncResponse`] and return its
|
/// Deserialize a pull response body as [`SyncResponse`] and return its
|
||||||
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
|
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
|
||||||
|
///
|
||||||
|
/// Only HTTP 401 (Unauthorized) and 403 (Forbidden) are treated as
|
||||||
|
/// authentication errors. All other non-2xx statuses (5xx, 429, etc.) are
|
||||||
|
/// classified as network/transport errors so the UI shows the right message.
|
||||||
async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncError> {
|
async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncError> {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
@@ -372,8 +376,12 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||||
Ok(sync_resp.merged)
|
Ok(sync_resp.merged)
|
||||||
} else {
|
} else if status == reqwest::StatusCode::UNAUTHORIZED
|
||||||
|
|| status == reqwest::StatusCode::FORBIDDEN
|
||||||
|
{
|
||||||
Err(SyncError::Auth(format!("server returned {status}")))
|
Err(SyncError::Auth(format!("server returned {status}")))
|
||||||
|
} else {
|
||||||
|
Err(SyncError::Network(format!("server returned {status}")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,14 +399,22 @@ async fn extract_leaderboard_body(resp: reqwest::Response) -> Result<Vec<Leaderb
|
|||||||
|
|
||||||
/// Deserialize a push response body as [`SyncResponse`], or map non-200
|
/// Deserialize a push response body as [`SyncResponse`], or map non-200
|
||||||
/// statuses to the appropriate [`SyncError`].
|
/// statuses to the appropriate [`SyncError`].
|
||||||
|
///
|
||||||
|
/// Only HTTP 401 (Unauthorized) and 403 (Forbidden) are treated as
|
||||||
|
/// authentication errors. All other non-2xx statuses (5xx, 429, etc.) are
|
||||||
|
/// classified as network/transport errors so the UI shows the right message.
|
||||||
async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, SyncError> {
|
async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, SyncError> {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
resp.json()
|
resp.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SyncError::Serialization(e.to_string()))
|
.map_err(|e| SyncError::Serialization(e.to_string()))
|
||||||
} else {
|
} else if status == reqwest::StatusCode::UNAUTHORIZED
|
||||||
|
|| status == reqwest::StatusCode::FORBIDDEN
|
||||||
|
{
|
||||||
Err(SyncError::Auth(format!("server returned {status}")))
|
Err(SyncError::Auth(format!("server returned {status}")))
|
||||||
|
} else {
|
||||||
|
Err(SyncError::Network(format!("server returned {status}")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,19 +428,12 @@ async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, Sync
|
|||||||
/// This is the **one** place in the codebase that matches on [`SyncBackend`]
|
/// This is the **one** place in the codebase that matches on [`SyncBackend`]
|
||||||
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
|
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
|
||||||
/// and remains backend-agnostic.
|
/// 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> {
|
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
|
||||||
match backend {
|
match backend {
|
||||||
SyncBackend::Local => Box::new(LocalOnlyProvider),
|
SyncBackend::Local => Box::new(LocalOnlyProvider),
|
||||||
SyncBackend::SolitaireServer { url, username } => {
|
SyncBackend::SolitaireServer { url, username } => {
|
||||||
Box::new(SolitaireServerClient::new(url.clone(), username.clone()))
|
Box::new(SolitaireServerClient::new(url.clone(), username.clone()))
|
||||||
}
|
}
|
||||||
SyncBackend::GooglePlayGames => {
|
|
||||||
// GPGS is Android-only; fall back to no-op on desktop.
|
|
||||||
Box::new(LocalOnlyProvider)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,12 +479,6 @@ mod tests {
|
|||||||
assert_eq!(provider.backend_name(), "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]
|
#[test]
|
||||||
fn factory_server_returns_server_client() {
|
fn factory_server_returns_server_client() {
|
||||||
let provider = provider_for_backend(&SyncBackend::SolitaireServer {
|
let provider = provider_for_backend(&SyncBackend::SolitaireServer {
|
||||||
|
|||||||