Compare commits
222 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa2a021712 | |||
| 6037596cc0 | |||
| d7ffb16df5 | |||
| b57db017d3 | |||
| 0b3140ad6d | |||
| e41def8c89 | |||
| aad8bb9c83 | |||
| 55c235b55f | |||
| 21ec03b157 | |||
| 17e3112502 | |||
| de4751115f | |||
| 9ff48ace5b | |||
| 91b7605b9f | |||
| 42d90b199c | |||
| 3e11e9e79a | |||
| bfcd05fbb5 | |||
| c497c3193c | |||
| 9aa0dd23b1 | |||
| d065d49fe7 | |||
| c30b04ec72 | |||
| 40d6e0ab17 | |||
| 9fe650fa20 | |||
| b73d246b4c | |||
| ae40a1db7a | |||
| b7c3a4996f | |||
| d48b9489db | |||
| 08b006ff30 | |||
| 17e0737a10 | |||
| dd63261999 | |||
| 93660c2217 | |||
| 56e2e6f151 | |||
| cc635328be | |||
| a4bc063497 | |||
| 540869c851 | |||
| bdac754b26 | |||
| f863d85c35 | |||
| 3c7a0eb4fb | |||
| d489e7a31b | |||
| f2f30c8002 | |||
| a49a340a30 | |||
| 27cdf78ce0 | |||
| faa6c5efc4 | |||
| 487b99bbc9 | |||
| 53e3b816cf | |||
| 87275bf340 | |||
| 56647d7f0d | |||
| cbf2483028 | |||
| a54201e97b | |||
| 48e412177c | |||
| cd54ce1bb0 | |||
| 7a3032b74c | |||
| 89699a8a86 | |||
| 70165da103 | |||
| 8a5fa8751c | |||
| bf660df971 | |||
| 13a8a012ee | |||
| 02ababa65f | |||
| 9c36b49729 | |||
| 8e90574437 | |||
| 95fcdad5d2 | |||
| d948fa862a | |||
| 1fcd032b0a | |||
| 3081505a3d | |||
| 07b8ecd9b2 | |||
| 5bed43ef32 | |||
| 23c9704887 | |||
| 93182fa251 | |||
| 89c51ab712 | |||
| 3984231c9b | |||
| d9f36bf34a | |||
| 57d1c58fdf | |||
| 42535f5109 | |||
| d5e6f8026b | |||
| 271647265c | |||
| 3eabc149a8 | |||
| f1aeb24157 | |||
| 000143231b | |||
| 1a1047664b | |||
| ba527de351 | |||
| fe41b502ac | |||
| b37f0cbec7 | |||
| a0fc0d2605 | |||
| 7ed4f2cba9 | |||
| ddc8f27c82 | |||
| 13dd44bd1b | |||
| 17f9b518f1 | |||
| 61d891fb76 | |||
| 7dba772e67 | |||
| ca5788f714 | |||
| 9887343d8b | |||
| 525fe0fe76 | |||
| 69ce9afab9 | |||
| 13aa0fd833 | |||
| 9f095c4039 | |||
| d8c70341f4 | |||
| 063269c70e | |||
| b126df82b2 | |||
| 655dfde736 | |||
| f712b89fe4 | |||
| f6c916641a | |||
| 95df5421c9 | |||
| fdb6c2ecfe | |||
| 9a3d7f3876 | |||
| c4970b16ea | |||
| 2c72e1fc87 | |||
| efa063fb8f | |||
| 78cf30e906 | |||
| 9a9026e33a | |||
| ab1d098877 | |||
| 160637d1c8 | |||
| 43f13c615e | |||
| 924a1e2af7 | |||
| a6b8348332 | |||
| b98cb8a99f | |||
| 7b59e70192 | |||
| 7f477b4ad8 | |||
| ce38b26721 | |||
| 172d7773f0 | |||
| 205ad6f646 | |||
| 936d035750 | |||
| 13d1d013e9 | |||
| b8fb3fbd6e | |||
| e510e90b95 | |||
| 902560cd68 | |||
| 912b08c719 | |||
| 3ef4ecb747 | |||
| 4b9d008be2 | |||
| 74482252d1 | |||
| 6e7705b256 | |||
| 59316de1e9 | |||
| 1719fdada0 | |||
| 8dda9541a3 | |||
| 60a80369d4 | |||
| dbe6c60133 | |||
| 74597a8c84 | |||
| 5d57b67934 | |||
| 220e3f040c | |||
| 54d34972d4 | |||
| 0c86cac2d5 | |||
| 2e080d02ce | |||
| 73e210b243 | |||
| f866299021 | |||
| b78a493a0c | |||
| 51d3454344 | |||
| 12789529a1 | |||
| c1bde18a2c | |||
| fd7fb7b6da | |||
| 138436558f | |||
| 65d595ad12 | |||
| abeb4e5cdf | |||
| b082bd65a6 | |||
| 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
|
||||
@@ -1,7 +1,9 @@
|
||||
/target
|
||||
/.sccache-cache
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
*.tmp
|
||||
data/
|
||||
.claude/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "36bab3ca8f126c66a6f4865d2699702689518bd3a796c374f932aba0434a4c94"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT\n r.id AS \"id!: String\",\n u.username AS \"username!: String\",\n r.seed AS \"seed!: i64\",\n r.draw_mode AS \"draw_mode!: String\",\n r.mode AS \"mode!: String\",\n r.time_seconds AS \"time_seconds!: i64\",\n r.final_score AS \"final_score!: i64\",\n r.recorded_at AS \"recorded_at!: String\",\n r.received_at AS \"received_at!: String\"\n FROM replays r\n JOIN users u ON u.id = r.user_id\n ORDER BY r.received_at DESC\n LIMIT ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "username!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "seed!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "draw_mode!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mode!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "time_seconds!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "final_score!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "recorded_at!: String",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "received_at!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3a9bd2e51b2389da5b7e85f26806fcffa896748e0b589d216cf60827fc3857a9"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT replay_json FROM replays WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "replay_json",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5bc1984044bc792c2e9577a159ca22789469df14cb25144451f37e8cdad8165c"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO replays (\n id, user_id, seed, draw_mode, mode, time_seconds, final_score,\n recorded_at, received_at, replay_json\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6a36a96faa9d9b423aae3b72b0c049a1489b67ca2361581b2300bb4ee0bc9e2f"
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
# Solitaire Quest — Architecture Document
|
||||
|
||||
> **Version:** 1.1
|
||||
> **Language:** Rust (Edition 2021)
|
||||
> **Language:** Rust (Edition 2024)
|
||||
> **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)
|
||||
6. [Persistence & Sync Architecture](#6-persistence--sync-architecture)
|
||||
7. [Sync Server Architecture](#7-sync-server-architecture)
|
||||
8. [Google Play Games Services (Android Future)](#8-google-play-games-services-android-future)
|
||||
9. [Data Models](#9-data-models)
|
||||
10. [API Reference](#10-api-reference)
|
||||
11. [Merge Strategy](#11-merge-strategy)
|
||||
12. [Achievement System](#12-achievement-system)
|
||||
13. [Progression System](#13-progression-system)
|
||||
14. [Audio System](#14-audio-system)
|
||||
15. [Asset Pipeline](#15-asset-pipeline)
|
||||
16. [Platform Targets](#16-platform-targets)
|
||||
17. [Build & Development Guide](#17-build--development-guide)
|
||||
18. [Deployment Guide](#18-deployment-guide)
|
||||
19. [Security Model](#19-security-model)
|
||||
20. [Testing Strategy](#20-testing-strategy)
|
||||
21. [Decision Log](#21-decision-log)
|
||||
8. [Data Models](#8-data-models)
|
||||
9. [API Reference](#9-api-reference)
|
||||
10. [Merge Strategy](#10-merge-strategy)
|
||||
11. [Achievement System](#11-achievement-system)
|
||||
12. [Progression System](#12-progression-system)
|
||||
13. [Audio System](#13-audio-system)
|
||||
14. [Asset Pipeline](#14-asset-pipeline)
|
||||
15. [Platform Targets](#15-platform-targets)
|
||||
16. [Build & Development Guide](#16-build--development-guide)
|
||||
17. [Deployment Guide](#17-deployment-guide)
|
||||
18. [Security Model](#18-security-model)
|
||||
19. [Testing Strategy](#19-testing-strategy)
|
||||
20. [Decision Log](#20-decision-log)
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Sync Backend by Platform
|
||||
|
||||
@@ -46,17 +43,15 @@ On Android (stretch goal), sync is enhanced with Google Play Games Services (GPG
|
||||
| macOS | Self-hosted server | Full feature set |
|
||||
| Windows | 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
|
||||
|
||||
- **Offline first.** The local file is always the source of truth. Sync is additive, never destructive.
|
||||
- **Pure core.** All game logic lives in a dependency-free Rust crate with no Bevy, no network, and no I/O. This keeps it fully unit-testable and portable.
|
||||
- **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.
|
||||
- **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s.
|
||||
|
||||
Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enforced by CLAUDE.md §2.1, §2.3, and §3.3 respectively — those are the canonical statements; this file describes the design that motivates them.
|
||||
|
||||
---
|
||||
|
||||
## 2. Workspace Structure
|
||||
@@ -72,26 +67,25 @@ solitaire_quest/
|
||||
├── Dockerfile # Multi-stage server build
|
||||
├── 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/
|
||||
│ │ ├── faces/ # Card face sprites (suit + rank)
|
||||
│ │ └── backs/ # Card back designs (back_0.png … back_4.png)
|
||||
│ ├── backgrounds/ # Table backgrounds (bg_0.png … bg_4.png)
|
||||
│ ├── fonts/ # .ttf font files
|
||||
│ │ ├── faces/{RANK}{SUIT}.png # 52 card faces — rendered from hayeah/playing-cards-assets SVGs (MIT)
|
||||
│ │ └── backs/back_0.png – back_4.png # back_0 = generated default back; back_1–4 are generated patterns
|
||||
│ ├── backgrounds/bg_0.png – bg_4.png # generated textures
|
||||
│ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
|
||||
│ └── audio/
|
||||
│ ├── card_deal.ogg
|
||||
│ ├── card_flip.ogg
|
||||
│ ├── card_place.ogg
|
||||
│ ├── card_invalid.ogg
|
||||
│ ├── win_fanfare.ogg
|
||||
│ └── ambient_loop.ogg
|
||||
│ ├── card_deal.wav
|
||||
│ ├── card_flip.wav
|
||||
│ ├── card_place.wav
|
||||
│ ├── card_invalid.wav
|
||||
│ ├── win_fanfare.wav
|
||||
│ └── ambient_loop.wav
|
||||
│
|
||||
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
|
||||
├── solitaire_sync/ # Shared API types — used by client and server
|
||||
├── solitaire_data/ # Persistence, sync client, settings
|
||||
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||
├── 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
|
||||
```
|
||||
|
||||
@@ -135,25 +129,10 @@ Owns:
|
||||
- `SyncBackend` enum and backend selection
|
||||
- Solitaire Server sync client (JWT auth, auto-refresh)
|
||||
- OS keychain integration (`keyring`)
|
||||
- `SyncProvider` trait — implemented by both `SolitaireServerClient` and `GpgsClient` (Android)
|
||||
|
||||
### `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.
|
||||
- `SyncProvider` trait — implemented by `SolitaireServerClient`
|
||||
|
||||
### `solitaire_engine`
|
||||
**Dependencies:** `bevy`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`.
|
||||
**Dependencies:** `bevy`, `kira`, `solitaire_core`, `solitaire_data`.
|
||||
|
||||
All Bevy-specific code. Structured as a collection of Plugins that `solitaire_app` registers.
|
||||
|
||||
@@ -165,6 +144,7 @@ Owns:
|
||||
- All Bevy UI screens (Home, Stats, Achievements, Settings, Profile)
|
||||
- Audio playback systems
|
||||
- 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`
|
||||
**Dependencies:** `solitaire_sync`, `axum`, `sqlx`, `jsonwebtoken`, `bcrypt`, `tower-governor`, `tracing`, `tokio`, `dotenvy`.
|
||||
@@ -223,8 +203,7 @@ SyncPlugin::on_startup()
|
||||
│ spawns AsyncComputeTask
|
||||
▼
|
||||
solitaire_data::sync_pull() ← dispatches to active SyncProvider
|
||||
│ SolitaireServerClient (desktop / iOS)
|
||||
│ GpgsClient (Android, future)
|
||||
│ SolitaireServerClient
|
||||
▼
|
||||
solitaire_sync::merge(local, remote)
|
||||
│
|
||||
@@ -245,7 +224,7 @@ SyncPlugin::on_exit()
|
||||
│ blocking push (acceptable on exit, not on main loop)
|
||||
▼
|
||||
active SyncProvider::push(local)
|
||||
│ POST to server — or — GPGS Saved Games PUT (Android)
|
||||
│ POST to server
|
||||
▼
|
||||
Done
|
||||
```
|
||||
@@ -256,19 +235,22 @@ Done
|
||||
|
||||
### 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 |
|
||||
| `TablePlugin` | — | Pile markers, background, layout calculation |
|
||||
| `FontPlugin` | — | Loads FiraMono-Medium via `AssetServer` at startup; exposes `FontResource` handle |
|
||||
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
|
||||
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
|
||||
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
|
||||
| `AudioPlugin` | — | Sound effect and music playback via bevy_kira_audio |
|
||||
| `AudioPlugin` | — | Sound effect and music playback via kira |
|
||||
| `InputPlugin` | — | Keyboard and mouse input routing |
|
||||
| `CursorPlugin` | — | Custom cursor sprite during drag |
|
||||
| `SelectionPlugin` | — | Keyboard-driven card selection |
|
||||
| `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 |
|
||||
| `ProgressPlugin` | — | XP/level system, persistence |
|
||||
| `AchievementPlugin` | A | Unlock evaluation, toast events, persistence |
|
||||
@@ -309,6 +291,20 @@ struct StatsResource(StatsSnapshot);
|
||||
struct ProgressResource(PlayerProgress);
|
||||
struct AchievementsResource(Vec<AchievementRecord>);
|
||||
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
|
||||
@@ -382,7 +378,6 @@ Implementations:
|
||||
|---|---|---|
|
||||
| `LocalOnlyProvider` | No-op (default) | 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.
|
||||
|
||||
@@ -397,9 +392,6 @@ pub enum SyncBackend {
|
||||
// JWT access + refresh tokens stored in OS keychain
|
||||
// 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 +403,6 @@ On exit: `POST /api/sync/push` with payload
|
||||
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.
|
||||
|
||||
### 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
|
||||
@@ -501,89 +489,7 @@ This ensures all players worldwide get the same challenge for a given date, rega
|
||||
|
||||
---
|
||||
|
||||
## 8. Google Play Games Services (Android Future)
|
||||
|
||||
> **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
|
||||
## 8. Data Models
|
||||
|
||||
### Core Game Models (`solitaire_core`)
|
||||
|
||||
@@ -677,14 +583,14 @@ pub struct Settings {
|
||||
pub music_volume: f32,
|
||||
pub animation_speed: AnimSpeed,
|
||||
pub theme: Theme,
|
||||
pub sync_backend: SyncBackend, // Local | SolitaireServer | GooglePlayGames
|
||||
pub sync_backend: SyncBackend, // Local | SolitaireServer
|
||||
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`).
|
||||
|
||||
@@ -727,9 +633,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
|
||||
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload {
|
||||
@@ -769,7 +675,7 @@ pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload {
|
||||
|
||||
---
|
||||
|
||||
## 12. Achievement System
|
||||
## 11. Achievement System
|
||||
|
||||
### Definition Structure
|
||||
|
||||
@@ -809,18 +715,17 @@ pub struct AchievementDef {
|
||||
| `speed_and_skill` | ??? | Win < 90s without undo | Yes | Card back #4 |
|
||||
| `comeback` | ??? | Win after 3+ stock recycles | Yes | Background #4 |
|
||||
| `zen_winner` | ??? | Win in Zen Mode | Yes | Badge |
|
||||
| `cinephile` | Cinephile | Watch a saved replay all the way through | No | — |
|
||||
|
||||
### Evaluation Timing
|
||||
|
||||
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.
|
||||
A small number of achievements are *event-driven* rather than condition-driven: their `AchievementDef::condition` always returns `false` and their unlock is written from a dedicated observer system instead. `cinephile` is the canonical example — it unlocks when `ReplayPlaybackState` transitions from `Playing` to `Completed` (a saved replay watched to its natural end). The Stop button transitions `Playing → Inactive` directly without entering `Completed`, so manual aborts do not unlock the achievement.
|
||||
|
||||
---
|
||||
|
||||
## 13. Progression System
|
||||
## 12. Progression System
|
||||
|
||||
### XP Sources
|
||||
|
||||
@@ -849,62 +754,85 @@ 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 `kira`. All sound files are `.wav`.
|
||||
|
||||
| File | Trigger |
|
||||
|---|---|
|
||||
| `card_deal.ogg` | New game deal animation |
|
||||
| `card_flip.ogg` | Card flips face-up |
|
||||
| `card_place.ogg` | Valid card placement |
|
||||
| `card_invalid.ogg` | Invalid move attempt |
|
||||
| `win_fanfare.ogg` | Game won |
|
||||
| `ambient_loop.ogg` | Looping background music (restarts seamlessly) |
|
||||
| `card_deal.wav` | New game deal animation |
|
||||
| `card_flip.wav` | Card flips face-up |
|
||||
| `card_place.wav` | Valid card placement |
|
||||
| `card_invalid.wav` | Invalid move attempt |
|
||||
| `win_fanfare.wav` | Game won |
|
||||
| `ambient_loop.wav` | Looping background music |
|
||||
|
||||
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 `kira` channel volumes.
|
||||
|
||||
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:
|
||||
- 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
|
||||
Backgrounds are Bevy `Sprite` entities with `Handle<Image>` from `BackgroundImageSet`. `BackgroundImageSet` is populated at startup by `table_plugin::load_background_images` via `AssetServer::load()`.
|
||||
|
||||
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 |
|
||||
|---|---|---|---|
|
||||
| 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+ |
|
||||
| Android | Stretch | Google Play Games + server | `cargo-mobile2`, touch input, GPGS via JNI |
|
||||
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input; GPGS unavailable on iOS |
|
||||
| Android | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
|
||||
| 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`.
|
||||
|
||||
---
|
||||
|
||||
## 17. Build & Development Guide
|
||||
## 16. Build & Development Guide
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -965,7 +893,7 @@ Add `--features bevy/dynamic_linking` during development to dramatically reduce
|
||||
|
||||
---
|
||||
|
||||
## 18. Deployment Guide
|
||||
## 17. Deployment Guide
|
||||
|
||||
### Docker Compose (Recommended)
|
||||
|
||||
@@ -1010,7 +938,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
|
||||
|
||||
---
|
||||
|
||||
## 19. Security Model
|
||||
## 18. Security Model
|
||||
|
||||
| Concern | Mitigation |
|
||||
|---|---|
|
||||
@@ -1026,7 +954,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
|
||||
|
||||
---
|
||||
|
||||
## 20. Testing Strategy
|
||||
## 19. Testing Strategy
|
||||
|
||||
### Unit Tests (`solitaire_core`)
|
||||
|
||||
@@ -1065,12 +993,10 @@ Using `axum::test` and an in-memory SQLite database:
|
||||
- [ ] Achievement toast appears and dismisses
|
||||
- [ ] Server sync: register, login, push, pull on second machine
|
||||
- [ ] 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 |
|
||||
|---|---|---|
|
||||
@@ -1082,7 +1008,10 @@ 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 |
|
||||
| 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 |
|
||||
| `SyncProvider` trait, not `SyncBackend` match arms | Allows adding Google Play Games Services cleanly; `SyncPlugin` stays backend-agnostic and testable | 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 |
|
||||
| `SyncProvider` trait, not `SyncBackend` match arms | `SyncPlugin` stays backend-agnostic and testable; new backends can be added without touching the plugin | 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. 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 |
|
||||
| Card art swapped from xCards (LGPL-3.0) to hayeah/playing-cards-assets (MIT) | Public-release readiness. The previous xCards art carried LGPL relinking obligations that complicate a single-binary distribution; hayeah's set derives from the public-domain `vector-playing-cards` line-art and is permissively MIT-licensed. CREDITS.md license summary collapsed to MIT + OFL-1.1. The default card back is original work in this project's midnight-purple palette. | 2026-05-01 |
|
||||
| Runtime SVG card-theme system (`CARD_PLAN.md`) | User-supplied themes need to ship SVG sources so they can rasterise at any resolution on the player's hardware; baking PNGs at build time only would lock theme installation to the developer. The pipeline (usvg → resvg → tiny-skia) rasterises once per (theme, target size) at load time and caches the resulting `Image`, so the runtime cost is paid once, not per frame. The bundled default theme ships via `embedded://`; user themes via `themes://` rooted at `user_theme_dir()`. | 2026-05-01 |
|
||||
|
||||
@@ -0,0 +1,883 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Solitaire Quest are documented here. The format is
|
||||
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
||||
project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
_Nothing yet._
|
||||
|
||||
## [0.19.0] — 2026-05-06
|
||||
|
||||
Closes the v0.18.0 punch list (items B and D — async hint and
|
||||
persistent replay share URLs), expands desktop platform fit
|
||||
(Wayland session support + monitor-aware default window size for
|
||||
HiDPI / 4K displays), polishes the win-celebration and
|
||||
double-click animation paths, and clears two test-flake
|
||||
contributors. A short-lived "Rusty Pixel" pixel-art card theme
|
||||
was prototyped and reverted in the same window — the engine
|
||||
plumbing it touched (`pixel_art` field on `ThemeMeta`, PNG
|
||||
manifest face support, second `embedded://` theme channel) was
|
||||
fully reverted and is not part of this release.
|
||||
|
||||
### Changed
|
||||
|
||||
- **H-key hint runs on `AsyncComputeTaskPool`** (`3e11e9e`). The
|
||||
synchronous `try_solve_from_state` call on every H press is gone;
|
||||
`handle_keyboard_hint` now spawns a task whose result the new
|
||||
`pending_hint::poll_pending_hint_task` system surfaces one frame
|
||||
later. New `PendingHintTask` resource carries the in-flight handle
|
||||
plus `move_count_at_spawn` for staleness detection;
|
||||
`drop_pending_hint_on_state_change` cancels the task whenever the
|
||||
game state shifts; `PendingHintTask::spawn` implements
|
||||
cancel-on-replace so two quick H presses keep at most one task in
|
||||
flight. Mirrors the v0.18.0 `PendingNewGameSeed` template.
|
||||
`emit_hint_visuals` and `find_heuristic_hint` are extracted as
|
||||
`pub` helpers so the polling system can call them.
|
||||
- **Persistent replay share URLs** (`42d90b1`). v0.18.0's
|
||||
`LastSharedReplayUrl` was an in-memory resource wiped on quit —
|
||||
the player had to share within the session of the win.
|
||||
`solitaire_data::Replay` now carries a `share_url: Option<String>`
|
||||
field with `#[serde(default)]` (no `REPLAY_SCHEMA_VERSION` bump
|
||||
needed; older `replays.json` files load unchanged with `share_url
|
||||
== None` on every entry). `poll_replay_upload_result` writes the
|
||||
resolved URL into `replays[0].share_url` and persists the updated
|
||||
history via `save_replay_history_to`. The Stats overlay's
|
||||
"Copy share link" button reads from
|
||||
`history.0.replays[selected.0].share_url`, so the Prev/Next
|
||||
selector's currently-displayed replay drives the clipboard
|
||||
contents — each historical win keeps its own URL.
|
||||
`LastSharedReplayUrl` removed (its role is now subsumed by the
|
||||
`share_url` field on the replay record).
|
||||
|
||||
### Added
|
||||
|
||||
- **Wayland session support** (`b57db01`). The workspace
|
||||
`Cargo.toml` Bevy feature list now enables `wayland` alongside
|
||||
`x11`. winit prefers Wayland when `WAYLAND_DISPLAY` is set on the
|
||||
session, falling back to X11 when it isn't. Pre-fix, a Wayland
|
||||
desktop environment fell through to XWayland, rendering the
|
||||
game inside an X11 frame stitched into the Wayland compositor.
|
||||
Post-fix, the game opens as a native Wayland surface. Costs a
|
||||
few hundred KB of binary for the libwayland-client bindings;
|
||||
cross-distro friendly because winit dlopen-probes the libraries
|
||||
rather than hard-linking them.
|
||||
- **Monitor-relative default window size** (`b57db01`). On launches
|
||||
with no saved geometry, the new
|
||||
`apply_smart_default_window_size` Update system queries
|
||||
`Monitor` (with the `PrimaryMonitor` marker) and resizes the
|
||||
primary window to ~70 % of the monitor's *logical* size on the
|
||||
first frame. Before, every fresh launch opened at 1280×800
|
||||
regardless of monitor; on a 4K monitor that's a comparatively
|
||||
tiny window in one corner. Logical size already accounts for
|
||||
the OS's HiDPI scale factor, so a Retina display reporting
|
||||
scale_factor 2.0 yields the same physical inches as a 1080p
|
||||
display reporting 1.0. Skipped entirely when saved geometry was
|
||||
applied — the player's chosen size always wins.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Duplicate "You Win" toast on game-won** (`55c235b`). The
|
||||
post-win UI was firing two celebration surfaces: a 4-second
|
||||
toast banner ("You Win! Score: X Time: Y") on top of the
|
||||
`win_summary_plugin`'s "You Won!" modal. In screenshots the
|
||||
toast banner was partially clipped behind the modal card,
|
||||
peeking out on either side. The toast predated the modal and is
|
||||
strictly subsumed by it; removed. The cards-fly-off cascade
|
||||
animation (`MotionCurve::Expressive` per-card rotation drift)
|
||||
is unchanged — that's the visual celebration, distinct from
|
||||
the textual celebration the modal owns. `WIN_TOAST_SECS` const
|
||||
removed.
|
||||
- **Double-click on a single card with no destination now plays
|
||||
the reject animation** (`d7ffb16`). `handle_double_click` only
|
||||
fired `MoveRejectedEvent` for multi-card stacks with no
|
||||
destination; a double-click on a single card whose top didn't
|
||||
fit any foundation or tableau slot produced zero feedback —
|
||||
no `card_invalid.wav`, no source-pile shake. Both priorities'
|
||||
failure paths now converge on a single rejection at the end of
|
||||
the double-click branch, so single-card and stack misses get
|
||||
the same feedback shape as drag-and-drop rejections.
|
||||
- **Double-click move animation no longer plays twice**
|
||||
(`6037596`). On a successful double-click, the slide-to-
|
||||
destination animation rendered twice — once from the move's
|
||||
`StateChangedEvent` landing, then again from the release's
|
||||
`end_drag` firing a redundant `StateChangedEvent` mid-slide.
|
||||
`sync_cards_on_change` saw the card mid-CardAnim (`cur ≠
|
||||
target`) and replaced the in-flight tween with a fresh one
|
||||
starting at the mid-position, visibly restarting the slide. The
|
||||
defensive `StateChangedEvent` write in `end_drag`'s
|
||||
uncommitted-drag branch is removed; `start_drag` only mutates
|
||||
`DragState` (never card transforms), so an uncommitted drag
|
||||
has no visual side effect to undo. The committed-drag branch
|
||||
keeps its `StateChangedEvent` since real drag snap-backs do
|
||||
need a resync.
|
||||
- **`auto_save_writes_after_30_seconds` test flake** (`91b7605`).
|
||||
The test's single-frame `app.update()` was sensitive to
|
||||
first-frame `Time::delta_secs()` variance under heavy parallel
|
||||
cargo-test load, and to production-disk
|
||||
`~/.local/share/solitaire_quest/game_state.json` state leaking
|
||||
into the test world via `GamePlugin::build`'s load path.
|
||||
`test_app` now resets `PendingRestoredGame(None)` after plugin
|
||||
build (preventing the dev machine's saved-game state from
|
||||
tripping the auto-save guard) and the test re-arms the timer in
|
||||
a small bounded loop until the file appears (robust against
|
||||
first-frame Time variance). No production-code change.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1170 passing tests (was 1166 at v0.18.0 close — net +4 from
|
||||
the persistent share URL backwards-compat test, the three
|
||||
async-hint tests, minus the dropped synchronous hint tests).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.18.0] — 2026-05-06
|
||||
|
||||
The launch-experience round. The engine used to drop the player on a
|
||||
silent default Classic deal whether they had unfinished work or not;
|
||||
v0.18.0 replaces that with two stacked decision points — a Restore
|
||||
prompt for in-progress saves, then an MSSC-style Home / mode picker
|
||||
that surfaces Daily / Zen / Challenge / Time Attack as picture tiles
|
||||
with live stats. The same round closes the last solver-on-main-thread
|
||||
hot path (winnable-only seed selection moves to
|
||||
`AsyncComputeTaskPool`), wires "Copy share link" into Stats, lights a
|
||||
"Won before" HUD chip on re-deals of beaten seeds, and tidies the
|
||||
unified-3.0 rule set across CLAUDE.md / CLAUDE_SPEC.md /
|
||||
CLAUDE_WORKFLOW.md / CLAUDE_PROMPT_PACK.md.
|
||||
|
||||
### Added
|
||||
|
||||
- **Restore prompt on launch** (`3c7a0eb`). When `game_state.json`
|
||||
holds an in-progress game (`move_count > 0`, not won), the engine
|
||||
now seeds `GameStateResource` with a fresh deal and holds the saved
|
||||
game in a new `PendingRestoredGame` resource. After the splash
|
||||
clears, a "Welcome back" modal offers **Continue** (Enter / C /
|
||||
click) or **New game** (N / click). Fresh-deal saves
|
||||
(`move_count == 0`) skip the prompt and load directly.
|
||||
- **Save preservation while the prompt is unanswered** (`f863d85`).
|
||||
Both `save_game_state_on_exit` and `auto_save_game_state` consult
|
||||
`PendingRestoredGame` first: if it still holds a pending saved
|
||||
game, that's what gets persisted (or the auto-save is skipped),
|
||||
so exiting before answering the prompt no longer overwrites the
|
||||
meaningful save with the placeholder fresh deal.
|
||||
- **Home / mode picker auto-shows on launch** (`dd63261`). The mode
|
||||
picker was only reachable via **M** during gameplay; players who
|
||||
hadn't discovered the hotkey never saw the Daily / Zen / Challenge
|
||||
/ Time Attack entry points after the splash cleared. `HomePlugin`
|
||||
gains an `auto_show_on_launch` flag (default true) and a
|
||||
one-shot `LaunchHomeShown` gate. Skips when the Restore prompt is
|
||||
on screen so Welcome-back still takes precedence.
|
||||
- **MSSC-style Home picker — header / chips / score chips / draw
|
||||
mode** (`ae40a1d`). Player-stats header strip (Level / XP /
|
||||
Lifetime Score, compact-formatted as `1.2M` / `12.3K` / `1,234`)
|
||||
acts as a clickable shortcut to Profile. Draw-mode chip row above
|
||||
the mode cards lets the player flip Draw 1 / Draw 3 from the
|
||||
picker itself; persists `settings.json` and respawns the modal so
|
||||
the active state repaints cleanly. Per-mode best-score / streak
|
||||
chips on each card; hidden on a 0 best so a fresh profile doesn't
|
||||
read "Best 0" everywhere.
|
||||
- **Today's Event callout on the Daily card** (`b73d246`). "Today,
|
||||
May 6" date line plus the server-fetched goal (when SyncPlugin is
|
||||
wired). Once today's daily is recorded as completed, the date
|
||||
flips to `Today, May 6 • Done` in `ACCENT_PRIMARY` so the picker
|
||||
reads as a reward state rather than a TODO.
|
||||
- **Picture-tile mode cards** (`9fe650f` + glyph-picking follow-ups
|
||||
`40d6e0a`, `c30b04e`, `d065d49`). Mode cards become a wrapping
|
||||
2-up grid (`FlexWrap::Wrap`, tiles 48 % wide, `min_height: 180px`)
|
||||
with a centred Unicode-glyph centrepiece per tile. Final glyph set
|
||||
picked from FiraMono-Medium's actual coverage: ♣ Classic, ◆ Daily,
|
||||
○ Zen, ▲ Challenge, → TimeAttack. `ACCENT_PRIMARY` when the mode is
|
||||
unlocked, `TEXT_DISABLED` when locked. Centrepiece is a `Text` node
|
||||
for now — when real per-mode artwork lands, swap to `Image` without
|
||||
touching tile layout, focus order, or chip rendering.
|
||||
- **Solver-vetted seed selection on `AsyncComputeTaskPool`**
|
||||
(`d489e7a`). Closes the worst-case 6 s UI stall on a New Game
|
||||
click with "Winnable deals only" enabled. New `PendingNewGameSeed`
|
||||
resource holds the in-flight `Task<u64>` plus the original
|
||||
request's `mode` / `confirmed` flags. `poll_pending_new_game_seed`
|
||||
runs `.before(GameMutation)` and replays a synthetic
|
||||
`NewGameRequestEvent` once the task resolves — the player sees no
|
||||
extra-frame visual lag. Cancel-on-replace: a fresh
|
||||
`NewGameRequestEvent` while a task is in flight drops the old
|
||||
task, letting Bevy's `Task` Drop cancel cooperatively at the next
|
||||
await point.
|
||||
- **"Won before" HUD indicator** (`bdac754`). When the current
|
||||
deal's `(seed, draw_mode, mode)` triple matches an entry in the
|
||||
rolling `ReplayHistory`, the HUD's tier-2 context row shows
|
||||
**✓ Won before** in `STATE_SUCCESS`. Cleared on win (the on-screen
|
||||
victory cue is enough) and on first-time deals. New
|
||||
`HudWonPreviously` marker driven by a separate
|
||||
`update_won_previously` system; gracefully no-ops in headless
|
||||
tests that don't load `StatsPlugin`.
|
||||
- **"Copy share link" Stats button** (`540869c`). End-to-end replay
|
||||
sharing on a server-backed sync backend:
|
||||
`sync_plugin::push_replay_on_win` spawns the upload on
|
||||
`AsyncComputeTaskPool` and stores the handle in
|
||||
`PendingReplayUpload` (drops any in-flight predecessor — the most
|
||||
recent win is what the player wants the link for);
|
||||
`poll_replay_upload_result` writes `<server>/replays/<id>` to
|
||||
`LastSharedReplayUrl` on success; the Stats overlay's action bar
|
||||
gains a button that writes the URL to the OS clipboard via
|
||||
`arboard` and surfaces a "Copied: \<url\>" toast. URL is in-memory
|
||||
only — sharing must happen within the session of the win.
|
||||
- **Empty-state copy + onboarding hints** (`56e2e6f`). Leaderboard
|
||||
empty state: two-tier "Be the first on the leaderboard." headline
|
||||
+ body invite. Achievements panel: first-launch hint above the
|
||||
grid until the first unlock. Volume hotkeys (`[` / `]`) now emit
|
||||
an `InfoToastEvent` with the new percentage so off-panel
|
||||
adjustments give visible feedback (previously silent).
|
||||
- **Enter dismisses the Win Summary and starts a fresh deal**
|
||||
(`17e0737`). The post-win modal's "Play Again" was click-only;
|
||||
keyboard-only players had to reach for the mouse to leave the
|
||||
celebration screen. The button label gains a trailing return-key
|
||||
glyph so the keyboard path is discoverable on first sight.
|
||||
- **`N` opens the real Confirm/Cancel modal** (`93660c2`). The old
|
||||
"Press N again" double-tap pattern was a UI-first violation (only
|
||||
continuation was another keystroke). `N` now fires
|
||||
`NewGameRequestEvent::default()` directly; `handle_new_game`'s
|
||||
active-game check spawns the existing `ConfirmNewGameScreen`. The
|
||||
HUD button already routed through the same modal — keyboard and
|
||||
mouse paths are unified. `Shift+N` keeps the keyboard power-user
|
||||
bypass (`confirmed: true`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Settings row layout** (`a4bc063`). All five
|
||||
slider/toggle row helpers (volume × 2, tooltip delay, time-bonus
|
||||
multiplier, replay-move interval, generic toggle) restructured to
|
||||
a label-spacer-cluster layout (`width: 100%`, label gets
|
||||
`flex-grow: 1`, controls cluster sits flush right). Stable across
|
||||
varying value-text widths ("0.80" → "1.00", "Instant" vs "1.5 s")
|
||||
and narrow windows.
|
||||
- **Docs adopt the unified-3.0 rule set** (`f2f30c8`). `CLAUDE.md`
|
||||
grows from a 114-line pointer doc to a 571-line rulebook (hard
|
||||
global constraints §2, engine rules §3, asset rules §4, code
|
||||
standards §5, build + verification §6, git workflow §7, the ASK
|
||||
BEFORE list §8, Context Injection System §14). New companions:
|
||||
`CLAUDE_SPEC.md` (formal architecture spec — crate dependency
|
||||
graph, data ownership, state-machine invariants, sync merge /
|
||||
server contracts, validation checklist),
|
||||
`CLAUDE_WORKFLOW.md` (two-agent Builder/Guardian pipeline with
|
||||
hard-fail patterns), `CLAUDE_PROMPT_PACK.md` (task-type
|
||||
templates). Three duplicate rule passages removed across
|
||||
`CLAUDE_SPEC.md` and `ARCHITECTURE.md`.
|
||||
- **Test discipline pruning** (`a49a340`). Removed 43 low-value
|
||||
tests across `solitaire_data` and `solitaire_core` (default-value
|
||||
tests, serde-derive round-trips on plain structs, single-field
|
||||
clamp tests, near-duplicates, constant-equals-itself tests). None
|
||||
pinned a behaviour contract or a regression on a real bug. Future
|
||||
agent briefs request tests for behaviour contracts or real-bug
|
||||
regressions, not a count of N.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Esc on a modal no longer opens Pause underneath** (`08b006f`).
|
||||
A single Esc press on Confirm New Game / Restore / Home /
|
||||
Onboarding / Settings used to both close the modal and spawn the
|
||||
Pause overlay on top in the same frame. `toggle_pause` now skips
|
||||
when any non-Pause `ModalScrim` is in the world; the HUD-button
|
||||
path is gated too. The four modal queries are bundled into a
|
||||
`PauseModalQueries` `SystemParam` to stay under Bevy's
|
||||
16-parameter cap.
|
||||
- **Esc dismisses Home / accepts the Restore-prompt default**
|
||||
(`d48b948`). Both screens previously ignored Esc, leaving the
|
||||
player no keyboard-only escape after the previous fix. Home: Esc
|
||||
behaves like Cancel (despawns the modal, keeps the underlying
|
||||
default deal). Restore: Esc maps to Continue (preserves the saved
|
||||
game, matching how the primary action already advertises Enter).
|
||||
- **Esc dismisses the topmost modal when Profile stacks on Home**
|
||||
(`9aa0dd2`). Clicking the Home header chip opens Profile on top
|
||||
of Home; Esc used to close Home (because
|
||||
`handle_home_cancel_button` fired with no awareness of layered
|
||||
modals) and leave Profile orphaned over the game.
|
||||
`profile_plugin` now splits P/button (toggle) from Esc
|
||||
(close-only); `handle_home_cancel_button` skips its Esc branch
|
||||
when any other `ModalScrim` exists.
|
||||
- **Restore-prompt resolution suppresses Home auto-show**
|
||||
(`b7c3a49`). Resolving the Welcome-back prompt cleared
|
||||
`PendingRestoredGame` and despawned the modal, but the
|
||||
launch-time Home auto-show then fired the next frame and stacked
|
||||
itself over the player's chosen path. `LaunchHomeShown` becomes
|
||||
`pub` so `handle_restore_prompt` flips it to `true` after either
|
||||
resolution; **M** still re-opens the picker on demand.
|
||||
- **Game timers freeze while the Home picker is up** (`c497c31`).
|
||||
The HUD's elapsed-time counter ticked from the moment the default
|
||||
Classic deal landed at startup, even though the auto-show Home
|
||||
picker was still up — the player saw "0:11" before they had
|
||||
chosen a mode. `tick_elapsed_time` and `advance_time_attack` now
|
||||
also gate on the absence of `HomeScreen`, mirroring their
|
||||
existing `PausedResource` check.
|
||||
- **Popover rows stay visible regardless of action-bar fade**
|
||||
(`cc63532`). Opening Modes / Menu showed a solid dark-purple
|
||||
block in the top-right with no readable content — the action-bar
|
||||
auto-fade was matching the popover rows by their shared
|
||||
`ActionButton` marker and dropping their alpha to the
|
||||
cursor-position-based fade value (typically 0). New `PopoverRow`
|
||||
marker on rows in `spawn_modes_popover` / `spawn_menu_popover`;
|
||||
`apply_action_fade` excludes them via `Without<PopoverRow>`.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1166 passing tests (was 1208 at v0.17.0 close — 43 net removals
|
||||
from the test-discipline prune plus 1 net-new test from the
|
||||
async-seed work, no behaviour regressions).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.17.0] — 2026-05-06
|
||||
|
||||
A short follow-up round on top of v0.16.0: the H-key hint is no
|
||||
longer a heuristic guess but the actual best first move suggested by
|
||||
the v0.15.0 solver, and the in-engine replay player now has a
|
||||
player-tunable playback rate.
|
||||
|
||||
### Added
|
||||
|
||||
- **Replay-rate slider** in Settings → Gameplay. Tunes
|
||||
`replay_move_interval_secs` from 0.10 s to 1.00 s in 0.05 s steps;
|
||||
default 0.45 s. `tick_replay_playback` reads the value from
|
||||
`SettingsResource` per frame so the slider takes effect on the
|
||||
next playback tick — no restart required.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Solver-driven hints.** Pressing **H** used to surface a
|
||||
heuristic-best move (foundation moves preferred, then
|
||||
tableau-to-tableau by depth-of-flip-revealed). It now asks the
|
||||
v0.15.0 solver for the actual provably-best first move via the
|
||||
new `solitaire_core::solver::try_solve_with_first_move` /
|
||||
`try_solve_from_state` APIs. When the solver returns inconclusive
|
||||
(rare deals where the bound runs out before a result), the old
|
||||
heuristic remains the fallback. Median 2 ms per H press.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1208 passing tests (was 1196 at v0.16.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.16.0] — 2026-05-06
|
||||
|
||||
A modal-feel polish round. Every overlay screen now scrolls when its
|
||||
content overflows the 800×600 minimum window, every clickable button
|
||||
shows a hand cursor on hover, keyboard focus lands on the primary
|
||||
button on the same frame the modal opens, and read-only modals
|
||||
dismiss when the player clicks the scrim outside the card.
|
||||
|
||||
### Added
|
||||
|
||||
- **Pointer cursor on hover** for every interactive `Button` entity
|
||||
(modal buttons, HUD action bar, mode-launcher cards, settings
|
||||
toggles, Stats selectors). `update_cursor_icon` gains a fourth
|
||||
branch sitting between Grabbing (active drag) and Grab
|
||||
(draggable card hover): when no drag is active and any
|
||||
`Interaction::Hovered`/`Pressed` button is detected, the window
|
||||
cursor swaps to `SystemCursorIcon::Pointer`. A pure
|
||||
`pick_cursor_icon` helper makes the priority logic
|
||||
unit-testable.
|
||||
- **Click-outside-to-dismiss** for the six read-only modals: Stats,
|
||||
Achievements, Help, Profile, Leaderboard, Home. New
|
||||
`ScrimDismissible` marker on `ModalScrim` opts a modal in;
|
||||
`dismiss_modal_on_scrim_click` runs in `Update`, despawns the
|
||||
topmost dismissible scrim on a left-mouse press whose cursor
|
||||
lands on the scrim and outside every `ModalCard`. Bevy's
|
||||
hierarchy despawn cascades to the card and children.
|
||||
Settings, Onboarding, Pause, Forfeit confirm, and Confirm New
|
||||
Game intentionally don't opt in — they carry unsaved or
|
||||
destructive state.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Modal content scrolls when it overflows** (Achievements, Help,
|
||||
Stats, Profile, Leaderboard). Each modal's body Node now
|
||||
carries `Overflow::scroll_y()` plus a `max_height` constraint
|
||||
(`Val::Vh(70.0)` for most, `Val::Vh(50.0)` for the
|
||||
leaderboard's variable-length ranking section) and a marker
|
||||
component (`AchievementsScrollable`, `HelpScrollable`,
|
||||
`StatsScrollable`, `ProfileScrollable`,
|
||||
`LeaderboardScrollable`). A sibling `scroll_*_panel` system
|
||||
per modal routes `MouseWheel` events into the body's
|
||||
`ScrollPosition`. Mirrors the existing `SettingsPanelScrollable`
|
||||
pattern. Home modal intentionally not scrolled — its five
|
||||
mode cards + Cancel are sized to fit at 800×600 by design.
|
||||
- **Modal focus arrives on the same frame the modal opens.**
|
||||
Previously `attach_focusable_to_modal_buttons` and
|
||||
`auto_focus_on_modal_open` ran in `Update` alongside arbitrary
|
||||
click-handlers that spawn modals; with no ordering edge,
|
||||
Bevy's deferred `Commands` queued the new entities but the
|
||||
attach system couldn't see them on the same tick. Both systems
|
||||
moved to `PostUpdate` so the schedule boundary itself supplies
|
||||
the sync point — `FocusedButton` is always populated before
|
||||
`app.update()` returns. The very next Tab/Enter press lands on
|
||||
a populated resource instead of wasting itself moving focus
|
||||
from None to the primary.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1196 passing tests (was 1178 at v0.15.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.15.0] — 2026-05-02
|
||||
|
||||
In-engine replay playback, the Klondike solver + "Winnable deals
|
||||
only" toggle, a 19th achievement, rolling replay history, and a
|
||||
significant build-time / binary-size win from disabling Bevy's
|
||||
default audio stack.
|
||||
|
||||
### Added
|
||||
|
||||
- **In-engine replay playback** for the Stats overlay's Watch Replay
|
||||
button. New `ReplayPlaybackPlugin` runs a state machine
|
||||
(Inactive / Playing / Completed) that resets the live game to the
|
||||
recorded deal and ticks through `replay.moves` at
|
||||
`REPLAY_MOVE_INTERVAL_SECS` (0.45 s) firing the canonical
|
||||
`MoveRequestEvent` / `DrawRequestEvent` per recorded move.
|
||||
Recording is suppressed during playback so replays don't re-record
|
||||
themselves.
|
||||
- **Replay overlay banner** (`ReplayOverlayPlugin`) anchored to the
|
||||
top of the window during playback. Shows "Replay" label, "Move N
|
||||
of M" progress, and a Stop button. Z-order leaves modals
|
||||
(Settings, Pause, Help) free to render on top so the player can
|
||||
adjust audio mid-replay.
|
||||
- **Rolling replay history** at `<data_dir>/replays.json` capped at
|
||||
8 entries. Replaces the single-slot `latest_replay.json` (legacy
|
||||
file is migrated forward on first launch via
|
||||
`migrate_legacy_latest_replay`). Stats overlay gains a Prev / Next
|
||||
selector and a "Replay N / M" caption so the player can revisit
|
||||
older wins.
|
||||
- **"Cinephile" achievement** (#19). Unlocks the first time
|
||||
`ReplayPlaybackState` transitions Playing → Completed (i.e. the
|
||||
replay played out to its end without the player pressing Stop).
|
||||
Stop transitions Playing → Inactive directly so it doesn't count.
|
||||
- **Klondike solver** in `solitaire_core::solver`. Iterative-DFS
|
||||
with memoisation on a 64-bit canonical state hash, two budget
|
||||
knobs (move_budget + state_budget) for pathological cases, and a
|
||||
three-state `SolverResult` (Winnable / Unwinnable / Inconclusive).
|
||||
Median solve time 2 ms; pathological inconclusives cap near
|
||||
120 ms. Pure logic — `solitaire_core` keeps no Bevy or I/O.
|
||||
- **"Winnable deals only" toggle** in Settings → Gameplay (default
|
||||
off). When on, `handle_new_game` walks seed N, N+1, N+2, …
|
||||
through `try_solve` until it finds Winnable or Inconclusive,
|
||||
capped at `SOLVER_DEAL_RETRY_CAP` (50) attempts. Daily
|
||||
challenges, replays, and explicit-seed requests bypass the
|
||||
solver — only random Classic deals are gated.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Bevy default-feature trim** (`bevy = { default-features = false,
|
||||
features = [...] }` in workspace Cargo.toml) drops 51 transitive
|
||||
crates including the `bevy_audio` → rodio → cpal 0.15 + symphonia
|
||||
chain that the project doesn't use (kira handles audio directly).
|
||||
The retained feature list is curated to exactly what the engine
|
||||
uses; `solitaire_wasm` is unaffected because it doesn't depend on
|
||||
bevy.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1178 passing tests (was 1134 at v0.14.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.14.0] — 2026-05-02
|
||||
|
||||
Two threads land in v0.14.0: the second half of the post-v0.12.0 UX
|
||||
candidate list (theme thumbnails, daily-challenge calendar, Time Attack
|
||||
auto-save, per-mode bests, time-bonus multiplier) plus a **major new
|
||||
feature** — the replay pipeline (record → upload → web viewer). Three
|
||||
Quat-reported bugs from a smoke-test round shipped alongside.
|
||||
|
||||
### Added
|
||||
|
||||
- **Theme-picker thumbnails** in Settings → Cosmetic. Each theme chip
|
||||
renders a small Ace-of-Spades + back preview pair via the existing
|
||||
`rasterize_svg` path. Cached per theme in a new
|
||||
`ThemeThumbnailCache`. Themes that lack a preview SVG fall back to
|
||||
a transparent placeholder rather than crashing.
|
||||
- **14-day daily-challenge calendar** in the Profile modal. Horizontal
|
||||
row of dots showing the trailing two weeks; today's dot is ringed
|
||||
in `ACCENT_PRIMARY`, completed days fill `STATE_SUCCESS`, missed
|
||||
days fill `BG_ELEVATED`. Caption above the row reads "Current
|
||||
streak: N · Longest: M".
|
||||
- **Time Attack session auto-save** to `<data_dir>/time_attack_session.json`,
|
||||
atomic .tmp + rename. 30-second auto-save while a session is active,
|
||||
plus on `AppExit`. Sessions whose 10-minute window expired in real
|
||||
time while the app was closed are discarded on load. Classic, Zen,
|
||||
and Challenge already auto-saved correctly via `game_state.json` —
|
||||
Time Attack was the only mode missing session-level persistence.
|
||||
- **Per-mode best-score and fastest-win readouts** in the Stats screen.
|
||||
`StatsSnapshot` gains six `#[serde(default)]` fields (Classic / Zen
|
||||
/ Challenge × best_score + fastest_win_seconds). Stats screen renders
|
||||
a "Per-mode bests" section between the primary cell grid and
|
||||
progression. Lifetime totals continue to roll all modes together.
|
||||
- **Time-bonus multiplier slider** in Settings → Gameplay (0.0–2.0,
|
||||
0.1 steps, default 1.0, "Off" label at zero). Cosmetic only —
|
||||
multiplies the time-bonus shown in the win modal but does NOT
|
||||
affect achievement unlock thresholds (those still use the raw
|
||||
unmultiplied score).
|
||||
- **Win-replay recording + storage.** Every move during a successful
|
||||
game appends to a `RecordingReplay` resource; on `GameWonEvent`
|
||||
the recording freezes into a `Replay` (seed + draw_mode + mode +
|
||||
score + time + ordered move list) and persists to
|
||||
`<data_dir>/latest_replay.json` atomically. Single-slot — overwrites
|
||||
on every win.
|
||||
- **"Watch replay" button** in the Stats overlay. Shows the latest
|
||||
win's caption and surfaces a button that loads the replay (button
|
||||
fires an `InfoToastEvent` describing the replay; full in-engine
|
||||
playback is deferred to a future build).
|
||||
- **Replay upload + fetch endpoints** on the server. `POST /api/replays`
|
||||
accepts a `Replay` JSON; `GET /api/replays/:id` returns it. JWT-gated
|
||||
with the existing auth middleware. Engine uploads winning replays
|
||||
automatically when the player has cloud sync configured.
|
||||
- **`solitaire_wasm` crate** — new workspace member compiling
|
||||
replay-relevant `solitaire_core` types to WebAssembly so a
|
||||
browser can re-execute a replay client-side. No-std-friendly
|
||||
surface; `wasm-bindgen` glue.
|
||||
- **Web replay viewer** served from the Solitaire server.
|
||||
`GET /replays/:id` returns HTML + CSS + the wasm bundle that
|
||||
fetches the replay JSON, rasterises a deal from the seed, and
|
||||
animates the recorded moves.
|
||||
- **Card flight animations on the web side** so the browser viewer
|
||||
reads as a real game replay rather than a static dump.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Multi-card lift validation.** `solitaire_core::rules::is_valid_tableau_sequence`
|
||||
rejects a moved stack whose adjacent cards don't form a descending
|
||||
alternating-colour run. Previously a player could lift any
|
||||
multi-card selection and drop it as long as the bottom landed
|
||||
legally. Wired into `move_cards`'s tableau-destination branch.
|
||||
- **Softlock detection.** `has_legal_moves` rewritten to walk every
|
||||
potential move source (every stock card, every waste card, the
|
||||
face-up top of every tableau column) and check it against every
|
||||
foundation and every tableau. Previously the heuristic
|
||||
early-returned `true` whenever stock had cards — players got
|
||||
stuck in unwinnable end-states with no end-game screen.
|
||||
`GameOverScreen` now actually fires for true softlocks. Quat's
|
||||
exact reproduction case is pinned by a new test.
|
||||
- **Deal-tween information leak.** New-game now snaps every card
|
||||
sprite to the stock pile position before writing
|
||||
`StateChangedEvent`, so all 52 cards animate from a single point
|
||||
during the deal. Previously the sprites started from their
|
||||
previous-game positions, briefly revealing the prior deal.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `SESSION_HANDOFF.md` refreshed for the Quat smoke-test round
|
||||
including investigation findings on solver decisions and
|
||||
dependency duplicates.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1134 passing tests (was 1053 at v0.13.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.13.0] — 2026-05-02
|
||||
|
||||
Third UX iteration round on top of v0.12.0. Six handoff candidates
|
||||
shipped — three small polish items, three larger interaction
|
||||
features (theme-aware backs, full keyboard play, right-click power
|
||||
shortcut). Plus two code-review fixes (font handling unified,
|
||||
sccache wiring removed).
|
||||
|
||||
### Added
|
||||
|
||||
- **Tooltip-delay slider** in Settings → Gameplay. `tooltip_delay_secs`
|
||||
ranges [0.0, 1.5] in 0.1 s steps; "Instant" label when zero.
|
||||
`Settings.tooltip_delay_secs` round-trips through serialise/deserialise
|
||||
with `#[serde(default)]`. The hover-delay comparison in
|
||||
`ui_tooltip` reads from `SettingsResource` with the existing
|
||||
`MOTION_TOOLTIP_DELAY_SECS` as the test-fixture fallback.
|
||||
- **Win-streak fire animation.** New `WinStreakMilestoneEvent` fires
|
||||
from `stats_plugin` when `win_streak_current` crosses any of
|
||||
[3, 5, 10] (only the threshold crossing — not every subsequent
|
||||
win). The HUD streak readout scale-pulses 1.0 → 1.20 → 1.0 over
|
||||
`MOTION_STREAK_FLOURISH_SECS` (0.6 s).
|
||||
- **Score-breakdown reveal on the win modal.** Replaces the single
|
||||
"Score: N" line with a per-component reveal (Base / Time bonus /
|
||||
No-undo bonus / Mode multiplier / Total). Rows fade in over
|
||||
`MOTION_SCORE_BREAKDOWN_FADE_SECS` (0.12 s) staggered by
|
||||
`MOTION_SCORE_BREAKDOWN_STAGGER_SECS` (0.15 s). Honours
|
||||
`AnimSpeed::Instant` by spawning all rows fully visible.
|
||||
- **Card backs follow the active theme.** `theme.ron`'s `back` slot
|
||||
now actually drives the face-down sprite. Active-theme back
|
||||
rasterises alongside the faces and supersedes the legacy
|
||||
`back_N.png` picker. The picker remains as a fallback for themes
|
||||
that don't ship a back, and the Settings UI surfaces a caption
|
||||
("Active theme provides its own back") + dimmed swatches when
|
||||
the override is in effect.
|
||||
- **Keyboard-only drag-and-drop.** Tab cycles draggable card stacks,
|
||||
Enter "lifts" the focused stack, arrow keys (or Tab) cycle the
|
||||
legal-destination targets only, Enter confirms, Esc cancels. A
|
||||
new `KeyboardDragState` resource models the two-mode flow without
|
||||
changing the existing `SelectionState` contract. Mutual exclusion
|
||||
with mouse drag uses a sentinel `DragState.active_touch_id =
|
||||
KEYBOARD_DRAG_TOUCH_ID` (u64::MAX) so neither pipeline can
|
||||
trample the other.
|
||||
- **Right-click radial menu.** Hold right-click on a face-up card →
|
||||
a small ring of icons appears at the cursor with one entry per
|
||||
legal destination. Release over an icon → fires
|
||||
`MoveRequestEvent`; release in dead space, Esc, or left-click
|
||||
cancels. Skips the drag motion entirely. New `RadialMenuPlugin`
|
||||
owns the flow; co-exists with the existing `RightClickHighlight`
|
||||
pile-marker tint.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Font handling consolidated to bundled-only.** Code-review
|
||||
feedback: the SVG rasteriser previously mixed
|
||||
`load_system_fonts` + bundled FiraMono + a lenient resolver,
|
||||
which made card text rendering depend on host fontconfig. Picked
|
||||
option (a) and applied it across both layers — `font_plugin` now
|
||||
embeds `assets/fonts/main.ttf` via `include_bytes!()` and
|
||||
registers it with `Assets<Font>`; `svg_loader::shared_fontdb`
|
||||
loads only the bundled bytes; the new `bundled_font_resolver`
|
||||
ignores the SVG's `font-family` request and always returns the
|
||||
single bundled face. A parse failure aborts with a clear error
|
||||
("bundled FiraMono failed to parse — binary is corrupt").
|
||||
|
||||
### Removed
|
||||
|
||||
- **Project-level sccache wiring.** Code-review feedback: sccache
|
||||
shouldn't be a per-project build dependency. Cargo's incremental
|
||||
cache already covers the single-project case, and forcing
|
||||
`rustc-wrapper = "sccache"` workspace-wide meant every contributor
|
||||
had to install it. `.cargo/config.toml` deleted entirely; plain
|
||||
`cargo build` now works without setup.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `help_plugin` controls reference gains a "Mouse" section covering
|
||||
double-click auto-move, right-click highlight, and the new
|
||||
hold-RMB radial.
|
||||
- `help_plugin` also gains a "Keyboard drag" section for the new
|
||||
Tab/Enter/Arrows/Esc flow.
|
||||
- Onboarding slide 3 picks up a `Tab → Enter` row referencing the
|
||||
full keyboard drag path.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1053 passing tests (was 1031 at v0.12.0 close).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.12.0] — 2026-05-02
|
||||
|
||||
UX feel polish round on top of v0.11.0. Six small-but-tangible
|
||||
improvements that make the play surface feel more responsive,
|
||||
forgiving, and discoverable, plus the doc refresh that should have
|
||||
ridden along with v0.11.0.
|
||||
|
||||
### Added
|
||||
|
||||
- **Foundation completion flourish.** When a King lands on a
|
||||
foundation (Ace-through-King for that suit), a brief celebration
|
||||
fires: King card scale-pulses 1.0 → 1.15 → 1.0 over 0.4 s, the
|
||||
foundation marker tints `STATE_SUCCESS` for the first half then
|
||||
fades, and a synthesised C6→E6→G6 bell ping plays (~240 ms,
|
||||
octave above `win_fanfare`'s root so the fourth completion + win
|
||||
cascade layer cleanly). New `FoundationCompletedEvent { slot,
|
||||
suit }` carries the trigger so future systems can hook in.
|
||||
- **Drag-cancel return tween.** Illegal drops glide each dragged
|
||||
card back to its origin slot over 150 ms with a quintic ease-out
|
||||
curve (`MotionCurve::Responsive`, zero overshoot — reads forgiving
|
||||
rather than jittery). The audio cue (`card_invalid.wav`) still
|
||||
fires for negative feedback. Right-click and double-click invalid
|
||||
paths still use `ShakeAnim` since there's no motion to interpolate.
|
||||
- **Focus ring breathing.** The keyboard focus ring's alpha modulates
|
||||
with a 1.4 s sin curve over [0.65, 1.0] of its native value so the
|
||||
indicator catches the eye on focus changes without competing with
|
||||
gameplay. Honours `AnimSpeed::Instant` by reverting to the static
|
||||
outline for reduced-motion users.
|
||||
- **First-win achievement onboarding toast.** After the player's
|
||||
very first win, a one-shot info toast surfaces "First win! Press
|
||||
A to see your achievements." `Settings.shown_achievement_onboarding`
|
||||
persists the seen state so the cue never re-fires (legacy
|
||||
`settings.json` files load to `false` via `#[serde(default)]`).
|
||||
- **Mode Launcher digit shortcuts.** Pressing M opens the Home modal
|
||||
(the Mode Launcher); inside it, pressing 1–5 launches each mode
|
||||
directly without needing Tab + Enter. Locked modes (Zen, Challenge,
|
||||
Time Attack at level < 5) are silent no-ops. Modal-scoped — digit
|
||||
keys outside the launcher fire nothing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Card aspect ratio matches hayeah SVGs.** `CARD_ASPECT` 1.4 →
|
||||
1.4523 to match the bundled artwork's natural 167.087 × 242.667
|
||||
dimensions. Cards previously rendered ~3.6 % vertically squashed.
|
||||
The vertical-budget math in `compute_layout` uses `CARD_ASPECT`
|
||||
algebraically so the worst-case-tableau-fits-on-screen guarantee
|
||||
adapts automatically.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **README refresh** with v0.11.0+ features (card themes, HUD
|
||||
overhaul, drag feel, unlocked foundations) and a corrected controls
|
||||
table — the previous table inverted Z/U for undo and listed H for
|
||||
help when F1 is the binding.
|
||||
- **CHANGELOG.md** added (this file), covering v0.9.0–v0.12.0 with
|
||||
Keep a Changelog 1.1.0 conventions.
|
||||
|
||||
### Stats
|
||||
|
||||
- 1007 passing tests (was 982 at v0.11.0).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.11.0] — 2026-05-02
|
||||
|
||||
The biggest release since 0.10.0. Headline threads: a runtime card-theme
|
||||
system, an HUD restructure that reclaims the play surface, and a round of
|
||||
UX feel polish surfaced by smoke testing.
|
||||
|
||||
### Added
|
||||
|
||||
- **Runtime card-theme system** (CARD_PLAN phases 1–7).
|
||||
- Bundled default theme ships in the binary via `embedded://` — 52
|
||||
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
|
||||
SVGs (MIT) plus a midnight-purple `back.svg` as original work.
|
||||
- User themes live under `themes://` rooted at `user_theme_dir()`. Drop
|
||||
a directory containing `theme.ron` + 53 SVGs and the registry picks
|
||||
it up on next launch.
|
||||
- Importer at `solitaire_engine::theme::import_theme(zip)` validates
|
||||
archives (20 MB cap, zip-slip rejection, manifest validation, every
|
||||
SVG round-tripped through the rasteriser) and atomically unpacks.
|
||||
- Picker UI in **Settings → Cosmetic**; selection persists as
|
||||
`selected_theme_id` and propagates to live sprites.
|
||||
- **Reserved HUD top band** (64 px) so cards no longer crowd the score
|
||||
readout or action buttons; layout's `top_y` shifts down accordingly.
|
||||
- **Action-bar auto-fade** — buttons fade out when the cursor leaves the
|
||||
band, fade back in when it returns. Lerp at ~167 ms.
|
||||
- **Visible drop-target overlay during drag** — a soft fill plus 3 px
|
||||
outline drawn ABOVE stacked cards for every legal target (full fanned
|
||||
column for tableaux, card-sized for foundations and empty tableaux).
|
||||
Replaces the previously invisible pile-marker tint.
|
||||
- **Card drop shadows** — every card casts a neutral 25 % black shadow
|
||||
with a 4 px halo; cards in the active drag set switch to a lifted
|
||||
shadow (40 % alpha, larger offset, bigger halo).
|
||||
- **Stock remaining-count badge** — small `·N` chip at the top-right of
|
||||
the stock pile so the player can see how close they are to a recycle.
|
||||
Hides when the stock empties.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Foundations are unlocked.** `PileType::Foundation(Suit)` →
|
||||
`Foundation(u8)` (slot 0..3). The claimed suit is derived from the
|
||||
bottom card via `Pile::claimed_suit()` — no separate field, no
|
||||
claim-stuck-after-undo bugs. Any Ace lands in any empty slot, and the
|
||||
slot then claims that suit. `next_auto_complete_move` prefers a
|
||||
claim-matched slot before falling back to the first empty slot for
|
||||
Aces. Empty foundation markers render as plain placeholders (no
|
||||
"C/D/H/S").
|
||||
- **HUD selection label** and **hint toast** read `claimed_suit()` and
|
||||
fall through to "Foundation N" / "move to foundation" only when the
|
||||
slot is empty.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`shared_fontdb` now bundles FiraMono.** The hayeah SVGs reference
|
||||
`Bitstream Vera Sans` and `Arial` by name. On minimal Linux installs
|
||||
/ fresh Wayland sessions / chroots where neither is installed AND the
|
||||
CSS-generic aliases don't resolve, card rank/suit text vanished. The
|
||||
bundled font is loaded into fontdb and pinned as every CSS generic's
|
||||
target so the resolver always lands on something real. Surfaced when
|
||||
a second-machine pull rendered cards without glyphs.
|
||||
- **Theme asset path resolution** — `AssetPath::resolve` (concatenates)
|
||||
→ `resolve_embed` (RFC 1808 sibling resolution). Was producing paths
|
||||
like `…/theme.ron/hearts_4.svg` and failing to load every face SVG.
|
||||
- **Sync exit log spam** — `push_on_exit` silently no-ops on
|
||||
`LocalOnlyProvider`'s `UnsupportedPlatform` instead of warn-spamming
|
||||
every shutdown.
|
||||
- **usvg font-substitution warn spam** — custom `FontResolver.select_font`
|
||||
appends `Family::SansSerif` and `Family::Serif` to every query so
|
||||
unmatched named families silently fall through.
|
||||
|
||||
### Migration
|
||||
|
||||
- **In-progress saves invalidated.** `GameState.schema_version` bumped
|
||||
1 → 2; pre-v2 `game_state.json` files silently fall through to "fresh
|
||||
game on launch." Stats, progress, achievements, and settings live in
|
||||
separate files and are unaffected.
|
||||
|
||||
### Stats
|
||||
|
||||
- 982 passing tests (was 819 at v0.10.0).
|
||||
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||
|
||||
## [0.10.0] — 2026-04-29
|
||||
|
||||
PNG art pipeline plus a major dependency pass. The first release where
|
||||
the binary shipped with bundled artwork.
|
||||
|
||||
### Added
|
||||
|
||||
- **52 individual card face PNGs** generated via `solitaire_assetgen`.
|
||||
- **Custom font** (FiraMono-Medium) loaded via `AssetServer` at startup
|
||||
through the new `FontPlugin`.
|
||||
- **Card backs and backgrounds** upgraded to 120×168 with richer
|
||||
patterns.
|
||||
- **Ambient audio loop** wired through the kira mixer.
|
||||
- **Arch Linux PKGBUILDs** for the game client and sync server (under
|
||||
the separate `solitaire-quest-pkgbuild` directory).
|
||||
- **Workspace README, CI workflow, migration guide.**
|
||||
|
||||
### Changed
|
||||
|
||||
- **Bevy 0.15 → 0.18** workspace migration.
|
||||
- **kira 0.9 → 0.12** audio backend migration.
|
||||
- **Edition 2024**, MSRV pinned to **Rust 1.95**.
|
||||
- **rand 0.9** upgrade.
|
||||
- **Card rendering** moved from `Text2d` overlay to PNG-backed
|
||||
`Sprite` with face/back atlases; `Text2d` retained as a headless
|
||||
fallback when `CardImageSet` is absent (tests under MinimalPlugins).
|
||||
- **Asset pipeline** switched from `include_bytes!()` for PNGs/TTFs to
|
||||
runtime `AssetServer::load()` so artwork can be swapped without a
|
||||
recompile. Audio remains embedded.
|
||||
- **Removed Google Play Games Services sync backend** — redundant with
|
||||
the self-hosted server.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Server JWT secret** loaded at startup (was lazy, surfaced as
|
||||
intermittent 500s).
|
||||
- **Daily-challenge race** in the server's seed-generation path.
|
||||
- **Rate limiter** switched to `SmartIpKeyExtractor` so the limit
|
||||
applies per real client IP rather than per upstream proxy.
|
||||
- **Touch input** uses `MessageReader<TouchInput>` (Bevy 0.18 rename).
|
||||
- **Sync push/pull races** in async task scheduling.
|
||||
- **Hot-path allocations** reduced in card-rendering systems.
|
||||
- **Conflict report coverage** added for sync merge edge cases.
|
||||
|
||||
### Stats
|
||||
|
||||
- 819 passing tests at tag time.
|
||||
|
||||
## [0.9.0] — 2026-04-28
|
||||
|
||||
Initial public-tagged release. Established the workspace structure
|
||||
(`solitaire_core` / `_sync` / `_data` / `_engine` / `_server` / `_app` /
|
||||
`_assetgen`), the modal scaffold via `ui_modal`, the design-token system
|
||||
in `ui_theme`, and the four-tier HUD layout. Foundations were
|
||||
suit-locked at this point; cards rendered as `Text2d` rank/suit overlays
|
||||
with no PNG artwork yet.
|
||||
|
||||
### Added
|
||||
|
||||
- Klondike core (Draw One / Draw Three modes).
|
||||
- Progression system (XP, levels, 18 achievements, daily challenge,
|
||||
weekly goals, special modes at level 5).
|
||||
- Self-hosted sync server (Axum + SQLite + JWT auth).
|
||||
- All 12 overlay screens migrated to the `ui_modal` scaffold with real
|
||||
Primary/Secondary/Tertiary buttons.
|
||||
- Animation upgrades: `SmoothSnap` slide curves, scoped settle bounce,
|
||||
deal jitter, win-cascade rotation.
|
||||
- Splash screen, focus rings (Phases 1–3), tooltips infrastructure +
|
||||
HUD/Settings/popover applications, achievement integration tests,
|
||||
destructive-confirm verb unification, leaderboard error/idle states,
|
||||
first-launch empty-state polish, hit-target accessibility fix,
|
||||
CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
|
||||
client-side sync round-trip integration tests.
|
||||
|
||||
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.16.0...HEAD
|
||||
[0.16.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.15.0...v0.16.0
|
||||
[0.15.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...v0.15.0
|
||||
[0.14.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...v0.14.0
|
||||
[0.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0
|
||||
[0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0
|
||||
[0.11.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.10.0...v0.11.0
|
||||
[0.10.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.9.0...v0.10.0
|
||||
[0.9.0]: https://github.com/funman300/Rusty_Solitaire/releases/tag/v0.9.0
|
||||
@@ -1,113 +1,571 @@
|
||||
# Solitaire Quest — Claude Code Instructions
|
||||
# CLAUDE.md
|
||||
|
||||
See @ARCHITECTURE.md for full project design, crate responsibilities, data models, and API reference.
|
||||
version: unified-3.0
|
||||
|
||||
---
|
||||
|
||||
## Project Layout
|
||||
# 0. Role of This File
|
||||
|
||||
```text
|
||||
solitaire_core/ # Pure Rust game logic — NO Bevy, NO network, NO I/O
|
||||
solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
|
||||
solitaire_data/ # Persistence + SyncProvider trait + server client
|
||||
solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||
solitaire_server/ # Axum sync server binary
|
||||
solitaire_gpgs/ # Google Play Games bridge — STUB ONLY until Android phase
|
||||
solitaire_app/ # Thin binary entry point
|
||||
assets/ # Source assets — embedded at compile time via include_bytes!()
|
||||
This document defines:
|
||||
|
||||
* **Execution rules (what Claude must do)**
|
||||
* **System constraints (what Claude must never violate)**
|
||||
* **Operational architecture (how code is structured)**
|
||||
|
||||
For full system design details:
|
||||
→ `ARCHITECTURE.md` (authoritative source of truth)
|
||||
|
||||
This file overrides all conversational assumptions.
|
||||
|
||||
---
|
||||
|
||||
# 1. System Architecture (Authoritative Mapping)
|
||||
|
||||
## 1.1 Crates
|
||||
|
||||
```text id="crate_map"
|
||||
solitaire_core/ # PURE logic (no IO, no Bevy, deterministic)
|
||||
solitaire_sync/ # Shared API + merge logic
|
||||
solitaire_data/ # Persistence + sync client
|
||||
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
||||
solitaire_server/ # Axum backend (optional sync layer)
|
||||
solitaire_app/ # Entry binary
|
||||
assets/ # Runtime assets (except audio)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build & Test Commands
|
||||
## 1.2 Architecture Source of Truth
|
||||
|
||||
```bash
|
||||
# Dev run (fast compile via dynamic linking)
|
||||
cargo run -p solitaire_app --features bevy/dynamic_linking
|
||||
* Full system design: `ARCHITECTURE.md`
|
||||
* This file NEVER redefines system design
|
||||
* This file ONLY enforces behavior
|
||||
|
||||
# Release build
|
||||
cargo build --workspace --release
|
||||
---
|
||||
|
||||
# All tests — MUST pass before any commit
|
||||
# 2. Hard Global Constraints (NON-NEGOTIABLE)
|
||||
|
||||
These override all other instructions.
|
||||
|
||||
## 2.1 Core Determinism
|
||||
|
||||
* `solitaire_core` MUST:
|
||||
|
||||
* be deterministic
|
||||
* be side-effect free
|
||||
* never depend on Bevy / IO / async
|
||||
|
||||
---
|
||||
|
||||
## 2.2 Sync Isolation
|
||||
|
||||
* `solitaire_sync`:
|
||||
|
||||
* no Bevy
|
||||
* no IO
|
||||
* no engine dependencies
|
||||
* merge logic must be pure functions only
|
||||
|
||||
---
|
||||
|
||||
## 2.3 Error Policy
|
||||
|
||||
* NO `unwrap()`
|
||||
* NO `panic!()` in runtime/game logic
|
||||
* All state transitions:
|
||||
|
||||
```rust id="err_model"
|
||||
Result<T, MoveError>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.4 Threading Rules
|
||||
|
||||
* Sync must run on `AsyncComputeTaskPool`
|
||||
* NEVER block Bevy main thread
|
||||
|
||||
---
|
||||
|
||||
## 2.5 Persistence Rules
|
||||
|
||||
* atomic writes only:
|
||||
|
||||
* write `.tmp`
|
||||
* rename atomically
|
||||
* no partial state writes allowed
|
||||
|
||||
---
|
||||
|
||||
## 2.6 Security Rules
|
||||
|
||||
* credentials ONLY via `keyring`
|
||||
* NEVER store secrets in:
|
||||
|
||||
* files
|
||||
* logs
|
||||
* source code
|
||||
|
||||
---
|
||||
|
||||
## 2.7 Sync System Rules
|
||||
|
||||
* All sync backends implement:
|
||||
|
||||
```rust id="sync_trait"
|
||||
trait SyncProvider
|
||||
```
|
||||
|
||||
* `SyncPlugin` MUST be backend-agnostic
|
||||
* NEVER match on backend inside ECS systems
|
||||
|
||||
---
|
||||
|
||||
# 3. Engine Rules (Bevy Layer)
|
||||
|
||||
## 3.1 ECS Design
|
||||
|
||||
* systems = single responsibility
|
||||
* communication = Events only
|
||||
* shared state = Resources only
|
||||
* per-entity state = Components only
|
||||
|
||||
---
|
||||
|
||||
## 3.2 Game State Authority
|
||||
|
||||
* ONLY `GameStateResource` can mutate game state
|
||||
* UI systems MUST NOT directly modify core logic
|
||||
|
||||
---
|
||||
|
||||
## 3.3 UI-First Constraint (CRITICAL)
|
||||
|
||||
Every player action MUST:
|
||||
|
||||
* have a visible UI control
|
||||
* NOT rely solely on keyboard shortcuts
|
||||
|
||||
Keyboard shortcuts are:
|
||||
→ optional accelerators only
|
||||
|
||||
---
|
||||
|
||||
## 3.4 Layout System
|
||||
|
||||
* recompute on `WindowResized`
|
||||
* no fixed resolution assumptions
|
||||
|
||||
---
|
||||
|
||||
# 4. Asset System Rules
|
||||
|
||||
## 4.1 Runtime Assets (AssetServer)
|
||||
|
||||
Loaded via:
|
||||
|
||||
* `CardImageSet`
|
||||
* `BackgroundImageSet`
|
||||
* `FontResource`
|
||||
|
||||
Includes:
|
||||
|
||||
* cards
|
||||
* backgrounds
|
||||
* fonts
|
||||
|
||||
---
|
||||
|
||||
## 4.2 Embedded Assets
|
||||
|
||||
Only audio:
|
||||
|
||||
```text id="audio_rule"
|
||||
include_bytes!()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4.3 Test Compatibility Rule
|
||||
|
||||
All asset loaders MUST accept:
|
||||
|
||||
```rust id="asset_fallback"
|
||||
Option<Res<AssetServer>>
|
||||
```
|
||||
|
||||
Must degrade gracefully under `MinimalPlugins`.
|
||||
|
||||
---
|
||||
|
||||
# 5. Code Standards
|
||||
|
||||
## 5.1 Error Handling
|
||||
|
||||
* use `thiserror`
|
||||
* no `Box<dyn Error>` in libraries
|
||||
|
||||
---
|
||||
|
||||
## 5.2 Public API Rules
|
||||
|
||||
* prefer `Into<T>` over concrete types
|
||||
* all public items require doc comments
|
||||
|
||||
---
|
||||
|
||||
## 5.3 Derive Order
|
||||
|
||||
```rust id="derive_order"
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.4 Performance Rules
|
||||
|
||||
* NO `clone()` in hot paths
|
||||
* profile before optimizing
|
||||
|
||||
---
|
||||
|
||||
## 5.5 SQL Rules
|
||||
|
||||
* ONLY `sqlx::query!`
|
||||
* NO raw SQL strings
|
||||
|
||||
---
|
||||
|
||||
# 6. Build & Verification Rules
|
||||
|
||||
These are mandatory before ANY commit.
|
||||
|
||||
```bash id="build_rules"
|
||||
cargo test --workspace
|
||||
|
||||
# Lint — MUST pass clean (zero warnings)
|
||||
cargo clippy --workspace -- -D warnings
|
||||
|
||||
# Run sync server locally
|
||||
cargo run -p solitaire_server
|
||||
|
||||
# Check a single crate
|
||||
cargo test -p solitaire_core
|
||||
cargo clippy -p solitaire_core -- -D warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hard Rules
|
||||
# 7. Git Workflow Rules
|
||||
|
||||
- `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>`.
|
||||
- Assets are embedded at compile time using `include_bytes!()`. No runtime asset loading via `AssetServer`.
|
||||
- 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.
|
||||
- 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.
|
||||
- `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 test --workspace` must pass after every change.
|
||||
## Commit format
|
||||
|
||||
```text id="commit_fmt"
|
||||
type(scope): description
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
* feat(core): add draw-three rules
|
||||
* fix(engine): correct drag z-order
|
||||
* test(core): undo boundary cases
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
## Commit conditions
|
||||
|
||||
- Use `thiserror` for error types. Never `Box<dyn Error>` in library crates.
|
||||
- Prefer `Into<T>` over concrete types in public API function parameters.
|
||||
- All public items must have doc comments (`///`). Private items: comment only when non-obvious.
|
||||
- Derive order convention: `#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]`
|
||||
- Bevy systems: one responsibility per system. Use `Events` for cross-system communication, never shared mutable state.
|
||||
- SQL queries: use `sqlx::query!` macros (compile-time checked), not raw string queries.
|
||||
- No `clone()` calls in hot paths (game loop systems). Profile before optimising elsewhere.
|
||||
* tests must pass
|
||||
* clippy must be clean
|
||||
|
||||
NEVER commit otherwise
|
||||
|
||||
---
|
||||
|
||||
## Bevy Conventions
|
||||
# 8. Change Control (ASK BEFORE DOING)
|
||||
|
||||
- One `Plugin` per major feature: `CardPlugin`, `AudioPlugin`, `AchievementPlugin`, `UIPlugin`, `SyncPlugin`.
|
||||
- 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.
|
||||
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
|
||||
Claude must request confirmation before:
|
||||
|
||||
* adding dependencies
|
||||
* modifying `solitaire_sync`
|
||||
* changing DB schema
|
||||
* introducing `unsafe`
|
||||
* changing merge strategy
|
||||
|
||||
---
|
||||
|
||||
## Git Workflow
|
||||
# 9. System Mental Model (IMPORTANT)
|
||||
|
||||
- Commit after each passing phase, not after every file change.
|
||||
- Commit message format: `type(scope): description`
|
||||
- `feat(core): add draw-three mode validation`
|
||||
- `fix(engine): card z-order during drag`
|
||||
- `test(core): undo stack boundary conditions`
|
||||
- `chore(server): add sqlx migration 002`
|
||||
- Never commit with failing tests or clippy warnings.
|
||||
- Never commit secrets, `.env` files, or `*.db` files.
|
||||
```text id="mental_model"
|
||||
Core (rules + deterministic logic)
|
||||
↓
|
||||
Engine (Bevy orchestration)
|
||||
↓
|
||||
Data layer (persistence + sync)
|
||||
↓
|
||||
Server (optional external system)
|
||||
```
|
||||
|
||||
Core is always the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Ask Before Doing
|
||||
# 10. Known Platform Pitfalls
|
||||
|
||||
- Adding a new crate dependency (discuss alternatives first).
|
||||
- Changing a type in `solitaire_sync` (breaking change on both client and server).
|
||||
- Altering the database schema (requires a new sqlx migration).
|
||||
- Introducing `unsafe` code anywhere.
|
||||
- Changing the merge strategy in `solitaire_sync::merge()`.
|
||||
Must always be handled explicitly:
|
||||
|
||||
* Bevy `Time` uses `f32`
|
||||
* `sqlx::migrate!()` path is crate-relative
|
||||
* `dirs::data_dir()` may return `None`
|
||||
* Linux may lack keyring backend
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
# 11. Forbidden Patterns
|
||||
|
||||
> Add entries here when Claude makes a mistake so it isn't repeated.
|
||||
* game logic inside Bevy systems
|
||||
* duplication across crates
|
||||
* blocking async calls in ECS
|
||||
* insecure credential storage
|
||||
* bypassing core logic layer
|
||||
|
||||
- Bevy's `Time` resource uses `f32` seconds; convert to `u64` only when writing to `StatsSnapshot`.
|
||||
- `sqlx::migrate!()` macro path is relative to the crate root, not the workspace root.
|
||||
- `keyring` on Linux requires a running secret service (e.g. GNOME Keyring or KWallet) — handle `Error::NoStorageAccess` gracefully and fall back to prompting the user.
|
||||
- `dirs::data_dir()` returns `None` on some minimal Linux environments — always handle the `None` case explicitly, do not unwrap.
|
||||
---
|
||||
|
||||
# 12. Execution Rules for Claude
|
||||
|
||||
When generating code:
|
||||
|
||||
1. respect crate boundaries
|
||||
2. minimize diff size
|
||||
3. do not expand scope
|
||||
4. follow existing patterns
|
||||
5. preserve invariants
|
||||
|
||||
If unclear:
|
||||
→ ask before acting
|
||||
|
||||
---
|
||||
|
||||
# 13. Relationship to ARCHITECTURE.md
|
||||
|
||||
| File | Role |
|
||||
| --------------- | ------------------------- |
|
||||
| CLAUDE.md | execution + constraints |
|
||||
| ARCHITECTURE.md | system design truth |
|
||||
| Both combined | full system understanding |
|
||||
|
||||
---
|
||||
# 14. Context Injection System (AUTOMATIC SCOPE FILTER)
|
||||
|
||||
## 14.1 Purpose
|
||||
|
||||
Before generating any response, Claude MUST construct a **minimal relevant context set**.
|
||||
|
||||
This prevents:
|
||||
|
||||
* architectural drift
|
||||
* irrelevant spec loading
|
||||
* over-engineering
|
||||
* cross-crate confusion
|
||||
|
||||
---
|
||||
|
||||
## 14.2 Input Classification Step (MANDATORY)
|
||||
|
||||
Every request MUST be classified into exactly one task type:
|
||||
|
||||
```text id="task_types"
|
||||
feature
|
||||
bugfix
|
||||
refactor
|
||||
system_design
|
||||
bevy_system
|
||||
core_logic
|
||||
sync
|
||||
optimization
|
||||
test
|
||||
debug
|
||||
```
|
||||
|
||||
If uncertain → ask clarification.
|
||||
|
||||
---
|
||||
|
||||
## 14.3 Context Selection Engine
|
||||
|
||||
After classification, Claude MUST include ONLY the relevant sections below.
|
||||
|
||||
---
|
||||
|
||||
## 14.4 Context Map (CORE RULESET)
|
||||
|
||||
### feature
|
||||
|
||||
Include:
|
||||
|
||||
* §2 Hard Global Constraints
|
||||
* §3 Engine Rules
|
||||
* ARCHITECTURE.md (crate of target feature only)
|
||||
* relevant data models (GameState, SyncPayload if needed)
|
||||
|
||||
---
|
||||
|
||||
### bugfix
|
||||
|
||||
Include:
|
||||
|
||||
* §2 Hard Global Constraints
|
||||
* §5 Code Standards
|
||||
* affected crate boundaries
|
||||
* relevant system (engine/core/sync only)
|
||||
|
||||
---
|
||||
|
||||
### refactor
|
||||
|
||||
Include:
|
||||
|
||||
* §3 Engine Rules
|
||||
* §5 Code Standards
|
||||
* §11 Forbidden Patterns
|
||||
* target crate boundaries
|
||||
|
||||
---
|
||||
|
||||
### system_design
|
||||
|
||||
Include:
|
||||
|
||||
* ARCHITECTURE.md (FULL)
|
||||
* §9 Mental Model
|
||||
* §1 System Architecture Mapping
|
||||
|
||||
---
|
||||
|
||||
### core_logic
|
||||
|
||||
Include:
|
||||
|
||||
* solitaire_core rules only
|
||||
* GameState model
|
||||
* MoveError model
|
||||
* §2.1–2.3 constraints
|
||||
|
||||
---
|
||||
|
||||
### bevy_system
|
||||
|
||||
Include:
|
||||
|
||||
* §3 Engine Rules
|
||||
* ECS rules (Events/Resources/Components)
|
||||
* UI-first constraint
|
||||
* relevant plugin system only
|
||||
|
||||
---
|
||||
|
||||
### sync
|
||||
|
||||
Include:
|
||||
|
||||
* SyncProvider trait
|
||||
* merge strategy rules
|
||||
* solitaire_sync models
|
||||
* §2.6 Sync Rules
|
||||
|
||||
---
|
||||
|
||||
### optimization
|
||||
|
||||
Include:
|
||||
|
||||
* target crate only
|
||||
* §5.4 Performance Rules
|
||||
* hot path constraints
|
||||
|
||||
---
|
||||
|
||||
### test
|
||||
|
||||
Include:
|
||||
|
||||
* §6 Build Rules
|
||||
* relevant module
|
||||
* expected invariants
|
||||
|
||||
---
|
||||
|
||||
### debug
|
||||
|
||||
Include:
|
||||
|
||||
* target file/module only
|
||||
* §2.3 Error Policy
|
||||
* runtime assumptions relevant to failure
|
||||
|
||||
---
|
||||
|
||||
## 14.5 Context Compression Rules
|
||||
|
||||
Claude MUST obey:
|
||||
|
||||
* never include full ARCHITECTURE.md unless system_design
|
||||
* max 2 crates per response unless explicitly required
|
||||
* prefer function-level context over file-level context
|
||||
* exclude unrelated plugins/systems
|
||||
|
||||
---
|
||||
|
||||
## 14.6 Context Priority Order
|
||||
|
||||
When space is limited:
|
||||
|
||||
1. Hard Constraints (§2)
|
||||
2. Target crate rules
|
||||
3. Data models
|
||||
4. Only then: architecture snippets
|
||||
|
||||
---
|
||||
|
||||
## 14.7 “No Context Pollution” Rule
|
||||
|
||||
Claude must NOT include:
|
||||
|
||||
* unrelated crates
|
||||
* unrelated plugins
|
||||
* unused data models
|
||||
* full architecture dumps
|
||||
* speculative systems
|
||||
|
||||
---
|
||||
|
||||
## 14.8 Self-Check Before Execution
|
||||
|
||||
Before writing code, Claude MUST verify:
|
||||
|
||||
* [ ] Is only relevant context included?
|
||||
* [ ] Is at least one hard constraint present?
|
||||
* [ ] Am I touching more than one crate unnecessarily?
|
||||
* [ ] Am I duplicating ARCHITECTURE.md content?
|
||||
|
||||
If any fail → revise context selection.
|
||||
|
||||
---
|
||||
|
||||
## 14.9 Injection Output Format (Internal Model)
|
||||
|
||||
Claude should behave as if it constructed:
|
||||
|
||||
```text id="ctx_format"
|
||||
[SELECTED TASK TYPE]
|
||||
|
||||
[MINIMAL REQUIRED RULES]
|
||||
|
||||
[MINIMAL ARCHITECTURE SLICES]
|
||||
|
||||
[RELEVANT MODELS]
|
||||
|
||||
[REQUEST]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14.10 Relationship to ARCHITECTURE.md
|
||||
|
||||
* ARCHITECTURE.md = source of truth
|
||||
* CLAUDE.md = execution constraints
|
||||
* THIS SECTION = filtering layer between them
|
||||
|
||||
---
|
||||
|
||||
# END CONTEXT INJECTION SYSTEM
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
# CLAUDE_PROMPT_PACK.md
|
||||
|
||||
version: 1.0
|
||||
|
||||
---
|
||||
|
||||
# 0. GLOBAL INSTRUCTION (prepend to every prompt)
|
||||
|
||||
```
|
||||
You must follow CLAUDE_SPEC.md strictly.
|
||||
|
||||
Rules:
|
||||
- Do not expand scope beyond what is defined
|
||||
- Do not refactor unrelated code
|
||||
- Do not introduce new dependencies
|
||||
- Prefer minimal, surgical changes
|
||||
- Use existing patterns in the codebase
|
||||
- Return minimal diffs or changed functions only
|
||||
|
||||
Before writing code:
|
||||
1. List relevant constraints from CLAUDE_SPEC.md
|
||||
2. Identify risks
|
||||
3. Then implement
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 1. FEATURE IMPLEMENTATION
|
||||
|
||||
```
|
||||
# TASK: Feature Implementation
|
||||
|
||||
feature: "<name>"
|
||||
|
||||
goal:
|
||||
"<clear outcome>"
|
||||
|
||||
scope:
|
||||
crates: []
|
||||
systems: []
|
||||
files: []
|
||||
|
||||
non_goals:
|
||||
- ""
|
||||
|
||||
constraints:
|
||||
- must follow CLAUDE_SPEC.md
|
||||
- event-driven architecture required
|
||||
- no blocking operations
|
||||
- no cross-crate leakage
|
||||
|
||||
acceptance_criteria:
|
||||
- ""
|
||||
- ""
|
||||
|
||||
edge_cases:
|
||||
- ""
|
||||
|
||||
---
|
||||
|
||||
## Required Patterns
|
||||
|
||||
Use this pattern for systems:
|
||||
<PASTE EXISTING SYSTEM SNIPPET HERE>
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
intent:
|
||||
plan:
|
||||
constraints_used:
|
||||
risks:
|
||||
|
||||
code_changes:
|
||||
(minimal diffs only)
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 2. BUGFIX
|
||||
|
||||
```
|
||||
# TASK: Bug Fix
|
||||
|
||||
bug_description:
|
||||
"<what is broken>"
|
||||
|
||||
expected_behavior:
|
||||
"<correct behavior>"
|
||||
|
||||
root_cause_hint (optional):
|
||||
""
|
||||
|
||||
scope:
|
||||
crates: []
|
||||
files: []
|
||||
|
||||
constraints:
|
||||
- minimal fix only
|
||||
- no refactors unless required
|
||||
- must add regression protection if applicable
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
1. Identify root cause
|
||||
2. Fix it minimally
|
||||
3. Preserve all invariants
|
||||
4. Do not change unrelated logic
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
root_cause:
|
||||
fix_strategy:
|
||||
|
||||
code_changes:
|
||||
(minimal diff)
|
||||
|
||||
regression_test (only if high-value):
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 3. REFACTOR
|
||||
|
||||
```
|
||||
# TASK: Refactor
|
||||
|
||||
target:
|
||||
"<what is being improved>"
|
||||
|
||||
goal:
|
||||
"<what improves>"
|
||||
|
||||
scope:
|
||||
crates: []
|
||||
files: []
|
||||
|
||||
non_goals:
|
||||
- no behavior changes
|
||||
- no new features
|
||||
|
||||
constraints:
|
||||
- must preserve behavior exactly
|
||||
- must respect crate boundaries
|
||||
- must not duplicate logic
|
||||
|
||||
---
|
||||
|
||||
## Refactor Type
|
||||
|
||||
- [ ] simplify logic
|
||||
- [ ] reduce duplication
|
||||
- [ ] improve readability
|
||||
- [ ] performance (non-invasive)
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
issues_found:
|
||||
|
||||
refactor_plan:
|
||||
|
||||
code_changes:
|
||||
(diff only)
|
||||
|
||||
verification:
|
||||
- behavior unchanged: yes/no
|
||||
- invariants preserved: yes/no
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 4. SYSTEM DESIGN (NEW FEATURE)
|
||||
|
||||
```
|
||||
# TASK: System Design
|
||||
|
||||
feature:
|
||||
"<name>"
|
||||
|
||||
goal:
|
||||
"<what problem it solves>"
|
||||
|
||||
constraints:
|
||||
- must fit existing architecture
|
||||
- must follow plugin + event model
|
||||
- must not violate crate boundaries
|
||||
|
||||
---
|
||||
|
||||
## Required Output
|
||||
|
||||
design:
|
||||
|
||||
components:
|
||||
- plugins:
|
||||
- systems:
|
||||
- events:
|
||||
- resources:
|
||||
|
||||
data_flow:
|
||||
(step-by-step)
|
||||
|
||||
integration_points:
|
||||
- where it connects to existing systems
|
||||
|
||||
risks:
|
||||
- ""
|
||||
|
||||
tradeoffs:
|
||||
- ""
|
||||
|
||||
---
|
||||
|
||||
## DO NOT
|
||||
|
||||
- write full implementation
|
||||
- modify unrelated systems
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 5. NEW BEVY SYSTEM
|
||||
|
||||
```
|
||||
# TASK: Add Bevy System
|
||||
|
||||
system_name:
|
||||
""
|
||||
|
||||
trigger:
|
||||
(event or condition)
|
||||
|
||||
reads:
|
||||
[Resources]
|
||||
|
||||
writes:
|
||||
[Resources]
|
||||
|
||||
emits:
|
||||
[Events]
|
||||
|
||||
constraints:
|
||||
- must be event-driven
|
||||
- must not directly mutate unrelated state
|
||||
- must be single responsibility
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
system_signature:
|
||||
|
||||
implementation:
|
||||
(code only)
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 6. CORE LOGIC FUNCTION (solitaire_core)
|
||||
|
||||
```
|
||||
# TASK: Core Logic Implementation
|
||||
|
||||
function:
|
||||
"<name>"
|
||||
|
||||
goal:
|
||||
"<what it does>"
|
||||
|
||||
rules:
|
||||
- no IO
|
||||
- no async
|
||||
- no Bevy
|
||||
- deterministic
|
||||
|
||||
invariants:
|
||||
- ""
|
||||
- ""
|
||||
|
||||
errors:
|
||||
- ""
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
constraints_checked:
|
||||
|
||||
implementation:
|
||||
(code only)
|
||||
|
||||
edge_case_handling:
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 7. SYNC / MERGE LOGIC
|
||||
|
||||
```
|
||||
# TASK: Sync Logic
|
||||
|
||||
goal:
|
||||
"<what is being merged or synced>"
|
||||
|
||||
constraints:
|
||||
- must be deterministic
|
||||
- must be idempotent
|
||||
- must be lossless
|
||||
- must not delete data
|
||||
|
||||
rules:
|
||||
- counters → max
|
||||
- times → min
|
||||
- collections → union
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
|
||||
merge_logic:
|
||||
|
||||
code_changes:
|
||||
|
||||
invariants_verified:
|
||||
- deterministic
|
||||
- idempotent
|
||||
- lossless
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 8. PERFORMANCE OPTIMIZATION
|
||||
|
||||
```
|
||||
# TASK: Optimization
|
||||
|
||||
target:
|
||||
"<what is slow>"
|
||||
|
||||
constraints:CLAUDE_WORKFLOW.md
|
||||
- no behavior change
|
||||
- no architecture change
|
||||
- minimal code changes
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
bottleneck:
|
||||
|
||||
optimization_strategy:
|
||||
|
||||
code_changes:
|
||||
|
||||
impact_estimate:
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 9. TEST GENERATION (STRICT MODE)
|
||||
|
||||
```
|
||||
# TASK: Test Generation
|
||||
|
||||
target:
|
||||
"<function/system>"
|
||||
|
||||
reason:
|
||||
- bugfix | complex logic | invariant protection
|
||||
|
||||
constraints:
|
||||
- no redundant tests
|
||||
- must test real behavior
|
||||
- must fail if logic breaks
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
test_cases:
|
||||
- ""
|
||||
|
||||
test_code:
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 10. DEBUGGING / INVESTIGATION
|
||||
|
||||
```
|
||||
# TASK: Debug
|
||||
|
||||
problem:
|
||||
"<symptom>"
|
||||
|
||||
context:
|
||||
"<relevant code or system>"
|
||||
|
||||
---
|
||||
|
||||
## Required Steps
|
||||
|
||||
1. List possible causes
|
||||
2. Narrow down most likely
|
||||
3. Suggest verification steps
|
||||
4. Provide minimal fix
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
hypotheses:
|
||||
|
||||
most_likely:
|
||||
|
||||
verification_steps:
|
||||
|
||||
fix:
|
||||
|
||||
notes:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 11. HARD CONSTRAINT OVERRIDE (RARE)
|
||||
|
||||
```
|
||||
# TASK: Exception Handling
|
||||
|
||||
reason:
|
||||
"<why constraints must be bent>"
|
||||
|
||||
requested_exception:
|
||||
"<rule being broken>"
|
||||
|
||||
justification:
|
||||
"<why unavoidable>"
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
analysis:
|
||||
|
||||
alternatives_considered:
|
||||
|
||||
final_decision:
|
||||
|
||||
risk:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 12. STOP CONDITIONS (always append)
|
||||
|
||||
```
|
||||
Stop when:
|
||||
- acceptance criteria are met
|
||||
- code is minimal and correct
|
||||
|
||||
Do NOT:
|
||||
- expand scope
|
||||
- refactor unrelated code
|
||||
- optimize prematurely
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# END
|
||||
@@ -0,0 +1,292 @@
|
||||
# CLAUDE_SPEC.md
|
||||
|
||||
version: 1.0
|
||||
|
||||
---
|
||||
|
||||
## 0. Global Rules
|
||||
|
||||
(Core determinism, panic policy, and event-driven engine constraints live in CLAUDE.md §2.1, §2.3, §3.1. Listed here only when they add information CLAUDE.md doesn't carry.)
|
||||
|
||||
rules:
|
||||
|
||||
* id: single_source_of_truth
|
||||
description: "GameStateResource is the only mutable game state in runtime"
|
||||
|
||||
* id: sync_is_additive
|
||||
description: "Remote data must never destructively overwrite local data"
|
||||
|
||||
---
|
||||
|
||||
## 1. Crate Graph
|
||||
|
||||
crates:
|
||||
solitaire_core:
|
||||
depends_on: [rand, serde, chrono]
|
||||
forbidden_deps: [bevy, reqwest, tokio, std::fs]
|
||||
|
||||
solitaire_sync:
|
||||
depends_on: [serde, serde_json, uuid, chrono]
|
||||
role: "shared_types"
|
||||
|
||||
solitaire_data:
|
||||
depends_on: [solitaire_core, solitaire_sync, reqwest, tokio, keyring]
|
||||
role: "persistence_and_sync"
|
||||
|
||||
solitaire_engine:
|
||||
depends_on: [bevy, kira, solitaire_core, solitaire_data]
|
||||
role: "runtime_engine"
|
||||
|
||||
solitaire_server:
|
||||
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
|
||||
role: "backend"
|
||||
|
||||
solitaire_app:
|
||||
depends_on: [solitaire_engine]
|
||||
role: "entrypoint"
|
||||
|
||||
---
|
||||
|
||||
## 2. Data Ownership
|
||||
|
||||
ownership:
|
||||
GameState:
|
||||
owner: solitaire_core
|
||||
mutable_in: solitaire_engine
|
||||
access_pattern: "via GameStateResource only"
|
||||
|
||||
StatsSnapshot:
|
||||
owner: solitaire_data
|
||||
|
||||
PlayerProgress:
|
||||
owner: solitaire_data
|
||||
|
||||
AchievementRecord:
|
||||
owner: solitaire_data
|
||||
|
||||
SyncPayload:
|
||||
owner: solitaire_sync
|
||||
|
||||
---
|
||||
|
||||
## 3. State Transitions
|
||||
|
||||
state_machine:
|
||||
GameState:
|
||||
transitions:
|
||||
- action: move_cards
|
||||
returns: Result<GameState, MoveError>
|
||||
|
||||
```
|
||||
- action: draw
|
||||
returns: Result<GameState, MoveError>
|
||||
|
||||
- action: undo
|
||||
returns: Result<GameState, MoveError>
|
||||
|
||||
invariants:
|
||||
- "52 cards always exist"
|
||||
- "no duplicate card IDs"
|
||||
- "all cards belong to exactly one pile"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Event System
|
||||
|
||||
events:
|
||||
|
||||
input:
|
||||
- MoveRequestEvent
|
||||
- DrawRequestEvent
|
||||
- UndoRequestEvent
|
||||
- NewGameRequestEvent
|
||||
|
||||
state:
|
||||
- StateChangedEvent
|
||||
- GameWonEvent
|
||||
|
||||
meta:
|
||||
- AchievementUnlockedEvent
|
||||
- SyncCompleteEvent
|
||||
|
||||
rules:
|
||||
|
||||
* "Input events trigger core logic"
|
||||
* "Core logic emits state events"
|
||||
* "UI reacts to state events only"
|
||||
|
||||
---
|
||||
|
||||
## 5. Sync Contract
|
||||
|
||||
sync:
|
||||
|
||||
provider_trait:
|
||||
methods:
|
||||
- pull() -> SyncPayload
|
||||
- push(payload) -> SyncResponse
|
||||
|
||||
guarantees:
|
||||
- "non-blocking during gameplay"
|
||||
- "blocking allowed on exit only"
|
||||
|
||||
merge:
|
||||
rules:
|
||||
counters: "max"
|
||||
best_times: "min"
|
||||
collections: "union"
|
||||
achievements: "never removed"
|
||||
|
||||
```
|
||||
properties:
|
||||
- deterministic
|
||||
- idempotent
|
||||
- lossless
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Persistence
|
||||
|
||||
storage:
|
||||
|
||||
format: json
|
||||
|
||||
files:
|
||||
- stats.json
|
||||
- progress.json
|
||||
- achievements.json
|
||||
- settings.json
|
||||
- game_state.json
|
||||
|
||||
guarantees:
|
||||
- atomic_write: true
|
||||
- crash_safe: true
|
||||
|
||||
---
|
||||
|
||||
## 7. Engine Rules
|
||||
|
||||
engine:
|
||||
|
||||
mutation_rules:
|
||||
- "Only GameLogicSystem mutates GameState"
|
||||
- "UI systems are read-only"
|
||||
|
||||
threading:
|
||||
- "sync runs on AsyncComputeTaskPool"
|
||||
- "main thread must never block"
|
||||
|
||||
plugins:
|
||||
pattern: "feature_isolation"
|
||||
communication: "events"
|
||||
|
||||
---
|
||||
|
||||
## 8. Server Contract
|
||||
|
||||
server:
|
||||
|
||||
auth:
|
||||
method: jwt
|
||||
access_expiry: 24h
|
||||
refresh_expiry: 30d
|
||||
|
||||
endpoints:
|
||||
- POST /api/auth/register
|
||||
- POST /api/auth/login
|
||||
- GET /api/sync/pull
|
||||
- POST /api/sync/push
|
||||
|
||||
limits:
|
||||
payload_max: 1MB
|
||||
rate_limit: "10 req/min auth routes"
|
||||
|
||||
---
|
||||
|
||||
## 9. Achievement System
|
||||
|
||||
achievements:
|
||||
|
||||
definition_location: solitaire_core
|
||||
state_location: solitaire_data
|
||||
|
||||
types:
|
||||
- condition_based
|
||||
- event_driven
|
||||
|
||||
rule:
|
||||
- "achievements cannot be revoked"
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing Rules
|
||||
|
||||
testing:
|
||||
|
||||
philosophy:
|
||||
- "test real failures"
|
||||
- "avoid redundant tests"
|
||||
|
||||
required_coverage:
|
||||
solitaire_core:
|
||||
- move_validation
|
||||
- undo_integrity
|
||||
- win_detection
|
||||
|
||||
```
|
||||
solitaire_sync:
|
||||
- merge_correctness
|
||||
- idempotency
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Prohibited Patterns
|
||||
|
||||
(See CLAUDE.md §11 for the canonical forbidden-patterns list.)
|
||||
|
||||
---
|
||||
|
||||
## 12. Extension Points
|
||||
|
||||
extensibility:
|
||||
|
||||
sync_backends:
|
||||
pattern: "implement SyncProvider"
|
||||
|
||||
game_modes:
|
||||
location: solitaire_core::GameMode
|
||||
|
||||
plugins:
|
||||
rule: "new feature = new plugin"
|
||||
|
||||
---
|
||||
|
||||
## 13. Validation Checklist (for Claude)
|
||||
|
||||
validation:
|
||||
|
||||
* check: "crate dependency rules respected"
|
||||
* check: "no panics in core"
|
||||
* check: "events used for cross-system communication"
|
||||
* check: "GameState mutations centralized"
|
||||
* check: "merge function properties preserved"
|
||||
* check: "no blocking operations in main loop"
|
||||
|
||||
---
|
||||
|
||||
## 14. Mental Model
|
||||
|
||||
model:
|
||||
|
||||
layers:
|
||||
- core
|
||||
- engine
|
||||
- data
|
||||
- server
|
||||
|
||||
flow:
|
||||
- input -> engine -> core -> engine -> ui
|
||||
- data <-> sync <-> server
|
||||
@@ -0,0 +1,335 @@
|
||||
# CLAUDE_WORKFLOW.md
|
||||
|
||||
version: 1.0
|
||||
|
||||
---
|
||||
|
||||
## 0. Overview
|
||||
|
||||
This workflow defines a **two-agent system**:
|
||||
|
||||
* **Builder Agent** → writes and modifies code
|
||||
* **Guardian Agent** → enforces architecture + rejects invalid changes
|
||||
|
||||
No code is considered valid unless it passes Guardian validation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Roles
|
||||
|
||||
### 1.1 Builder Agent
|
||||
|
||||
role: "code_generation"
|
||||
|
||||
responsibilities:
|
||||
|
||||
* implement features
|
||||
* refactor code
|
||||
* generate tests (only when justified)
|
||||
* follow CLAUDE_SPEC.md
|
||||
|
||||
constraints:
|
||||
|
||||
* cannot bypass validation
|
||||
* must declare intent before writing code
|
||||
|
||||
output_contract:
|
||||
must_produce:
|
||||
- change_summary
|
||||
- files_modified
|
||||
- reasoning (short)
|
||||
- code_diff
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Guardian Agent
|
||||
|
||||
role: "architecture_enforcement"
|
||||
|
||||
responsibilities:
|
||||
|
||||
* validate against CLAUDE_SPEC.md
|
||||
* detect violations
|
||||
* reject or approve changes
|
||||
* suggest minimal fixes (not full rewrites)
|
||||
|
||||
constraints:
|
||||
|
||||
* no feature implementation
|
||||
* no large rewrites
|
||||
* must be deterministic
|
||||
|
||||
output_contract:
|
||||
must_produce:
|
||||
- status: APPROVED | REJECTED
|
||||
- violations[]
|
||||
- required_fixes[]
|
||||
- optional_improvements[]
|
||||
|
||||
---
|
||||
|
||||
## 2. Workflow Pipeline
|
||||
|
||||
```text
|
||||
User Request
|
||||
↓
|
||||
Builder Agent (proposal + code)
|
||||
↓
|
||||
Guardian Agent (validation)
|
||||
↓
|
||||
IF approved → commit
|
||||
IF rejected → feedback → Builder retry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Builder Protocol
|
||||
|
||||
### Step 1 — Intent Declaration
|
||||
|
||||
Builder MUST start with:
|
||||
|
||||
```yaml
|
||||
intent:
|
||||
feature: "<name>"
|
||||
crates_touched: []
|
||||
systems_affected: []
|
||||
risk_level: low|medium|high
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — Plan
|
||||
|
||||
```yaml
|
||||
plan:
|
||||
- step: "..."
|
||||
- step: "..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — Implementation
|
||||
|
||||
* Only modify declared crates
|
||||
* Follow ownership rules
|
||||
* Use events for cross-system communication
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — Output
|
||||
|
||||
```yaml
|
||||
change_summary: "..."
|
||||
|
||||
files_modified:
|
||||
- path: ...
|
||||
change: "..."
|
||||
|
||||
violations_self_check:
|
||||
- none | list
|
||||
|
||||
notes: "short reasoning"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Guardian Protocol
|
||||
|
||||
### Step 1 — Spec Validation
|
||||
|
||||
Check against:
|
||||
|
||||
* crate boundaries
|
||||
* mutation rules
|
||||
* event system usage
|
||||
* sync guarantees
|
||||
* forbidden patterns
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — Invariant Validation
|
||||
|
||||
Must verify:
|
||||
|
||||
* GameState invariants preserved
|
||||
* no new panic paths
|
||||
* no blocking calls in engine
|
||||
* merge properties unchanged
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — Output Decision
|
||||
|
||||
#### APPROVED
|
||||
|
||||
```yaml
|
||||
status: APPROVED
|
||||
|
||||
notes:
|
||||
- "no violations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### REJECTED
|
||||
|
||||
```yaml
|
||||
status: REJECTED
|
||||
|
||||
violations:
|
||||
- id: core_purity_violation
|
||||
file: "solitaire_core/src/..."
|
||||
reason: "uses std::fs"
|
||||
|
||||
required_fixes:
|
||||
- "move IO to solitaire_data"
|
||||
|
||||
optional_improvements:
|
||||
- "simplify event naming"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Enforcement Rules
|
||||
|
||||
### Hard Fail (automatic rejection)
|
||||
|
||||
* core crate uses IO / Bevy / network
|
||||
* GameState mutated outside GameLogicSystem
|
||||
* blocking async on main thread
|
||||
* duplicate logic across crates
|
||||
* merge function altered incorrectly
|
||||
|
||||
---
|
||||
|
||||
### Soft Fail (allowed but flagged)
|
||||
|
||||
* unnecessary complexity
|
||||
* redundant tests
|
||||
* minor architectural drift
|
||||
|
||||
---
|
||||
|
||||
## 6. Iteration Loop
|
||||
|
||||
Max attempts per task: **3**
|
||||
|
||||
```text
|
||||
Attempt 1 → Reject → Fix
|
||||
Attempt 2 → Reject → Fix
|
||||
Attempt 3 → Final decision
|
||||
```
|
||||
|
||||
If still failing:
|
||||
→ escalate to user
|
||||
|
||||
---
|
||||
|
||||
## 7. Diff Strategy
|
||||
|
||||
Builder MUST produce:
|
||||
|
||||
* minimal diffs
|
||||
* no unrelated refactors
|
||||
* no formatting-only changes
|
||||
|
||||
---
|
||||
|
||||
## 8. Test Strategy Integration
|
||||
|
||||
Builder rules:
|
||||
|
||||
* only add tests if:
|
||||
|
||||
* fixing a bug
|
||||
* protecting complex logic
|
||||
* validating invariants
|
||||
|
||||
Guardian rejects:
|
||||
|
||||
* redundant tests
|
||||
* no-op tests
|
||||
|
||||
---
|
||||
|
||||
## 9. Optional Extensions
|
||||
|
||||
### 9.1 Third Agent (Optimizer)
|
||||
|
||||
role: performance + cleanup
|
||||
|
||||
runs AFTER approval:
|
||||
|
||||
* reduce allocations
|
||||
* simplify logic
|
||||
* improve ECS scheduling
|
||||
|
||||
---
|
||||
|
||||
### 9.2 CI Integration
|
||||
|
||||
Pipeline:
|
||||
|
||||
```text
|
||||
Builder → Guardian → cargo check → clippy → tests
|
||||
```
|
||||
|
||||
Guardian runs BEFORE compilation to catch structural issues early.
|
||||
|
||||
---
|
||||
|
||||
## 10. Example Interaction
|
||||
|
||||
### Builder
|
||||
|
||||
```yaml
|
||||
intent:
|
||||
feature: "undo stack limit fix"
|
||||
crates_touched: [solitaire_core]
|
||||
risk_level: low
|
||||
```
|
||||
|
||||
```yaml
|
||||
change_summary: "limit undo stack to 64 entries"
|
||||
|
||||
files_modified:
|
||||
- solitaire_core/src/game_state.rs
|
||||
|
||||
notes: "prevents unbounded memory growth"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Guardian
|
||||
|
||||
```yaml
|
||||
status: APPROVED
|
||||
|
||||
notes:
|
||||
- "respects core constraints"
|
||||
- "no invariant violations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Mental Model
|
||||
|
||||
* Builder = **creative**
|
||||
* Guardian = **strict**
|
||||
|
||||
Builder explores
|
||||
Guardian enforces
|
||||
|
||||
Neither replaces the other.
|
||||
|
||||
---
|
||||
|
||||
## 12. Success Criteria
|
||||
|
||||
System is working if:
|
||||
|
||||
* architectural violations go to ~0
|
||||
* code stays consistent across features
|
||||
* refactors become safe
|
||||
* complexity grows sub-linearly
|
||||
@@ -0,0 +1,112 @@
|
||||
# Credits
|
||||
|
||||
Solitaire Quest is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
|
||||
the work of many open-source projects and a small handful of third-party
|
||||
assets. This file lists every component that ships in the binary or in the
|
||||
`assets/` directory.
|
||||
|
||||
---
|
||||
|
||||
## Code & Framework
|
||||
|
||||
| Component | License | Role |
|
||||
|---|---|---|
|
||||
| [Bevy 0.18](https://bevyengine.org/) | MIT OR Apache-2.0 | Game engine, ECS, rendering, UI |
|
||||
| [kira 0.12](https://crates.io/crates/kira) | MIT OR Apache-2.0 | Audio playback (mixer, sub-tracks, looping ambient) |
|
||||
| [serde](https://crates.io/crates/serde) / [serde_json](https://crates.io/crates/serde_json) | MIT OR Apache-2.0 | Serialization for save files and the sync API |
|
||||
| [tokio](https://crates.io/crates/tokio) | MIT | Async runtime for the sync client and server |
|
||||
| [axum 0.8](https://crates.io/crates/axum) | MIT | HTTP framework for the self-hosted sync server |
|
||||
| [sqlx 0.8](https://crates.io/crates/sqlx) | MIT OR Apache-2.0 | Compile-time-checked SQLite access on the server |
|
||||
| [reqwest 0.13](https://crates.io/crates/reqwest) | MIT OR Apache-2.0 | HTTP client for the sync provider |
|
||||
| [jsonwebtoken 10](https://crates.io/crates/jsonwebtoken) | MIT | JWT issuance and validation |
|
||||
| [bcrypt 0.19](https://crates.io/crates/bcrypt) | MIT | Password hashing on the server |
|
||||
| [keyring 4](https://crates.io/crates/keyring) | MIT OR Apache-2.0 | OS keychain integration for credential storage |
|
||||
| [tower-governor 0.8](https://crates.io/crates/tower-governor) | MIT | Rate limiting on `/api/auth/*` |
|
||||
| [chrono](https://crates.io/crates/chrono) | MIT OR Apache-2.0 | Date / time handling |
|
||||
| [uuid](https://crates.io/crates/uuid) | MIT OR Apache-2.0 | User and session identifiers |
|
||||
| [thiserror](https://crates.io/crates/thiserror) | MIT OR Apache-2.0 | Error type derive |
|
||||
| [rand 0.9](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Seeded shuffler in `solitaire_core` |
|
||||
| [png 0.17](https://crates.io/crates/png) | MIT OR Apache-2.0 | PNG encoder used by `solitaire_assetgen` |
|
||||
| [ab_glyph 0.2](https://crates.io/crates/ab_glyph) | Apache-2.0 | Glyph rasterization for generated card art |
|
||||
|
||||
The full transitive dependency tree (several hundred crates) is captured in
|
||||
`Cargo.lock` and reachable via `cargo tree`. Every crate brought in is
|
||||
MIT, Apache-2.0, BSD-style, or a dual-licensed combination thereof — no
|
||||
copyleft code is statically linked into the game binary.
|
||||
|
||||
---
|
||||
|
||||
## Assets
|
||||
|
||||
### Card artwork
|
||||
|
||||
| File(s) | Source | License |
|
||||
|---|---|---|
|
||||
| `solitaire_engine/assets/themes/default/{suit}_{rank}.svg` (52 SVGs) | [hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets) | MIT |
|
||||
| `solitaire_engine/assets/themes/default/back.svg` | Original — Solitaire Quest | MIT (this project) |
|
||||
| `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | Pre-rendered from the same `playing-cards-assets` SVGs | MIT (passed through from hayeah) |
|
||||
| `assets/cards/backs/back_0.png` – `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
|
||||
|
||||
The face SVGs come from Howard Yeh's `playing-cards-assets` repository, which
|
||||
is itself derived from the public-domain `vector-playing-cards` Google Code
|
||||
project. The art is redistributed under the MIT license — see the upstream
|
||||
repository for the full notice. The files ship unmodified in the bundled
|
||||
default theme; user-supplied themes can override them per-installation
|
||||
through the runtime SVG theming system documented in `CARD_PLAN.md`.
|
||||
|
||||
The default card back is original work by this project, midnight-purple
|
||||
themed to match the rest of the UI palette.
|
||||
|
||||
### Backgrounds
|
||||
|
||||
| File(s) | Source | License |
|
||||
|---|---|---|
|
||||
| `assets/backgrounds/bg_0.png` – `bg_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
|
||||
|
||||
### Typography
|
||||
|
||||
| File | Source | License |
|
||||
|---|---|---|
|
||||
| `assets/fonts/main.ttf` (FiraMono-Medium) | [mozilla/Fira](https://github.com/mozilla/Fira) | SIL Open Font License 1.1 |
|
||||
|
||||
The OFL permits redistribution and embedding in software so long as the font
|
||||
file itself is not sold standalone. The file ships unmodified.
|
||||
|
||||
### Audio
|
||||
|
||||
All six WAV files in `assets/audio/` are **original work** — there are no
|
||||
third-party audio samples in this project. They are synthesized
|
||||
programmatically by `solitaire_assetgen/src/bin/gen_sfx.rs`, which writes
|
||||
44.1 kHz mono 16-bit PCM WAVs using a hand-rolled WAV writer (no `hound` or
|
||||
`dasp` dependency). The synthesis stack is entirely additive: sine /
|
||||
square waves, layered harmonics, deterministic LCG noise, AR envelopes,
|
||||
and a slow LFO for the ambient track.
|
||||
|
||||
| File | Synthesis approach |
|
||||
|---|---|
|
||||
| `card_deal.wav` | Filtered LCG noise with a sweeping low-pass cutoff for a "whoosh" |
|
||||
| `card_flip.wav` | High-passed LCG noise under a fast AR envelope |
|
||||
| `card_place.wav` | 120 Hz sine body + filtered noise click |
|
||||
| `card_invalid.wav` | Two dissonant square tones (196 Hz + 207.65 Hz) beating against each other |
|
||||
| `win_fanfare.wav` | C-major arpeggio (C5 / E5 / G5 / C6) with sine + 2nd harmonic |
|
||||
| `ambient_loop.wav` | 55 Hz fundamental with 2nd and 3rd harmonics, modulated by a 0.2 Hz LFO; loop length is chosen so the tone and LFO both complete an integer number of cycles for seamless looping |
|
||||
|
||||
Audio files are MIT-licensed alongside the rest of this project.
|
||||
|
||||
---
|
||||
|
||||
## License Summary
|
||||
|
||||
- **Project code:** MIT — see [LICENSE](LICENSE).
|
||||
- **Card face artwork (52 SVGs from hayeah/playing-cards-assets, plus the
|
||||
pre-rendered PNGs in `assets/cards/faces/`):** MIT, redistributed
|
||||
unmodified. The original `vector-playing-cards` line art is itself
|
||||
public domain.
|
||||
- **FiraMono-Medium font:** SIL Open Font License 1.1, redistributed unmodified.
|
||||
- **All other assets** (backgrounds, the default `back.svg`, generated card
|
||||
backs, every audio file) are original work covered by this project's MIT
|
||||
license.
|
||||
|
||||
If you redistribute Solitaire Quest, you must ship this `CREDITS.md` and the
|
||||
`LICENSE` file alongside the binary so the MIT (project + hayeah card art)
|
||||
and OFL (FiraMono) notices remain visible to end users.
|
||||
@@ -5,42 +5,114 @@ members = [
|
||||
"solitaire_data",
|
||||
"solitaire_engine",
|
||||
"solitaire_server",
|
||||
"solitaire_gpgs",
|
||||
"solitaire_app",
|
||||
"solitaire_assetgen",
|
||||
"solitaire_wasm",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
version = "0.1.0"
|
||||
license = "MIT"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
thiserror = "1"
|
||||
rand = "0.8"
|
||||
thiserror = "2"
|
||||
rand = "0.9"
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
dirs = "5"
|
||||
keyring = "2"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
dirs = "6"
|
||||
keyring = "4"
|
||||
keyring-core = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||
arboard = { version = "3", default-features = false }
|
||||
|
||||
solitaire_core = { path = "solitaire_core" }
|
||||
solitaire_sync = { path = "solitaire_sync" }
|
||||
solitaire_data = { path = "solitaire_data" }
|
||||
solitaire_engine = { path = "solitaire_engine" }
|
||||
|
||||
bevy = "0.15"
|
||||
kira = "0.9"
|
||||
# Bevy with `default-features = false` to avoid the unused
|
||||
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
||||
# Audio is handled directly by `kira` in `audio_plugin.rs`, so the
|
||||
# `bevy_audio` feature is intentionally omitted. The features below
|
||||
# enumerate every leaf of the standard `2d` + `ui` meta-features that
|
||||
# we actually use; new features should only be added with a
|
||||
# corresponding use site.
|
||||
bevy = { version = "0.18", default-features = false, features = [
|
||||
# default_app
|
||||
"async_executor",
|
||||
"bevy_asset",
|
||||
"bevy_input_focus",
|
||||
"bevy_log",
|
||||
"bevy_state",
|
||||
"bevy_window",
|
||||
"custom_cursor",
|
||||
"reflect_auto_register",
|
||||
# default_platform (desktop subset; no android/webgl/gilrs/sysinfo)
|
||||
"std",
|
||||
"bevy_winit",
|
||||
"default_font",
|
||||
"multi_threaded",
|
||||
# winit prefers Wayland when WAYLAND_DISPLAY is set on the
|
||||
# session and falls through to X11 otherwise. Without `wayland`,
|
||||
# winit-on-Wayland-session falls back to XWayland which renders
|
||||
# the game in an X11 frame inside the Wayland compositor.
|
||||
"wayland",
|
||||
"x11",
|
||||
# common_api
|
||||
"bevy_color",
|
||||
"bevy_image",
|
||||
"bevy_mesh",
|
||||
"bevy_shader",
|
||||
"bevy_text",
|
||||
"png",
|
||||
# 2d rendering
|
||||
"bevy_camera",
|
||||
"bevy_render",
|
||||
"bevy_core_pipeline",
|
||||
"bevy_sprite",
|
||||
"bevy_sprite_render",
|
||||
# UI rendering
|
||||
"bevy_ui",
|
||||
"bevy_ui_render",
|
||||
] }
|
||||
kira = "0.12"
|
||||
|
||||
axum = "0.7"
|
||||
# SVG rasterisation pipeline for the runtime card-theme system.
|
||||
# usvg parses + simplifies; resvg renders to a tiny-skia Pixmap;
|
||||
# tiny-skia provides the CPU rasteriser. All three are maintained
|
||||
# together by the resvg-rs project and version in lockstep.
|
||||
usvg = "0.47"
|
||||
resvg = "0.47"
|
||||
tiny-skia = "0.12"
|
||||
|
||||
# Theme manifest format. RON keeps the file human-editable while
|
||||
# preserving Rust-style structures the importer can validate.
|
||||
ron = "0.12"
|
||||
|
||||
# Importer-only: reads user-supplied theme zip archives, validates
|
||||
# their contents, and unpacks them into the user themes directory.
|
||||
# Default features are disabled to keep the dependency footprint small;
|
||||
# only `deflate` is needed because the importer rejects other
|
||||
# compression methods anyway (see Phase 7 spec).
|
||||
zip = { version = "8.6", default-features = false, features = ["deflate"] }
|
||||
|
||||
# Importer-only test dependency: tests build zip archives in a
|
||||
# scratch directory so they don't pollute the real user themes path
|
||||
# on the developer's machine.
|
||||
tempfile = "3.27"
|
||||
|
||||
axum = "0.8"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
||||
jsonwebtoken = "9"
|
||||
bcrypt = "0.15"
|
||||
tower_governor = "0.4"
|
||||
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
|
||||
bcrypt = "0.19"
|
||||
tower_governor = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dotenvy = "0.15"
|
||||
|
||||
@@ -6,10 +6,6 @@ FROM rust:slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y pkg-config libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
|
||||
# Tell sqlx to use the cached query metadata instead of a live database.
|
||||
@@ -22,11 +18,11 @@ RUN cargo build --release -p solitaire_server
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y libssl3 ca-certificates \
|
||||
&& apt-get install -y ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
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"]
|
||||
|
||||
@@ -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,122 @@
|
||||
# Solitaire Quest
|
||||
|
||||
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
|
||||
system, full progression (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; foundations are
|
||||
unlocked (any Ace lands in any empty slot, the slot then claims that suit)
|
||||
- **Card themes** — bundled hayeah/playing-cards-assets default plus
|
||||
user-installable themes (drop a directory under the data dir or import a
|
||||
zip from Settings → Cosmetic)
|
||||
- **Modern HUD** — reserved top band keeps cards from crowding the score
|
||||
readout; the action bar auto-fades when the cursor leaves it so it can't
|
||||
compete with the play surface
|
||||
- **Drag feel** — every legal drop target is highlighted in green during
|
||||
drag; cards cast a soft drop shadow that lifts when picked up; the stock
|
||||
pile shows a remaining-count chip so you can see how close you are to a
|
||||
recycle
|
||||
- **Keyboard navigation** — Tab cycles focus through buttons, arrow keys
|
||||
move within picker rows, Enter activates; works across every modal and
|
||||
the HUD action bar
|
||||
- **Progression** — XP, levels, unlockable card backs and backgrounds
|
||||
- **19 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 alongside the suit
|
||||
glyph
|
||||
|
||||
## 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
|
||||
|
||||
Every action also has a visible UI button — keyboard shortcuts are optional
|
||||
accelerators.
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| Left click / drag | Move cards |
|
||||
| Double click | Auto-move card to its best legal destination |
|
||||
| Right click | Highlight legal moves for a card |
|
||||
| Space / D | Draw from stock |
|
||||
| U | Undo |
|
||||
| H | Hint (highlight a legal move) |
|
||||
| N | New game |
|
||||
| Z | Zen mode |
|
||||
| G | Forfeit (during pause) |
|
||||
| Tab / Shift+Tab | Cycle keyboard focus |
|
||||
| Enter | Activate focused button / auto-complete (when badge is lit) |
|
||||
| Esc | Pause / dismiss modal |
|
||||
| F1 | Help / controls |
|
||||
| F11 | Toggle fullscreen |
|
||||
| S / A / P / O / L / M | Stats / Achievements / Profile / Settings / Leaderboard / Menu |
|
||||
|
||||
## Card themes
|
||||
|
||||
The default theme ships embedded in the binary, so the game runs
|
||||
self-contained with no external assets. To install another theme, drop a
|
||||
directory containing a `theme.ron` manifest plus 53 SVG files (52 faces +
|
||||
1 back) under the platform data dir's `themes/` folder, or import a zip
|
||||
from **Settings → Cosmetic**. The picker chip lights up the moment a new
|
||||
theme is registered. Themes are SVG-based, so they rasterise cleanly at
|
||||
whatever resolution the window happens to be.
|
||||
|
||||
## 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 (982 passing as of v0.11.0)
|
||||
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 --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
||||
(Tokio, Axum, sqlx, Serde, kira, and many more). Card faces come from
|
||||
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
|
||||
(MIT, derived from the public-domain `vector-playing-cards` library); the
|
||||
default card back is original work; the UI font is FiraMono-Medium (OFL).
|
||||
All audio is synthesized programmatically by this project. See
|
||||
[CREDITS.md](CREDITS.md) for the full list and license details.
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,180 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-06 (post-v0.19.0) — Tagged + pushed at
|
||||
`6037596`. v0.19.0 closes the v0.18.0 punch list (async H-key hint,
|
||||
persistent replay share URLs), expands desktop platform fit (Wayland
|
||||
session support + monitor-aware default window size), polishes the
|
||||
win-celebration and double-click animation paths, and clears two
|
||||
test-flake contributors. A short-lived "Rusty Pixel" pixel-art card
|
||||
theme was prototyped and reverted in the same window.
|
||||
|
||||
## Status at pause
|
||||
|
||||
- **HEAD on origin:** `6037596` (post-tag commit; the tag itself
|
||||
points at this commit).
|
||||
- **Working tree:** modified — `CHANGELOG.md` and
|
||||
`SESSION_HANDOFF.md` carry the v0.19.0 promotion + this refresh,
|
||||
ready to commit.
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
clean (verified this session).
|
||||
- **Tests:** **1170 passing / 0 failing** across the workspace
|
||||
(verified this session). One known flake remains:
|
||||
`solitaire_engine::sync_plugin::tests::pull_failure_sets_error_status`
|
||||
occasionally fails when cargo-test parallelism starves the
|
||||
`AsyncComputeTaskPool` within the test's 5-update budget. Same
|
||||
shape as the auto-save flake before v0.19.0's hardening; could be
|
||||
fixed similarly with a wall-clock-bounded loop.
|
||||
- **Tags on origin:** `v0.9.0` through `v0.18.0` (v0.19.0 ready to
|
||||
push once committed).
|
||||
|
||||
## Where we are
|
||||
|
||||
v0.18.0's resume-prompt menu (A–D) is closed:
|
||||
|
||||
- ~~**A — Tag v0.18.0:**~~ shipped at `bfcd05f`.
|
||||
- ~~**B — Solver-on-`AsyncComputeTaskPool` for the H-key hint:**~~
|
||||
shipped at `3e11e9e`.
|
||||
- **C — Desktop packaging:** still gated on artwork + signing
|
||||
certs. Icon export PNGs (11 sizes, 16–1024 px) sit in
|
||||
`artwork/` from the v0.18-era export; not yet wired into the
|
||||
Bevy window or assembled into `.icns` / `.ico`. App icon is
|
||||
the first natural step.
|
||||
- ~~**D — Persistent share link:**~~ shipped at `42d90b1`.
|
||||
|
||||
The Rusty Pixel theme arc is documented as a sub-history but
|
||||
not part of v0.19.0's content:
|
||||
|
||||
| Commit | Status |
|
||||
|---|---|
|
||||
| `de47511` PNG-format thumbnail support | reverted |
|
||||
| `17e3112` `pixel_art: bool` field + nearest-sampling opt-in | reverted |
|
||||
| `21ec03b` bundle Rusty Pixel as `embedded://` theme | reverted |
|
||||
| `aad8bb9` / `e41def8` / `0b3140a` reverts | landed |
|
||||
|
||||
The arc remains in commit history for archaeology but the
|
||||
codebase reaches v0.19.0's HEAD identical to where it would be if
|
||||
the arc had never landed.
|
||||
|
||||
### Design direction (unchanged)
|
||||
|
||||
- **Tone:** Balatro — chunky readable type, theatrical hierarchy,
|
||||
satisfying micro-interactions.
|
||||
- **Palette:** Midnight Purple base + Balatro yellow primary + warm
|
||||
magenta secondary.
|
||||
|
||||
### Canonical remote
|
||||
|
||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
|
||||
Always push there.
|
||||
|
||||
## v0.19.0 (2026-05-06)
|
||||
|
||||
| Area | Commits | What landed |
|
||||
|---|---|---|
|
||||
| Async H-key hint | `3e11e9e` | New `pending_hint.rs` module: `PendingHintTask` resource, `poll_pending_hint_task` + `drop_pending_hint_on_state_change` systems, cancel-on-replace, stale-state guard via `move_count_at_spawn`. Removes the last synchronous solver hot path. |
|
||||
| Persistent share URLs | `42d90b1` | `Replay.share_url: Option<String>` with `#[serde(default)]`. `poll_replay_upload_result` writes into `replays[0].share_url` + persists. Stats Copy button reads from selected replay. `LastSharedReplayUrl` deleted. |
|
||||
| Auto-save flake fix | `91b7605` | `test_app` clears `PendingRestoredGame(None)` after plugin build; test re-arms the timer in a bounded loop. No production-code change. |
|
||||
| Wayland support | `b57db01` | Adds `wayland` to Bevy features. winit prefers Wayland when `WAYLAND_DISPLAY` is set, falls back to X11. Native Wayland surface instead of XWayland frame. |
|
||||
| Smart default window size | `b57db01` | New `apply_smart_default_window_size` Update system queries `PrimaryMonitor` and resizes the window to ~70 % of monitor's logical size on the first frame. Skipped when saved geometry was applied. |
|
||||
| Win-celebration cleanup | `55c235b` | Drops the duplicate "You Win" toast that rendered behind the WinSummary modal. Cards-fly-off cascade kept; toast removed. |
|
||||
| Double-click reject animation | `d7ffb16` | Single-card double-clicks with no destination now play the same shake + sound as multi-card stack misses. Both priorities' failure paths converge on one `MoveRejectedEvent` write. |
|
||||
| Double-click animation dedup | `6037596` | Drops the redundant `StateChangedEvent` write in `end_drag`'s uncommitted-drag branch; previously raced an in-flight CardAnim and restarted the slide visibly. |
|
||||
|
||||
## Open punch list
|
||||
|
||||
### Carried forward
|
||||
|
||||
- **Desktop packaging** per `ARCHITECTURE.md §17`. Eleven icon
|
||||
PNG sizes (16, 24, 32, 48, 64, 96, 128, 192, 256, 512, 1024)
|
||||
exported via `artwork/Icon Export.html` sit in `artwork/`
|
||||
pending wiring. Pending: actual Bevy window-icon hookup,
|
||||
macOS `.icns` assembly via `iconutil`, Windows `.ico` via
|
||||
`magick convert`, Linux hicolor PNG hierarchy install,
|
||||
AppImage recipe, macOS notarisation cert, Windows
|
||||
Authenticode cert.
|
||||
|
||||
### Possible next-round candidates
|
||||
|
||||
- **App icon round** — wire the icon into the Bevy window via
|
||||
`Window::icon`, generate `.icns` and `.ico` from the existing
|
||||
PNGs. Half-day task; doesn't depend on signing certs.
|
||||
- **`pull_failure_sets_error_status` flake fix** — same pattern
|
||||
as the auto-save flake. Wall-clock-bounded loop instead of
|
||||
fixed 5-update budget. ~10 lines.
|
||||
- **Settings UI for "open at this size on launch"** — once the
|
||||
smart-default-size system is shipping, expose a checkbox to
|
||||
*disable* it (player who specifically wants 1280×800 every
|
||||
time). Trivial.
|
||||
- **Persistent share link URL on selector caption** — surface
|
||||
whether the currently-selected replay has a `share_url`
|
||||
populated (e.g. "Replay 3 / 8 \u{2022} Shareable") so players
|
||||
know which entries the Copy button can copy.
|
||||
|
||||
### Process notes (from this round)
|
||||
|
||||
- **Async port template (worked again):** the H-key port
|
||||
followed `d489e7a`'s `PendingNewGameSeed` shape one-to-one
|
||||
and the second async port required no new infrastructure.
|
||||
Future async ports (e.g. moving `try_solve_with_first_move`'s
|
||||
full-search variant, if it ever surfaces in the picker UI)
|
||||
should follow the same shape.
|
||||
- **Rusty Pixel reverted cleanly:** `git revert` of three
|
||||
contiguous feature commits produced a clean three-revert
|
||||
sequence with no manual conflict resolution. Bisect remains
|
||||
fast over the full v0.19.0 history because the reverts are
|
||||
individual commits, not a squash.
|
||||
- **Defensive event writes pattern:** the
|
||||
`auto_save_writes_after_30_seconds` flake AND the
|
||||
`end_drag` double-animation bug shared a root cause:
|
||||
defensive `MessageWriter` writes that originally covered an
|
||||
edge case which no longer holds, but became load-bearing
|
||||
once another system started paying attention to the event.
|
||||
Worth a periodic pass: any event write that doesn't
|
||||
correspond to a real state change is a candidate for
|
||||
removal.
|
||||
|
||||
## Resume prompt
|
||||
|
||||
```
|
||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||
Working directory: <Rusty_Solitaire clone path on this machine>.
|
||||
Branch: master. v0.19.0 just shipped. The next natural item is
|
||||
desktop-packaging follow-through, starting with the app icon.
|
||||
|
||||
State: HEAD at 6037596 + the v0.19.0 docs commit on top (this
|
||||
session). Tag v0.19.0 points at the docs commit.
|
||||
|
||||
READ FIRST (in order, before doing anything):
|
||||
1. SESSION_HANDOFF.md — this file
|
||||
2. CHANGELOG.md — [Unreleased] is empty; [0.19.0] just landed
|
||||
3. CLAUDE.md — unified-3.0 rule set
|
||||
4. CLAUDE_SPEC.md — formal architecture spec
|
||||
5. ARCHITECTURE.md — crate responsibilities + data flow
|
||||
6. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||
— saved feedback / project context
|
||||
(machine-local; may be missing on a
|
||||
fresh machine)
|
||||
|
||||
DECISION TO ASK THE PLAYER FIRST:
|
||||
A. App icon — wire artwork/icon-{size}.png into Bevy's
|
||||
Window::icon, generate .icns + .ico, drop into Linux
|
||||
hicolor hierarchy. Half-day task. No cert dependency.
|
||||
B. Desktop packaging continued — AppImage recipe, .desktop
|
||||
file, install scripts. Larger task; unlocks distro
|
||||
packaging. No cert dependency.
|
||||
C. macOS / Windows signing cert acquisition — needs user
|
||||
action; agent can't drive.
|
||||
D. `pull_failure_sets_error_status` flake fix — small, well-
|
||||
scoped. Same pattern as the v0.19.0 auto-save flake fix.
|
||||
|
||||
WORKFLOW NOTES:
|
||||
- Use the system git config (already correct).
|
||||
- When attributing playtester feedback in commits/docs, use
|
||||
"Quat" not "Rhys" (saved feedback memory).
|
||||
- Sub-agents stage + verify only; orchestrator commits.
|
||||
- Every commit must pass build / clippy / test before pushing.
|
||||
- Push to GitHub (origin) — gh auth setup-git is already
|
||||
wired on this machine.
|
||||
|
||||
OPEN AT THE START: ask which of A–D. Don't pick unilaterally.
|
||||
```
|
||||
|
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: 39 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 196 KiB |
@@ -1,7 +1,7 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
> Last updated: 2026-04-25
|
||||
> Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git
|
||||
> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitaire.git
|
||||
> Test count: **242 passing** (83 core + 60 data + 99 engine), `cargo clippy --workspace -- -D warnings` clean
|
||||
|
||||
---
|
||||
|
||||
@@ -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]
|
||||
name = "solitaire_app"
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
@@ -11,3 +12,4 @@ path = "src/main.rs"
|
||||
bevy = { workspace = true }
|
||||
solitaire_engine = { workspace = true }
|
||||
solitaire_data = { workspace = true }
|
||||
keyring = { workspace = true }
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{
|
||||
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
|
||||
};
|
||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||
use solitaire_engine::{
|
||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin,
|
||||
ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, GamePlugin,
|
||||
HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin,
|
||||
PausePlugin, ProfilePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin,
|
||||
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
|
||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||
SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
|
||||
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
|
||||
// sync provider. Falls back to defaults if no settings file exists yet.
|
||||
let settings: Settings = settings_file_path()
|
||||
@@ -16,25 +43,87 @@ fn main() {
|
||||
.unwrap_or_default();
|
||||
let sync_provider = provider_for_backend(&settings.sync_backend);
|
||||
|
||||
App::new()
|
||||
// Restore the previous window geometry if the player has one saved.
|
||||
// Otherwise open at the platform default (1280×800, centred on the
|
||||
// primary monitor) — `apply_smart_default_window_size` will resize
|
||||
// up to a monitor-relative target on the first frame so HiDPI / 4K
|
||||
// sessions don't end up with a comparatively tiny window.
|
||||
let had_saved_geometry = settings.window_geometry.is_some();
|
||||
let (window_resolution, window_position) = match settings.window_geometry {
|
||||
Some(geom) => (
|
||||
(geom.width, geom.height).into(),
|
||||
WindowPosition::At(IVec2::new(geom.x, geom.y)),
|
||||
),
|
||||
None => (
|
||||
(1280u32, 800u32).into(),
|
||||
WindowPosition::Centered(MonitorSelection::Primary),
|
||||
),
|
||||
};
|
||||
|
||||
let mut app = App::new();
|
||||
|
||||
// The card-theme system's `themes://` asset source must be
|
||||
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
|
||||
// because that plugin freezes the asset-source list at build
|
||||
// time. The matching `AssetSourcesPlugin` (added below) finishes
|
||||
// the wiring after `DefaultPlugins` by populating the embedded
|
||||
// default theme into Bevy's `EmbeddedAssetRegistry`.
|
||||
register_theme_asset_sources(&mut app);
|
||||
|
||||
app
|
||||
.add_plugins(
|
||||
DefaultPlugins.set(WindowPlugin {
|
||||
DefaultPlugins
|
||||
.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: "Solitaire Quest".into(),
|
||||
resolution: (1280.0, 800.0).into(),
|
||||
// X11/Wayland WM_CLASS so taskbar managers group
|
||||
// multiple windows of this app correctly.
|
||||
name: Some("solitaire-quest".into()),
|
||||
resolution: window_resolution,
|
||||
position: window_position,
|
||||
// AutoNoVsync prefers Mailbox (triple-buffered) and
|
||||
// falls back to Immediate, eliminating the vsync stall
|
||||
// that AutoVsync produces during continuous window
|
||||
// resize on X11 / Wayland. The game's frame budget is
|
||||
// small enough that a few stray dropped frames from
|
||||
// disabling vsync are imperceptible.
|
||||
present_mode: PresentMode::AutoNoVsync,
|
||||
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()
|
||||
}),
|
||||
)
|
||||
.add_plugins(AssetSourcesPlugin)
|
||||
.add_plugins(ThemePlugin)
|
||||
.add_plugins(ThemeRegistryPlugin)
|
||||
.add_plugins(FontPlugin)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(CardPlugin)
|
||||
.add_plugins(CursorPlugin)
|
||||
.add_plugins(InputPlugin)
|
||||
.add_plugins(RadialMenuPlugin)
|
||||
.add_plugins(SelectionPlugin)
|
||||
.add_plugins(AnimationPlugin)
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(ReplayPlaybackPlugin)
|
||||
.add_plugins(ReplayOverlayPlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
.add_plugins(ProgressPlugin::default())
|
||||
.add_plugins(AchievementPlugin::default())
|
||||
@@ -44,7 +133,7 @@ fn main() {
|
||||
.add_plugins(TimeAttackPlugin)
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
@@ -52,5 +141,109 @@ fn main() {
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(LeaderboardPlugin)
|
||||
.run();
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(UiTooltipPlugin)
|
||||
.add_plugins(SplashPlugin);
|
||||
|
||||
// Smart default window sizing: when no saved geometry was loaded,
|
||||
// resize the freshly-opened 1280×800 window to ~70 % of the primary
|
||||
// monitor's logical size on the first frame. Without this, a 4K
|
||||
// monitor opens the same 1280×800 window that a 1080p monitor
|
||||
// does — visually tiny relative to screen. Skipped entirely when
|
||||
// saved geometry was applied; the player's preference always wins.
|
||||
if !had_saved_geometry {
|
||||
app.add_systems(Update, apply_smart_default_window_size);
|
||||
}
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
/// One-shot Update system that runs only on launches without saved
|
||||
/// window geometry. Resizes the primary window to a fraction of the
|
||||
/// primary monitor's *logical* size — bigger monitors get bigger
|
||||
/// windows automatically. Logical size already accounts for the OS's
|
||||
/// HiDPI scale factor, so a 2880×1800 Retina display reporting
|
||||
/// scale_factor 2.0 yields a 1440×900 logical size and a 1008×630
|
||||
/// target window — same physical inches as a 1920×1080 monitor with
|
||||
/// scale_factor 1.0 yielding 1344×756.
|
||||
///
|
||||
/// Uses `Local<bool>` to make itself one-shot rather than introducing
|
||||
/// a dedicated resource. The Update tick is necessary because Bevy
|
||||
/// populates the `Monitor` entities asynchronously after winit's
|
||||
/// Resumed event fires; they may not exist on the first Startup pass.
|
||||
fn apply_smart_default_window_size(
|
||||
mut applied: Local<bool>,
|
||||
monitors: Query<&Monitor, With<PrimaryMonitor>>,
|
||||
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
||||
) {
|
||||
if *applied {
|
||||
return;
|
||||
}
|
||||
let Ok(monitor) = monitors.single() else {
|
||||
// Primary monitor not yet spawned by bevy_winit. Try again
|
||||
// next frame; the cost is one early-exit per tick until
|
||||
// monitors arrive (typically frame 1 or 2).
|
||||
return;
|
||||
};
|
||||
let Ok(mut window) = windows.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let scale = monitor.scale_factor as f32;
|
||||
if scale <= 0.0 {
|
||||
// Defensive: a zero or negative scale factor would NaN the
|
||||
// arithmetic below. Bail and accept the default size.
|
||||
*applied = true;
|
||||
return;
|
||||
}
|
||||
let logical_w = monitor.physical_width as f32 / scale;
|
||||
let logical_h = monitor.physical_height as f32 / scale;
|
||||
|
||||
// Target 70 % of monitor in each dimension, clamped to the
|
||||
// existing 800×600 minimum and the monitor's own logical size
|
||||
// (so we never request a window larger than the screen).
|
||||
let target_w = (logical_w * 0.7).clamp(800.0, logical_w);
|
||||
let target_h = (logical_h * 0.7).clamp(600.0, logical_h);
|
||||
|
||||
// Resize only when the change is meaningful — at exactly 1280×800
|
||||
// on a 1920×1080 monitor the new target is 1344×756 (only ~5 %
|
||||
// wider), worth the resize; at the same default on an 800×600
|
||||
// monitor the clamp pins us at 800×600 and we shouldn't resize.
|
||||
let curr_w = window.resolution.width();
|
||||
let curr_h = window.resolution.height();
|
||||
if (curr_w - target_w).abs() > 8.0 || (curr_h - target_h).abs() > 8.0 {
|
||||
window.resolution.set(target_w, target_h);
|
||||
}
|
||||
*applied = true;
|
||||
}
|
||||
|
||||
/// 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_or(0, |d| d.as_secs());
|
||||
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
|
||||
}
|
||||
default_hook(info);
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
[package]
|
||||
name = "solitaire_assetgen"
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
edition.workspace = true
|
||||
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.
|
||||
|
||||
[dependencies]
|
||||
png = "0.17"
|
||||
ab_glyph = "0.2"
|
||||
|
||||
[[bin]]
|
||||
name = "gen_sfx"
|
||||
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,18 @@ fn main() -> io::Result<()> {
|
||||
let out_dir = workspace_root().join("assets").join("audio");
|
||||
fs::create_dir_all(&out_dir)?;
|
||||
|
||||
let effects: [(&str, Generator); 5] = [
|
||||
let effects: [(&str, Generator); 7] = [
|
||||
("card_flip.wav", card_flip),
|
||||
("card_place.wav", card_place),
|
||||
("card_deal.wav", card_deal),
|
||||
("card_invalid.wav", card_invalid),
|
||||
("win_fanfare.wav", win_fanfare),
|
||||
("ambient_loop.wav", ambient_loop),
|
||||
("foundation_complete.wav", foundation_complete),
|
||||
];
|
||||
|
||||
for (name, gen) in &effects {
|
||||
let samples = gen();
|
||||
for (name, make) in &effects {
|
||||
let samples = make();
|
||||
let path = out_dir.join(name);
|
||||
write_wav_mono_pcm16(&path, SAMPLE_RATE, &samples)?;
|
||||
println!("wrote {} ({} samples)", path.display(), samples.len());
|
||||
@@ -169,6 +171,102 @@ fn win_fanfare() -> Vec<i16> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Per-suit foundation-completion ping (~240 ms): a rising three-note
|
||||
/// chime — C6, E6, G6 — with a soft 2nd-harmonic warm layer on each
|
||||
/// note. Shorter and brighter than `win_fanfare` so it can fire up to
|
||||
/// four times per game (once per suit) without drowning out subsequent
|
||||
/// move sounds. The fourth firing co-occurs with the win cascade and
|
||||
/// `win_fanfare`; the C-major triad sits an octave above the
|
||||
/// fanfare's root so the two layer cleanly instead of fighting for the
|
||||
/// same frequency band.
|
||||
fn foundation_complete() -> Vec<i16> {
|
||||
// C major triad, one octave up from win_fanfare's root.
|
||||
let notes = [1046.50_f32, 1318.51, 1567.98]; // C6, E6, G6
|
||||
let note_dur = 0.07_f32; // brisk, ascending
|
||||
let total = note_dur * notes.len() as f32 + 0.05;
|
||||
let n = duration_samples(total);
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let t = i as f32 / SAMPLE_RATE as f32;
|
||||
let mut sample = 0.0f32;
|
||||
for (idx, freq) in notes.iter().enumerate() {
|
||||
let start = idx as f32 * note_dur;
|
||||
let local = t - start;
|
||||
// Each note rings out for 0.18 s — overlapping notes form a
|
||||
// brief chord at the tail.
|
||||
if !(0.0..=0.18).contains(&local) {
|
||||
continue;
|
||||
}
|
||||
// Sine + soft 2nd harmonic for warmth, ar_envelope decays
|
||||
// sharply so each note is bell-like rather than sustained.
|
||||
let s = (2.0 * std::f32::consts::PI * freq * local).sin()
|
||||
+ 0.25 * (2.0 * std::f32::consts::PI * freq * 2.0 * local).sin();
|
||||
let env = ar_envelope(local, 0.005, 0.18, 14.0);
|
||||
sample += s * env;
|
||||
}
|
||||
out.push(quantize(sample * 0.20));
|
||||
}
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "solitaire_core"
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
@@ -12,20 +12,25 @@
|
||||
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AchievementContext {
|
||||
// Stats (after this win has been recorded).
|
||||
/// Total number of games played (after this win has been recorded).
|
||||
pub games_played: u32,
|
||||
/// Total number of games won (after this win has been recorded).
|
||||
pub games_won: u32,
|
||||
/// Current consecutive win streak (after this win has been recorded).
|
||||
pub win_streak_current: u32,
|
||||
/// Highest single-game score ever achieved.
|
||||
pub best_single_score: u32,
|
||||
/// Cumulative score across all games ever played.
|
||||
pub lifetime_score: u64,
|
||||
/// Total wins completed in Draw 3 mode.
|
||||
pub draw_three_wins: u32,
|
||||
|
||||
// Progression.
|
||||
/// Current daily-challenge completion streak (consecutive days).
|
||||
pub daily_challenge_streak: u32,
|
||||
|
||||
// Last-win facts (GameWonEvent + GameState at win time).
|
||||
/// Score achieved in the just-won game.
|
||||
pub last_win_score: i32,
|
||||
/// Elapsed seconds for the just-won game.
|
||||
pub last_win_time_seconds: u64,
|
||||
/// `true` if `undo()` was called at least once during the won game.
|
||||
pub last_win_used_undo: bool,
|
||||
@@ -55,13 +60,17 @@ pub enum Reward {
|
||||
/// A single achievement's static metadata + unlock condition.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AchievementDef {
|
||||
/// Unique string identifier for this achievement (e.g. `"first_win"`).
|
||||
pub id: &'static str,
|
||||
/// Human-readable display name shown in the achievements screen.
|
||||
pub name: &'static str,
|
||||
/// Flavour text describing how to unlock the achievement.
|
||||
pub description: &'static str,
|
||||
/// Hidden from the achievements screen until unlocked.
|
||||
pub secret: bool,
|
||||
/// Reward granted on first unlock. `None` for cosmetic-only recognition.
|
||||
pub reward: Option<Reward>,
|
||||
/// Predicate evaluated on every `GameWonEvent` and `StateChangedEvent`. Returns true when the achievement should unlock.
|
||||
pub condition: fn(&AchievementContext) -> bool,
|
||||
}
|
||||
|
||||
@@ -131,6 +140,16 @@ fn comeback(c: &AchievementContext) -> bool {
|
||||
fn zen_winner(c: &AchievementContext) -> bool {
|
||||
c.last_win_is_zen
|
||||
}
|
||||
/// Cinephile is event-driven: it unlocks when the engine observes a
|
||||
/// `ReplayPlaybackState` transition from `Playing` to `Completed`, not on
|
||||
/// any field of [`AchievementContext`]. The condition predicate therefore
|
||||
/// always returns false so [`check_achievements`] never unlocks it from a
|
||||
/// `GameWonEvent` / `StateChangedEvent` cycle — the unlock is driven by
|
||||
/// `AchievementUnlockedEvent` written directly from the engine's
|
||||
/// replay-playback observer.
|
||||
fn cinephile_never(_c: &AchievementContext) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// All currently-evaluable achievements. Order is stable so persistence files
|
||||
/// remain readable across versions (new achievements append).
|
||||
@@ -279,6 +298,18 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
||||
reward: Some(Reward::Badge),
|
||||
condition: zen_winner,
|
||||
},
|
||||
AchievementDef {
|
||||
id: "cinephile",
|
||||
name: "Cinephile",
|
||||
description: "Watch a saved replay all the way through",
|
||||
secret: false,
|
||||
reward: None,
|
||||
// Event-driven unlock: the engine's replay-playback observer fires
|
||||
// `AchievementUnlockedEvent("cinephile")` directly on a Playing →
|
||||
// Completed transition. `cinephile_never` keeps the condition path
|
||||
// a no-op so a `GameWonEvent` evaluation cycle cannot unlock it.
|
||||
condition: cinephile_never,
|
||||
},
|
||||
];
|
||||
|
||||
/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
|
||||
@@ -477,6 +508,109 @@ mod tests {
|
||||
assert!(achievement_by_id("nonexistent").is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Direct predicate tests via ctx_defaults()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Baseline context representing a single clean one-minute win in Draw-One mode.
|
||||
fn ctx_defaults() -> AchievementContext {
|
||||
AchievementContext {
|
||||
games_played: 1,
|
||||
games_won: 1,
|
||||
win_streak_current: 1,
|
||||
best_single_score: 0,
|
||||
lifetime_score: 0,
|
||||
draw_three_wins: 0,
|
||||
daily_challenge_streak: 0,
|
||||
last_win_score: 0,
|
||||
last_win_time_seconds: 600,
|
||||
last_win_used_undo: false,
|
||||
wall_clock_hour: Some(12),
|
||||
last_win_recycle_count: 0,
|
||||
last_win_is_zen: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_demon_true_when_under_three_minutes() {
|
||||
let mut c = ctx_defaults();
|
||||
c.last_win_time_seconds = 179;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(ids.contains(&"speed_demon"), "speed_demon should unlock at 179s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_demon_false_when_over_three_minutes() {
|
||||
let mut c = ctx_defaults();
|
||||
c.last_win_time_seconds = 181;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(!ids.contains(&"speed_demon"), "speed_demon must not unlock at 181s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_true_when_under_90_seconds() {
|
||||
let mut c = ctx_defaults();
|
||||
c.last_win_time_seconds = 89;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(ids.contains(&"lightning"), "lightning should unlock at 89s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_false_at_exactly_90_seconds() {
|
||||
let mut c = ctx_defaults();
|
||||
c.last_win_time_seconds = 90;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(!ids.contains(&"lightning"), "lightning must not unlock at exactly 90s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_undo_true_when_zero_undos() {
|
||||
let mut c = ctx_defaults();
|
||||
c.last_win_used_undo = false;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(ids.contains(&"no_undo"), "no_undo should unlock when undo was not used");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_undo_false_when_undo_used() {
|
||||
let mut c = ctx_defaults();
|
||||
c.last_win_used_undo = true;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(!ids.contains(&"no_undo"), "no_undo must not unlock when undo was used");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_scorer_true_when_score_5000_or_more() {
|
||||
let mut c = ctx_defaults();
|
||||
c.best_single_score = 5_000;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(ids.contains(&"high_scorer"), "high_scorer should unlock at best_single_score=5000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_scorer_false_when_below_5000() {
|
||||
let mut c = ctx_defaults();
|
||||
c.best_single_score = 4_999;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(!ids.contains(&"high_scorer"), "high_scorer must not unlock at best_single_score=4999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_a_roll_true_at_streak_3() {
|
||||
let mut c = ctx_defaults();
|
||||
c.win_streak_current = 3;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock at streak=3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comeback_true_when_three_or_more_recycles() {
|
||||
let mut c = ctx_defaults();
|
||||
c.last_win_recycle_count = 3;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(ids.contains(&"comeback"), "comeback should unlock at last_win_recycle_count=3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_a_roll_requires_streak_of_3() {
|
||||
let mut c = ctx();
|
||||
@@ -609,6 +743,31 @@ mod tests {
|
||||
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cinephile_achievement_in_canonical_list() {
|
||||
let def = achievement_by_id("cinephile").expect("cinephile must be registered");
|
||||
assert_eq!(def.id, "cinephile");
|
||||
assert_eq!(def.name, "Cinephile");
|
||||
assert!(!def.secret, "cinephile is not a secret achievement");
|
||||
// Event-driven: the predicate is a sentinel that always returns
|
||||
// false. `check_achievements` must never unlock cinephile from a
|
||||
// GameWonEvent context, even one that satisfies every other gate.
|
||||
let mut c = ctx();
|
||||
c.games_won = 1;
|
||||
c.win_streak_current = 999;
|
||||
c.last_win_time_seconds = 1;
|
||||
c.last_win_used_undo = false;
|
||||
c.best_single_score = 99_999;
|
||||
c.lifetime_score = u64::MAX;
|
||||
c.last_win_is_zen = true;
|
||||
c.last_win_recycle_count = 99;
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(
|
||||
!ids.contains(&"cinephile"),
|
||||
"cinephile must never unlock via condition evaluation; got {ids:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfectionist_score_well_above_threshold_still_passes() {
|
||||
let mut c = ctx();
|
||||
|
||||
@@ -63,9 +63,13 @@ impl Rank {
|
||||
/// A single playing card.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Card {
|
||||
/// Unique identifier for this card within the deal. Stable across moves and undo.
|
||||
pub id: u32,
|
||||
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
|
||||
pub suit: Suit,
|
||||
/// The card's rank (Ace through King).
|
||||
pub rank: Rank,
|
||||
/// Whether the card is visible to the player. Face-down cards may not be moved.
|
||||
pub face_up: bool,
|
||||
}
|
||||
|
||||
@@ -73,16 +77,6 @@ pub struct Card {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rank_value_ace_is_one() {
|
||||
assert_eq!(Rank::Ace.value(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_value_king_is_thirteen() {
|
||||
assert_eq!(Rank::King.value(), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_values_are_sequential() {
|
||||
let ranks = [
|
||||
@@ -96,26 +90,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_red_is_diamonds_and_hearts() {
|
||||
assert!(Suit::Diamonds.is_red());
|
||||
assert!(Suit::Hearts.is_red());
|
||||
assert!(!Suit::Clubs.is_red());
|
||||
assert!(!Suit::Spades.is_red());
|
||||
fn suit_red_and_black_are_complementary() {
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
assert_ne!(suit.is_red(), suit.is_black(), "{suit:?} must be exactly one of red/black");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_black_is_clubs_and_spades() {
|
||||
assert!(Suit::Clubs.is_black());
|
||||
assert!(Suit::Spades.is_black());
|
||||
assert!(!Suit::Diamonds.is_black());
|
||||
assert!(!Suit::Hearts.is_black());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_up_field_reflects_construction() {
|
||||
let card = Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: false };
|
||||
assert!(!card.face_up);
|
||||
let card2 = Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true };
|
||||
assert!(card2.face_up);
|
||||
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
||||
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
||||
}
|
||||
}
|
||||
|
||||