Compare commits
278 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| daa655a0af | |||
| 4d48cad4e3 | |||
| dd970215cc | |||
| ddb65403c2 | |||
| 62b61cc786 | |||
| 31139ae455 | |||
| 07e035771c | |||
| c5787c6953 | |||
| 716a025352 | |||
| 3eb3a26789 | |||
| 0c1cc40266 | |||
| 04f9bf9be3 | |||
| a292a7ead0 | |||
| d109c32b75 | |||
| dd101b3d54 | |||
| af414b6aed | |||
| ae84dc1504 | |||
| 8719f77ec2 | |||
| a14200ac2f | |||
| e8bf9d79da | |||
| 48b28d29f8 | |||
| babe5cc9c8 | |||
| 3a4bb63a6f | |||
| 56233687b0 | |||
| 73ac67d76b | |||
| a27cf5a020 | |||
| 29136d815d | |||
| ef54cdeb65 | |||
| e080b49914 | |||
| 54005d5494 | |||
| 44f5972edd | |||
| 13ae16051d | |||
| a65e5b8c7b | |||
| 6204db8bb1 | |||
| c84d9f445c | |||
| cacb19c03f | |||
| 39b84965b6 | |||
| 41a009a693 | |||
| fa7f98ac52 | |||
| 9891ae4ba3 | |||
| cdcaddaabe | |||
| d752870007 | |||
| 1d1543e4bc | |||
| 651f4060e6 | |||
| a1376075bd | |||
| ceec4fc486 | |||
| 0d477ac9fd | |||
| 4b51e50203 | |||
| f2d2119db5 | |||
| 59424a370c | |||
| fb8b2ac684 | |||
| 690e1d2ad6 | |||
| 35516d31f6 | |||
| 9b065e5ac6 | |||
| e1b8766e15 | |||
| 67c150bd7b | |||
| 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 |
|
||||
|
||||
@@ -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,121 @@ 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)
|
||||
"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",
|
||||
# Android: NativeActivity glue. The feature is target-gated inside
|
||||
# bevy_internal — desktop builds compile it out, so leaving it on
|
||||
# the always-on list is harmless on Linux/macOS/Windows. Pairs with
|
||||
# cargo-apk's NativeActivity wrapper (cargo-apk 0.10+ uses this by
|
||||
# default). Switch to `android-game-activity` later if we want
|
||||
# AndroidX GameActivity for Google Play Games integration.
|
||||
"android-native-activity",
|
||||
# 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,313 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-08 — **v0.21.0 cut and tagged at `04f9bf9`**,
|
||||
working tree clean, all post-tag work pushed to origin.
|
||||
|
||||
v0.21.0 closes the visual-identity arc opened in v0.20.0. Three
|
||||
through-lines landed in this cycle: the **card-face / suit /
|
||||
card-back artwork migration** that v0.20.0 deliberately deferred
|
||||
(both rendering paths in lockstep — `assets/cards/*.png` fallback
|
||||
plus the bundled-default theme SVGs at
|
||||
`solitaire_engine/assets/themes/default/*.svg` that
|
||||
`include_bytes!()`-embed into the binary), the **splash boot-
|
||||
screen + replay-overlay polish** that closed Resume-prompt
|
||||
Options B and C, and a late-cycle **`ACCENT_PRIMARY` palette
|
||||
swap** from cyan `#6fc2ef` to brick red `#a54242` after a quick
|
||||
stakeholder review of the shipped art.
|
||||
|
||||
Full v0.21.0 detail lives in `CHANGELOG.md` § [0.21.0]. This
|
||||
file from here on focuses on what's *open* post-cut and how to
|
||||
resume.
|
||||
|
||||
## Status at pause
|
||||
|
||||
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is
|
||||
`04f9bf9`; any post-cut docs edits ride on top of that.
|
||||
- **HEAD on origin:** matches local. v0.21.0 is fully on origin.
|
||||
- **Working tree:** clean. No WIP outstanding.
|
||||
- **`artwork/` directory:** still untracked. Intentional.
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
clean.
|
||||
- **Tests:** **1184 passing / 0 failing** across the workspace
|
||||
(net +8 from v0.20.0's 1176 baseline). Detail in
|
||||
`CHANGELOG.md` § [0.21.0] § Stats.
|
||||
- **Tags on origin:** `v0.9.0` through `v0.21.0`. v0.21.0 is on
|
||||
`04f9bf9`; v0.20.0 stays on `41a009a`.
|
||||
|
||||
## Since the v0.21.0 cut
|
||||
|
||||
Two Resume-prompt options closed post-tag (2026-05-08):
|
||||
|
||||
- **Option A — App icon round** (`3eb3a26` + `716a025`). 9-size
|
||||
PNG hierarchy in `assets/icon/` (16/24/32/48/64/128/256/512/
|
||||
1024 px), generated by a new `icon_generator` example from a
|
||||
shared `icon_svg` builder (Terminal `▌RS` mark on dark
|
||||
`#151515` with brick-red accent). Runtime `Window::icon`
|
||||
wired via `WinitWindows` on desktop only (Android draws its
|
||||
launcher icon from the APK manifest). The follow-up fix
|
||||
`716a025` wraps `NonSend<WinitWindows>` in `Option<...>`
|
||||
to satisfy Bevy 0.18's stricter system-param validation —
|
||||
the resource doesn't exist on the first few frames before
|
||||
winit's `Resumed` event fires. New deps (target-gated
|
||||
non-Android): direct `winit = "0.30"` for `Icon`
|
||||
construction, direct `tiny-skia` for PNG → RGBA decode.
|
||||
Pin test `icon_svg_pin` guards future rasteriser drift.
|
||||
- **Option F — Accessibility modes** (`c5787c6` + `07e0357`).
|
||||
High-contrast and reduce-motion settings flags wired through
|
||||
the engine and surfaced as Settings panel toggles. HC boosts
|
||||
`RED_SUIT_COLOUR` to `#ff8aa0` and `BLACK_SUIT_COLOUR` to
|
||||
`#f5f5f5` for card text rendering; reduce-motion forces
|
||||
`effective_slide_secs` to 0 regardless of `AnimSpeed`. CBM
|
||||
and HC compose: lime CBM wins on red when both are on; HC
|
||||
still applies to black suits when both are on. Six new
|
||||
tests pin the truth tables. UI toggles sit alongside the
|
||||
Color-blind row in Settings → Cosmetic; tab-walk visits
|
||||
all three accessibility flags in one vertical run.
|
||||
|
||||
Three Resume-prompt options remain live: B (APK launch
|
||||
verification), C (replay-overlay extensions), D (Toast
|
||||
Warning/Error wiring), E (Phase 8 sync). The visible-payoff
|
||||
pieces of the post-v0.21.0 menu have shipped; what's left is
|
||||
Android runtime work, replay-overlay polish, sync infrastructure,
|
||||
and toast-event sourcing.
|
||||
|
||||
## Open punch list
|
||||
|
||||
### Phase Android (build + persistence shipped; runtime gaps remain)
|
||||
|
||||
- **APK launch verification on AVD / device.** `adb install` then
|
||||
`adb logcat` against the `bevy_test` AVD or an x86_64 device.
|
||||
The build works and persistence is wired, but no end-to-end
|
||||
device run has been logged. Shakes out runtime bugs the build +
|
||||
unit tests can't catch.
|
||||
- **JNI ClipboardManager bridge.** Replaces the Android stub for
|
||||
the Stats "Copy share link" toast. `arboard` doesn't ship an
|
||||
Android backend; small custom JNI call.
|
||||
- **Android Keystore for credentials.** `keyring` is target-gated
|
||||
to a stub returning `KeychainUnavailable`; replace with Android
|
||||
Keystore via JNI when sync auth ships on mobile.
|
||||
- **Google Play Games (gpgs) integration.** Listed as a
|
||||
Phase-Android target since Phase 1; now unblocked by the build
|
||||
target.
|
||||
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
|
||||
panic doesn't affect the APK on disk but produces noisy stderr.
|
||||
Either upstream a cargo-apk fix or document `--lib` as
|
||||
canonical in the runbook.
|
||||
|
||||
### Visual-identity follow-ups (post-v0.21.0)
|
||||
|
||||
The visual-identity arc is effectively complete: token system,
|
||||
chrome migration, splash boot screen, replay-overlay banner,
|
||||
card-face artwork (both rendering paths), and the `ACCENT_PRIMARY`
|
||||
palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
|
||||
|
||||
- **Replay-overlay screen-takeover redesign.** The full mockup
|
||||
(`docs/ui-mockups/replay-overlay-mobile.html`) calls for a
|
||||
mini-tableau preview, playback controls, move-log scroll, and
|
||||
a WIN MOVE marker on the scrub bar. Banner-local pieces all
|
||||
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` +
|
||||
`e080b49`); the screen-takeover is a multi-session redesign
|
||||
with data-layer impact (move-log scroller; WIN MOVE needs a
|
||||
`win_move_index` field on `Replay` that doesn't yet exist).
|
||||
- **Floating `MOVE N/M` chip above the focused card during
|
||||
playback.** Cross-plugin work — `update_progress_text` writes
|
||||
the banner chip but the card-position lookup belongs in
|
||||
`card_plugin`. Smaller scope than the screen-takeover.
|
||||
- **Toast Warning / Error variants.** `ToastVariant` has slots
|
||||
for `Warning` (gold) and `Error` (pink) but no in-engine
|
||||
event uses them yet. Wire when a warning- or error-flavoured
|
||||
toast event materialises.
|
||||
- *High-contrast accessibility mode — closed 2026-05-08 by
|
||||
`c5787c6` + `07e0357`.* Card text rendering picks up
|
||||
`TEXT_PRIMARY_HC` (`#f5f5f5`) and `RED_SUIT_COLOUR_HC`
|
||||
(`#ff8aa0`); Settings panel has a toggle. Future scope:
|
||||
extend HC through chrome borders (`BORDER_SUBTLE_HC` already
|
||||
defined, not yet consumed), buttons, popover edges.
|
||||
- *Reduced-motion mode — closed 2026-05-08 by the same pair.*
|
||||
`effective_slide_secs` forces 0 when on, regardless of the
|
||||
`AnimSpeed` setting. Future scope: gate splash scanline
|
||||
overlay + cursor pulse animation on the same flag, gate
|
||||
warning-chip pulse, gate any future card-lift z-bump
|
||||
animation.
|
||||
|
||||
### Carried forward from v0.19.0
|
||||
|
||||
- *App icon round — closed 2026-05-08 by `3eb3a26` + `716a025`.*
|
||||
Runtime `Window::icon` wired (Linux/macOS/Windows); 9-size
|
||||
PNG hierarchy at `assets/icon/icon_<size>.png` covers Linux
|
||||
hicolor + downstream `.icns`/`.ico` packaging needs. The
|
||||
`.ico` and `.icns` bundle-format files themselves are *not*
|
||||
generated — both would need new crate deps (`ico` and
|
||||
`icns` respectively) and only matter at app-bundle time
|
||||
(cargo-bundle / packaging), not at `cargo run`. Open if the
|
||||
project later ships as a packaged macOS / Windows app.
|
||||
|
||||
### Other small candidates
|
||||
|
||||
- **Prev/Next selector chips spawn site.** v0.19.0's `9b065e5`
|
||||
noted Prev/Next markers exist in `stats_plugin` but no spawn
|
||||
site renders them today — the Shareable badge therefore lands
|
||||
on the single-replay caption. If/when Prev/Next is plumbed,
|
||||
the badge will need to follow.
|
||||
- **Toast queue / immediate unification.** The two toast paths
|
||||
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
|
||||
for fire-and-forget) now share visual treatment but remain
|
||||
separate functions because they serve different temporal
|
||||
needs (sequential vs. parallel). If overlap becomes a UX
|
||||
issue, merge into one queue with priority lanes.
|
||||
|
||||
### Process notes
|
||||
|
||||
- **The desktop-adaptation spec is the canonical reference for
|
||||
geometry decisions** when porting any future plugin. Read
|
||||
`docs/ui-mockups/desktop-adaptation.md` first; apply the
|
||||
universal rules to every surface; consult the per-screen
|
||||
table for the priority surfaces. The 9 missing-plugin screens
|
||||
(splash now ported; eight remaining) inherit the universal
|
||||
rules without dedicated guidance.
|
||||
- **Stitch `generate_variants` is unreliable for layout-only
|
||||
adaptation prompts** as of 2026-05-07. The first call timed
|
||||
out and no variant ever landed in `list_screens`. If a future
|
||||
session wants visual desktop mockups, prefer
|
||||
`generate_screen_from_text` with a fresh narrow prompt per
|
||||
screen rather than `generate_variants` against existing
|
||||
mobile screens.
|
||||
- **Token-port pattern.** v0.20.0's chrome-migration commits
|
||||
set a reusable shape for "centralised design system applied
|
||||
across N plugins":
|
||||
1. Constants module (`ui_theme.rs`) is the source of truth.
|
||||
2. Const sites that can't call `Alpha::with_alpha` (not yet
|
||||
`const` on stable) use a literal RGB matching the token,
|
||||
with a unit test pinning the RGB to the token (e.g.
|
||||
`MARKER_VALID`, `HINT_PILE_HIGHLIGHT_COLOUR`,
|
||||
`RIGHT_CLICK_HIGHLIGHT_COLOUR`).
|
||||
3. Cross-plugin duplication (e.g. `MARKER_DEFAULT` ↔
|
||||
`PILE_MARKER_DEFAULT_COLOUR`) collapses to a single
|
||||
promoted const re-exported from one plugin and imported
|
||||
by the other — replaces "kept in sync" doc comments with a
|
||||
compile-time invariant.
|
||||
4. Domain colours (suit pips, card faces, lerp helpers) stay
|
||||
as literals with a comment naming the rationale; only UI
|
||||
chrome routes through tokens.
|
||||
- **`SplashFadable` scaffolding pattern** (introduced in
|
||||
`cacb19c`). Any future overlay that needs to fade `N >> 3`
|
||||
elements together should follow the same shape: one tiny
|
||||
marker carrying the full-alpha base colour, one global query
|
||||
that lerps every marker's alpha each frame, no per-element
|
||||
query plumbing. Cleanly outscales the `Without<X>, Without<Y>`
|
||||
query exclusion pattern that the old splash was hitting at
|
||||
three siblings.
|
||||
|
||||
### Canonical remote
|
||||
|
||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
|
||||
Always push there. As of v0.21.0 origin matches local; the next
|
||||
push happens when post-cut work accumulates and is ready to roll
|
||||
into a v0.21.1 / v0.22.0 cut.
|
||||
|
||||
### Design direction (Terminal — base16-eighties)
|
||||
|
||||
- **Tone:** retro-terminal / synthwave — flat depth (no box-shadows),
|
||||
monospaced-forward typography (JetBrains Mono / FiraMono), tight
|
||||
16 px edge margins, 8 px card radius.
|
||||
- **Palette:** near-black surface ramp (`#151515` / `#202020` /
|
||||
`#2a2a2a` / `#353535`), brick-red primary CTA (`#a54242` —
|
||||
swapped from cyan `#6fc2ef` in v0.21.0 commit `a292a7e`), lime
|
||||
success (`#acc267`), gold warning (`#ddb26f`), pink error /
|
||||
suit-red (`#fb9fb1`), lavender celebration (`#e1a3ee`), teal
|
||||
info (`#12cfc0`).
|
||||
- **Two-color suits.** Red = `#fb9fb1`, black = `#d0d0d0`.
|
||||
Outlined glyphs for diamonds & clubs are *always on*; the
|
||||
Settings "color-blind mode" toggle swaps red → lime `#acc267`
|
||||
(was red → cyan pre-v0.21.0; lime is the next-best non-red
|
||||
base16-eighties accent now that the primary itself is red).
|
||||
- **Card glyphs render upright in both corners** — no 180°
|
||||
inverted-corner-indicator rotation. Single-orientation
|
||||
digital play doesn't benefit from the traditional flip-
|
||||
readback convention. `design-system.md` § Game Cards
|
||||
documents this deliberate deviation.
|
||||
|
||||
## 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.21.0 is tagged at 04f9bf9 (cut 2026-05-08).
|
||||
Working tree clean. v0.21.0 closed the visual-identity arc that
|
||||
v0.20.0 deferred — full Terminal cards on both rendering paths
|
||||
(asset PNGs + bundled-default theme SVGs), splash boot screen,
|
||||
replay-overlay banner enrichments, and a project-wide ACCENT_PRIMARY
|
||||
swap from cyan to brick red `#a54242`. See CHANGELOG.md § [0.21.0]
|
||||
for full detail.
|
||||
|
||||
State: HEAD locally — see `git rev-parse HEAD`. All workspace tests
|
||||
pass (1184+; check with `cargo test --workspace`), clippy clean.
|
||||
|
||||
READ FIRST (in order, before doing anything):
|
||||
1. SESSION_HANDOFF.md — this file
|
||||
2. CHANGELOG.md — [0.21.0] section is the most recent cut
|
||||
3. CLAUDE.md — unified-3.0 rule set
|
||||
4. CLAUDE_SPEC.md — formal architecture spec
|
||||
5. ARCHITECTURE.md — crate responsibilities + data flow
|
||||
6. docs/ui-mockups/ — design system + 24-mockup library +
|
||||
desktop-adaptation.md (the rules-based
|
||||
companion to the mockups; read this
|
||||
before any plugin port)
|
||||
7. docs/android/* — Android setup + build runbook
|
||||
8. ~/.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. *Closed 2026-05-08 by `3eb3a26` + `716a025`.* App icon
|
||||
round — runtime `Window::icon` wired plus a 9-size PNG
|
||||
hierarchy at `assets/icon/`. `.ico` / `.icns` bundle
|
||||
formats stay open if the project later ships as a
|
||||
packaged macOS / Windows app.
|
||||
B. APK launch verification on AVD / device — `adb install` +
|
||||
`adb logcat` to shake out runtime bugs the build / unit
|
||||
tests can't catch. Likely surfaces JNI ClipboardManager
|
||||
and Android Keystore stubs that need real bridges. Larger
|
||||
scope; needs an Android device or emulator running.
|
||||
C. Replay-overlay extensions — either the floating `MOVE N/M`
|
||||
chip above the focused card (smaller, cross-plugin; needs
|
||||
cursor → card-position plumbing in `card_plugin`) or the
|
||||
full screen-takeover redesign (multi-session: move-log
|
||||
scroll, mini tableau preview, WIN MOVE marker, data-layer
|
||||
impact for `Replay::win_move_index`).
|
||||
D. Toast Warning / Error variant wiring. UI infrastructure
|
||||
exists in `ToastVariant`; no in-engine event uses Warning
|
||||
(gold) or Error (pink) yet. Wire when a real warning- or
|
||||
error-flavoured event materialises.
|
||||
E. Phase 8 (sync) — local storage scaffolding, self-hosted
|
||||
Axum server, `SolitaireServerClient` impl, GPGS stub
|
||||
wired into Settings. The biggest open arc by scope; rolls
|
||||
up several Phase Android dependencies (Keystore,
|
||||
ClipboardManager).
|
||||
F. *Closed 2026-05-08 by `c5787c6` + `07e0357`.* High-contrast
|
||||
and reduced-motion accessibility modes — Settings flags
|
||||
+ UI toggles + engine wiring. Card text rendering uses
|
||||
HC variants when on; card slide_secs forces to 0 when
|
||||
reduce-motion is on. Future scope: extend HC through
|
||||
chrome borders, buttons; gate splash + warning-chip
|
||||
animations on reduce-motion.
|
||||
|
||||
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 wired on
|
||||
primary dev box; verify on laptop before first push.
|
||||
- Token-port pattern: when migrating tokens, walk every
|
||||
concrete artifact downstream of the token (PNG textures,
|
||||
embedded SVGs, hardcoded literals, comment color names),
|
||||
not just the token name. v0.21.0 surfaced three "the
|
||||
migration walked past this" follow-ups that all matched
|
||||
this shape — codified here so future similar work can
|
||||
pattern-match instead of rediscovering.
|
||||
|
||||
OPEN AT THE START: ask which of A–F. Don't pick unilaterally.
|
||||
```
|
||||
|
After Width: | Height: | Size: 469 B |
|
After Width: | Height: | Size: 469 B |
|
After Width: | Height: | Size: 469 B |
|
After Width: | Height: | Size: 472 B |
|
After Width: | Height: | Size: 472 B |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 369 B |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 489 B |
|
After Width: | Height: | Size: 759 B |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 927 B |
@@ -0,0 +1,228 @@
|
||||
# Android build — developer setup
|
||||
|
||||
This doc captures the toolchain install + build invocation for the
|
||||
Android target. Steps are runnable on a fresh Debian 13 (trixie) box;
|
||||
later sections document what's known to compile, what's stubbed, and
|
||||
the next milestones.
|
||||
|
||||
> **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB
|
||||
> debug-signed `solitaire-quest.apk` for `x86_64-linux-android`. Has
|
||||
> NOT yet been verified to launch on a device or emulator — that's
|
||||
> the next milestone.
|
||||
|
||||
---
|
||||
|
||||
## 1. Toolchain install (Debian 13 / trixie)
|
||||
|
||||
Run as one block. Will pull ~15-20 GB of disk between APT, the SDK,
|
||||
the NDK, the system image, and Rust target sysroots. Requires sudo.
|
||||
|
||||
```bash
|
||||
# 1. JDK 21 (Android tooling needs JDK 17+; Debian 13 default is 21).
|
||||
sudo apt update && sudo apt install -y openjdk-21-jdk-headless unzip wget
|
||||
|
||||
# 2. SDK directory + Google's cmdline-tools bootstrap.
|
||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
wget -O /tmp/cmdline-tools.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/cmdline-tools.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
rm /tmp/cmdline-tools.zip
|
||||
|
||||
# 3. Persist env vars.
|
||||
{
|
||||
echo ''
|
||||
echo '# Android dev'
|
||||
echo 'export ANDROID_HOME="$HOME/Android/Sdk"'
|
||||
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.3.11579264"'
|
||||
echo 'export JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"'
|
||||
echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"'
|
||||
} >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# 4. Accept SDK licences (interactive prompts answered by `yes |`).
|
||||
yes | sdkmanager --licenses
|
||||
|
||||
# 5. Platform packages — ~5 GB.
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-34" \
|
||||
"build-tools;34.0.0" \
|
||||
"ndk;26.3.11579264" \
|
||||
"emulator" \
|
||||
"system-images;android-34;google_apis;x86_64"
|
||||
|
||||
# 6. AVD for testing (one-time).
|
||||
echo no | avdmanager create avd \
|
||||
-n bevy_test \
|
||||
-k "system-images;android-34;google_apis;x86_64" \
|
||||
-d pixel_7
|
||||
|
||||
# 7. Rust cross-compile targets.
|
||||
rustup target add \
|
||||
aarch64-linux-android \
|
||||
armv7-linux-androideabi \
|
||||
x86_64-linux-android \
|
||||
i686-linux-android
|
||||
|
||||
# 8. cargo-apk.
|
||||
cargo install cargo-apk
|
||||
```
|
||||
|
||||
Sanity:
|
||||
|
||||
```bash
|
||||
java --version | head -1 # openjdk 21.0.x
|
||||
adb --version | head -1 # 35.x or higher
|
||||
sdkmanager --list_installed | head # build-tools, emulator, ndk, platforms, system-images
|
||||
avdmanager list avd | head # bevy_test
|
||||
rustup target list --installed | grep android # 4 targets
|
||||
cargo apk --help | head -5
|
||||
```
|
||||
|
||||
If `sdkmanager --version` errors with `JAVA_HOME is not set`, the env
|
||||
section in step 3 didn't apply to your shell — `source ~/.bashrc`
|
||||
again or open a new terminal.
|
||||
|
||||
### Optional: emulator runtime libs
|
||||
|
||||
The Android emulator is dynamically linked against X11/GL/audio. If
|
||||
`emulator -list-avds` works but `emulator -avd bevy_test` complains
|
||||
about `libX11.so.6`, install:
|
||||
|
||||
```bash
|
||||
sudo apt install -y \
|
||||
libx11-6 libxcursor1 libxrandr2 libxi6 libxinerama1 libxxf86vm1 \
|
||||
libgl1 libnss3 libpulse0 libxcomposite1
|
||||
```
|
||||
|
||||
Headless emulator launch:
|
||||
|
||||
```bash
|
||||
emulator -avd bevy_test -no-window -gpu swiftshader_indirect &
|
||||
adb wait-for-device && adb devices
|
||||
# Stop later:
|
||||
# adb -s emulator-5554 emu kill
|
||||
```
|
||||
|
||||
Headless + software rendering is fine for "does it boot" smoke tests
|
||||
but useless for perf measurement — use a physical Pixel-class device
|
||||
over USB for real numbers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Build the APK
|
||||
|
||||
```bash
|
||||
cargo apk build -p solitaire_app --target x86_64-linux-android
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
target/debug/apk/solitaire-quest.apk
|
||||
```
|
||||
|
||||
Targets shipped via `[package.metadata.android].build_targets` in
|
||||
`solitaire_app/Cargo.toml`:
|
||||
|
||||
| Target | Use |
|
||||
|--------|-----|
|
||||
| `aarch64-linux-android` | Real phones (modern 64-bit ARM) |
|
||||
| `armv7-linux-androideabi` | Older 32-bit ARM phones |
|
||||
| `x86_64-linux-android` | The `bevy_test` AVD on this dev box |
|
||||
|
||||
Build any of them with `--target <triple>`.
|
||||
|
||||
### Known cosmetic warning
|
||||
|
||||
After the APK is signed cargo-apk panics with:
|
||||
|
||||
```
|
||||
thread 'main' panicked: Bin is not compatible with Cdylib
|
||||
```
|
||||
|
||||
This happens AFTER the APK is on disk and signed. cargo-apk is
|
||||
trying to also wrap the desktop `[[bin]]` target. The APK is still
|
||||
valid. Work around with `--lib`:
|
||||
|
||||
```bash
|
||||
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
|
||||
```
|
||||
|
||||
(Permanent fix to come — likely a `[[bin]] required-features = ["desktop"]`
|
||||
gate so cargo-apk skips the bin target on Android.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Install + run
|
||||
|
||||
Physical device:
|
||||
|
||||
```bash
|
||||
adb devices # confirm connection
|
||||
adb install target/debug/apk/solitaire-quest.apk
|
||||
adb shell am start -n com.solitairequest.app/android.app.NativeActivity
|
||||
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
|
||||
```
|
||||
|
||||
Emulator:
|
||||
|
||||
```bash
|
||||
emulator -avd bevy_test -no-window -gpu swiftshader_indirect &
|
||||
adb wait-for-device
|
||||
adb install target/debug/apk/solitaire-quest.apk
|
||||
# ... same start + logcat steps as above.
|
||||
```
|
||||
|
||||
If `adb install` errors with `INSTALL_FAILED_NO_MATCHING_ABIS`, the
|
||||
emulator is x86_64 but the APK was built for arm — rebuild with the
|
||||
`x86_64-linux-android` target, or add an x86_64 system image to the
|
||||
AVD.
|
||||
|
||||
---
|
||||
|
||||
## 4. What's wired vs. what's stubbed
|
||||
|
||||
The first build pass (commit `fb8b2ac`) gates four desktop-only
|
||||
crates / call sites so the workspace cross-compiles. Each gate is
|
||||
documented at its call site.
|
||||
|
||||
| Surface | Desktop | Android |
|
||||
|---------|---------|---------|
|
||||
| Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) |
|
||||
| Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline |
|
||||
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Stub returning `KeychainUnavailable`; sync requires fresh login each launch |
|
||||
| App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity |
|
||||
|
||||
What's NOT yet ported / not yet measured:
|
||||
|
||||
- `dirs::data_dir()` returns `None` on Android. Callers in
|
||||
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
|
||||
`achievements.rs`, `settings.rs` all need an Android-aware
|
||||
helper (likely `/data/data/com.solitairequest.app/files`).
|
||||
- Touch UX pass — hit-target sizes, modal scaling on small screens,
|
||||
app lifecycle (suspend / resume), font scaling.
|
||||
- Android Keystore via JNI for `auth_tokens`.
|
||||
- JNI ClipboardManager for share links.
|
||||
- Google Play Games sign-in (the `solitaire_gpgs` crate referenced
|
||||
in older docs doesn't yet exist).
|
||||
|
||||
---
|
||||
|
||||
## 5. Iteration loop
|
||||
|
||||
```bash
|
||||
# Edit code…
|
||||
cargo build -p solitaire_app # desktop sanity
|
||||
cargo clippy --workspace --all-targets -- -D warnings # gate
|
||||
cargo test --workspace # gate
|
||||
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
|
||||
adb install -r target/debug/apk/solitaire-quest.apk # `-r` reinstalls
|
||||
adb logcat -c && adb shell am start -n com.solitairequest.app/android.app.NativeActivity
|
||||
adb logcat | grep -iE "RustStdoutStderr|solitaire"
|
||||
```
|
||||
|
||||
`adb logcat` is the canonical way to see Bevy / Rust panic output —
|
||||
they end up in the `RustStdoutStderr` tag.
|
||||
@@ -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,293 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Rusty Solitaire - Achievements</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Inter:wght@400;500;700&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"tertiary-fixed": "#fbd7ff",
|
||||
"on-tertiary-container": "#683476",
|
||||
"surface-container-lowest": "#0b0f11",
|
||||
"error": "#fb9fb1",
|
||||
"secondary-fixed-dim": "#bad073",
|
||||
"on-primary-fixed-variant": "#004c69",
|
||||
"background": "#101417",
|
||||
"error-container": "#93000a",
|
||||
"tertiary-container": "#e1a3ee",
|
||||
"inverse-primary": "#00668a",
|
||||
"highlight-valid": "#acc267",
|
||||
"suit-red": "#fb9fb1",
|
||||
"on-surface-variant": "#bfc8cf",
|
||||
"on-secondary": "#293500",
|
||||
"on-primary-container": "#004f6c",
|
||||
"surface-tint": "#7ed0fe",
|
||||
"on-surface": "#e0e3e6",
|
||||
"outline-variant": "#3f484e",
|
||||
"on-background": "#e0e3e6",
|
||||
"primary-fixed": "#c4e7ff",
|
||||
"inverse-surface": "#e0e3e6",
|
||||
"info": "#12cfc0",
|
||||
"inverse-on-surface": "#2d3134",
|
||||
"warning": "#ddb26f",
|
||||
"on-tertiary-fixed-variant": "#653173",
|
||||
"on-secondary-container": "#b2c86d",
|
||||
"on-secondary-fixed-variant": "#3c4d00",
|
||||
"highlight-celebration": "#e1a3ee",
|
||||
"surface": "#151515",
|
||||
"surface-container-highest": "#313538",
|
||||
"outline": "#505050",
|
||||
"on-primary": "#003549",
|
||||
"on-error-container": "#ffdad6",
|
||||
"surface-variant": "#313538",
|
||||
"on-error": "#690005",
|
||||
"suit-black": "#d0d0d0",
|
||||
"primary": "#a1dcff",
|
||||
"suit-red-cb": "#6fc2ef",
|
||||
"surface-bright": "#363a3d",
|
||||
"on-tertiary": "#4c195b",
|
||||
"surface-dim": "#101417",
|
||||
"primary-container": "#6fc2ef",
|
||||
"tertiary": "#f7c3ff",
|
||||
"primary-fixed-dim": "#7ed0fe",
|
||||
"surface-container-high": "#272a2d",
|
||||
"on-secondary-fixed": "#161e00",
|
||||
"surface-container": "#1c2023",
|
||||
"tertiary-fixed-dim": "#f0b0fc",
|
||||
"secondary-fixed": "#d5ec8c",
|
||||
"secondary-container": "#435401",
|
||||
"on-tertiary-fixed": "#340043",
|
||||
"on-primary-fixed": "#001e2c",
|
||||
"secondary": "#bad073",
|
||||
"surface-container-low": "#181c1f"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"stack-overlap": "2rem",
|
||||
"gutter-card": "0.375rem",
|
||||
"touch-target-min": "48dp",
|
||||
"margin-edge": "1rem",
|
||||
"action-bar-height": "64px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"card-rank": ["JetBrains Mono"],
|
||||
"body-md": ["Inter"],
|
||||
"label-caps": ["JetBrains Mono"],
|
||||
"hud-score": ["JetBrains Mono"],
|
||||
"headline": ["JetBrains Mono"],
|
||||
"hud-timer": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
|
||||
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
||||
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
|
||||
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
body {
|
||||
background-color: #151515;
|
||||
color: #e0e3e6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.scanline {
|
||||
background: linear-gradient(to bottom, transparent 50%, rgba(0,0,0,0.1) 50%);
|
||||
background-size: 100% 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-body-md text-body-md overflow-x-hidden pb-[action-bar-height]">
|
||||
<!-- Status Bar -->
|
||||
<header class="fixed top-0 w-full h-[32px] bg-[#202020] flex items-center justify-between px-margin-edge z-[60] border-b border-outline-variant">
|
||||
<div class="flex items-center gap-2 font-label-caps text-on-surface">
|
||||
<span class="text-primary">▌</span>achievements.json
|
||||
</div>
|
||||
<div class="font-label-caps text-[#a0a0a0]">
|
||||
8/19 UNLOCKED
|
||||
</div>
|
||||
</header>
|
||||
<!-- Top App Bar (Shared Component Reference) -->
|
||||
<nav class="fixed top-[32px] w-full h-[64px] bg-surface flex items-center justify-between px-margin-edge z-50 border-b border-outline-variant">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-primary" data-icon="terminal">terminal</span>
|
||||
<h1 class="font-headline text-[20px] text-primary uppercase tracking-widest">Rusty Solitaire</h1>
|
||||
</div>
|
||||
<button class="w-10 h-10 flex items-center justify-center hover:bg-surface-container-highest transition-colors">
|
||||
<span class="material-symbols-outlined text-on-surface-variant" data-icon="settings">settings</span>
|
||||
</button>
|
||||
</nav>
|
||||
<main class="mt-[112px] px-margin-edge">
|
||||
<!-- Hero Progress Card -->
|
||||
<section class="w-full h-[100px] bg-[#202020] border border-[#353535] rounded-lg p-4 mb-6">
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<span class="font-label-caps text-[10px] text-[#a0a0a0]">PROGRESS</span>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-headline text-[28px] font-bold text-[#d0d0d0]">8/19</span>
|
||||
<span class="font-label-caps text-[14px] text-highlight-celebration">(42%)</span>
|
||||
</div>
|
||||
<div class="w-full h-[4px] bg-[#353535] rounded-full overflow-hidden mt-1">
|
||||
<div class="h-full bg-highlight-celebration" style="width: 42%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Filter Chip Row -->
|
||||
<section class="flex gap-2 mb-6 overflow-x-auto no-scrollbar">
|
||||
<button class="h-[32px] px-3 flex items-center justify-center border border-[#6fc2ef] text-[#6fc2ef] rounded-[4px] font-label-caps text-[11px]">
|
||||
[ ALL ]
|
||||
</button>
|
||||
<button class="h-[32px] px-3 flex items-center justify-center border border-outline text-[#a0a0a0] rounded-[4px] font-label-caps text-[11px] hover:border-primary hover:text-primary transition-colors">
|
||||
UNLOCKED
|
||||
</button>
|
||||
<button class="h-[32px] px-3 flex items-center justify-center border border-outline text-[#a0a0a0] rounded-[4px] font-label-caps text-[11px] hover:border-primary hover:text-primary transition-colors">
|
||||
LOCKED
|
||||
</button>
|
||||
<button class="h-[32px] px-3 flex items-center justify-center border border-outline text-[#a0a0a0] rounded-[4px] font-label-caps text-[11px] hover:border-primary hover:text-primary transition-colors">
|
||||
SECRET
|
||||
</button>
|
||||
</section>
|
||||
<!-- Achievements Grid -->
|
||||
<section class="grid grid-cols-2 gap-3 mb-10">
|
||||
<!-- FIRST WIN -->
|
||||
<div class="h-[100px] bg-[#202020] border border-[#353535] p-3 flex flex-col justify-between rounded-sm">
|
||||
<div class="w-8 h-8 rounded-full bg-[#6fc2ef] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#151515] text-[20px]" data-icon="emoji_events" style="font-variation-settings: 'FILL' 1;">emoji_events</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#d0d0d0] leading-none mb-1">FIRST WIN</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#a0a0a0] leading-tight">Win your first game</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SPEED DEMON -->
|
||||
<div class="h-[100px] bg-[#202020] border border-highlight-celebration p-3 flex flex-col justify-between rounded-sm relative">
|
||||
<div class="absolute inset-0 border border-highlight-celebration opacity-20 pointer-events-none"></div>
|
||||
<div class="w-8 h-8 rounded-full bg-[#6fc2ef] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#151515] text-[20px]" data-icon="speed" style="font-variation-settings: 'FILL' 1;">speed</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#d0d0d0] leading-none mb-1">SPEED DEMON</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#a0a0a0] leading-tight">Win in under 3:00</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- STREAK 10 -->
|
||||
<div class="h-[100px] bg-[#202020] border border-[#353535] p-3 flex flex-col justify-between rounded-sm">
|
||||
<div class="w-8 h-8 rounded-full bg-[#6fc2ef] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#151515] text-[20px]" data-icon="bolt" style="font-variation-settings: 'FILL' 1;">bolt</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#d0d0d0] leading-none mb-1">STREAK 10</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#a0a0a0] leading-tight">10 wins in a row</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- DAILY DEFENDER -->
|
||||
<div class="h-[100px] bg-[#202020] border border-[#353535] p-3 flex flex-col justify-between rounded-sm">
|
||||
<div class="w-8 h-8 rounded-full bg-[#6fc2ef] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#151515] text-[20px]" data-icon="calendar_today" style="font-variation-settings: 'FILL' 1;">calendar_today</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#d0d0d0] leading-none mb-1">DAILY DEFENDER</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#a0a0a0] leading-tight">Complete 7 daily seeds</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- PERFECTIONIST (LOCKED) -->
|
||||
<div class="h-[100px] bg-[#0d0d0d] border border-[#353535] p-3 flex flex-col justify-between rounded-sm opacity-80">
|
||||
<div class="w-8 h-8 rounded-full border border-[#505050] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#505050] text-[20px]" data-icon="undo">undo</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#505050] leading-none mb-1">PERFECTIONIST</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#353535] leading-tight">Win without using undo</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- CHALLENGE BEATEN (LOCKED) -->
|
||||
<div class="h-[100px] bg-[#0d0d0d] border border-[#353535] p-3 flex flex-col justify-between rounded-sm opacity-80">
|
||||
<div class="w-8 h-8 rounded-full border border-[#505050] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#505050] text-[20px]" data-icon="military_tech">military_tech</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#505050] leading-none mb-1">CHALLENGE BEATEN</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#353535] leading-tight">Complete CHALLENGE mode</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SECRET (LOCKED) -->
|
||||
<div class="h-[100px] bg-[#0d0d0d] border border-[#353535] p-3 flex flex-col justify-between rounded-sm opacity-80">
|
||||
<div class="w-8 h-8 rounded-full border border-[#505050] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#505050] text-[20px]" data-icon="help_outline">help_outline</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#505050] leading-none mb-1">????</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#353535] leading-tight">SECRET · Hidden until unlocked</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- PAR HUNTER (LOCKED) -->
|
||||
<div class="h-[100px] bg-[#0d0d0d] border border-[#353535] p-3 flex flex-col justify-between rounded-sm opacity-80">
|
||||
<div class="w-8 h-8 rounded-full border border-[#505050] flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[#505050] text-[20px]" data-icon="golf_course">golf_course</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-label-caps text-[13px] font-bold text-[#505050] leading-none mb-1">PAR HUNTER</h3>
|
||||
<p class="font-label-caps text-[10px] text-[#353535] leading-tight">Beat par on 50 games</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- Footer Status -->
|
||||
<footer class="fixed bottom-[action-bar-height] w-full h-[24px] bg-background border-t border-outline-variant flex items-center justify-between px-margin-edge z-40 text-[10px] font-label-caps">
|
||||
<div class="flex items-center">
|
||||
<span class="text-primary mr-1">▌</span>
|
||||
<span class="text-on-surface-variant">NORMAL</span>
|
||||
<span class="mx-2 text-outline">│</span>
|
||||
<span class="text-on-surface-variant">achievements</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div><span class="text-[#a0a0a0]">[F]</span> <span class="text-[#505050]">filter</span></div>
|
||||
<div><span class="text-[#a0a0a0]">[/]</span> <span class="text-[#505050]">search</span></div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- Bottom Navigation Bar (Shared Component Reference) -->
|
||||
<nav class="fixed bottom-0 w-full h-action-bar-height bg-surface-container flex justify-around items-center px-margin-edge z-50 border-t border-outline-variant">
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:text-primary hover:bg-surface-container-highest transition-colors active:scale-95">
|
||||
<span class="material-symbols-outlined" data-icon="power_settings_new">power_settings_new</span>
|
||||
<span class="font-label-caps text-[10px] mt-1">[Q] QUIT</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:text-primary hover:bg-surface-container-highest transition-colors active:scale-95">
|
||||
<span class="material-symbols-outlined" data-icon="add_box">add_box</span>
|
||||
<span class="font-label-caps text-[10px] mt-1">[N] NEW</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:text-primary hover:bg-surface-container-highest transition-colors active:scale-95">
|
||||
<span class="material-symbols-outlined" data-icon="undo">undo</span>
|
||||
<span class="font-label-caps text-[10px] mt-1">[U] UNDO</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:text-primary hover:bg-surface-container-highest transition-colors active:scale-95">
|
||||
<span class="material-symbols-outlined" data-icon="lightbulb">lightbulb</span>
|
||||
<span class="font-label-caps text-[10px] mt-1">[H] HINT</span>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- CRT Overlay Effect (Visual Decoration) -->
|
||||
<div class="fixed inset-0 pointer-events-none z-[100] opacity-[0.03] scanline"></div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,251 @@
|
||||
# Card-face artwork migration plan
|
||||
|
||||
**Status:** planning artifact (no code changed by this document).
|
||||
**Tracks:** the "Card-face / suit / card-back artwork regeneration"
|
||||
item in `SESSION_HANDOFF.md` → "Visual-identity follow-ups"
|
||||
(SESSION_HANDOFF Resume prompt option D).
|
||||
**Companion to:** `docs/ui-mockups/design-system.md` (Game Cards
|
||||
spec, lines 214–233) and `docs/ui-mockups/desktop-adaptation.md`
|
||||
(rules-based companion to the mockups).
|
||||
|
||||
## Why this is a multi-session arc
|
||||
|
||||
Every post-v0.20.0 visual-identity port to date (modal scaffold,
|
||||
toasts, table chrome, splash boot screen, replay overlay) was a
|
||||
**single rendering path** — change tokens, change comments, ship.
|
||||
Cards have **two** rendering paths that are visually identical
|
||||
today and would visually disagree the moment one moves:
|
||||
|
||||
1. **PNG path (production).** `assets/cards/faces/<rank><suit>.png`
|
||||
loaded into `CardImageSet.faces[suit][rank]` at startup; card
|
||||
sprites blit the texture. 52 face PNGs + 5 back PNGs already
|
||||
in `assets/`, all the legacy white-card aesthetic from the
|
||||
pre-Terminal design system.
|
||||
2. **Constant fallback (tests + asset-missing edge).** When
|
||||
`CardImageSet` isn't a registered resource (the case under
|
||||
`MinimalPlugins` test fixtures, and the bare-bones path the
|
||||
first-frame of production hits before assets resolve), the
|
||||
renderer falls back to solid-colour sprites driven by the
|
||||
`card_plugin` constants:
|
||||
- `CARD_FACE_COLOUR` — `(0.98, 0.98, 0.95)` cream-ish white.
|
||||
- `RED_SUIT_COLOUR` — `(0.78, 0.12, 0.15)` warm red.
|
||||
- `BLACK_SUIT_COLOUR` — `(0.08, 0.08, 0.08)` near-black.
|
||||
- `CARD_FACE_COLOUR_RED_CBM` — `(0.85, 0.92, 1.0, 1.0)` light
|
||||
blue (the legacy color-blind tint).
|
||||
- `card_back_colour(idx)` — five legacy back themes.
|
||||
|
||||
A single-path migration leaves a known-broken state where tests
|
||||
pass against Terminal constants while a human sees legacy artwork
|
||||
on screen — the exact bisection-hostile drift the handoff's
|
||||
"in lockstep" warning preempts.
|
||||
|
||||
## Target state — Terminal aesthetic
|
||||
|
||||
Per `design-system.md` § Game Cards (lines 214–233):
|
||||
|
||||
### Card face
|
||||
|
||||
| Element | Spec |
|
||||
|---|---|
|
||||
| Background | `#1a1a1a` |
|
||||
| Border | 1 px solid in **suit colour** (pink for ♥/♦, foreground gray for ♠/♣) |
|
||||
| Corner radius | 8 px |
|
||||
| Top-left | rank in JetBrains Mono **Bold 18 px** + small suit glyph (10 px) |
|
||||
| Bottom-right | large suit glyph (32 px), rotated 180° |
|
||||
| Glyph fill rule | ♥ ♠ filled; ♦ ♣ outlined (1.5 px stroke). Always on, not a toggle. |
|
||||
|
||||
### Suit colours (always-on glyph differentiation is the *primary*
|
||||
distinguishing mechanism; colour is supplementary):
|
||||
|
||||
| Suit | Default | Color-blind mode |
|
||||
|---|---|---|
|
||||
| Hearts | `#fb9fb1` (pink) | `#6fc2ef` (cyan) |
|
||||
| Diamonds | `#fb9fb1` (pink) | `#6fc2ef` (cyan) |
|
||||
| Spades | `#d0d0d0` (gray) | `#d0d0d0` (unchanged) |
|
||||
| Clubs | `#d0d0d0` (gray) | `#d0d0d0` (unchanged) |
|
||||
|
||||
### Card back ("Terminal" theme)
|
||||
|
||||
| Element | Spec |
|
||||
|---|---|
|
||||
| Background | `#151515` |
|
||||
| Pattern | horizontal scanlines at 2 px pitch in `#1a1a1a` (1 px line, 1 px gap), full bleed |
|
||||
| Border | 1 px solid `#353535` |
|
||||
| Top-left badge | 12×16 px solid `#6fc2ef` block, 6 px from corner |
|
||||
| Bottom-right monogram | `▌RS` in JetBrains Mono 12 px `#505050`, 6 px from corner |
|
||||
| Corner radius | 8 px |
|
||||
| Theme name / author | `"Terminal"` / `"Rusty Solitaire"` |
|
||||
|
||||
## Generation pipeline — programmatic SVG via the existing
|
||||
`resvg` stack
|
||||
|
||||
### Why this path (vs. external tooling or direct `tiny_skia`)
|
||||
|
||||
The codebase already ships an SVG-to-PNG rasteriser at
|
||||
`solitaire_engine/src/assets/svg_loader.rs`:
|
||||
|
||||
- Public `rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, _>`
|
||||
- Backed by `usvg` (parser) + `resvg` (renderer) + `tiny_skia`
|
||||
(CPU pixmap)
|
||||
- Bundled font db includes JetBrains-style mono (FiraMono — same
|
||||
face the splash uses; close enough to JetBrains Mono for
|
||||
rasterisation purposes, and identical to what the Bevy UI
|
||||
consumes in the rest of the app)
|
||||
- `RenderAssetUsages::default()` is the call-site convention here
|
||||
|
||||
This means: **generating new card PNGs is one new file
|
||||
(`solitaire_engine/examples/card_face_generator.rs`) calling an
|
||||
existing public function.** No new dependencies, no asset-pipeline
|
||||
changes, no build-script machinery. Anyone who runs the example
|
||||
gets bit-identical artwork.
|
||||
|
||||
The two alternatives are weaker:
|
||||
|
||||
- **External tool (Inkscape / Figma / hand-design)** — produces
|
||||
one-off PNGs that can't be re-generated reproducibly without
|
||||
re-opening the source files in a specific tool. Iteration cost
|
||||
is high; design tweaks (e.g. "make the suit glyph 2 px larger")
|
||||
require a designer-in-the-loop.
|
||||
- **Direct `tiny_skia` painting calls** — bypasses SVG entirely,
|
||||
but loses the readability of "open the SVG to see exactly what
|
||||
the card looks like." Also reinvents primitives (rounded
|
||||
rectangles, text layout) that `usvg` already handles.
|
||||
|
||||
### Output format
|
||||
|
||||
PNG, RGBA8 sRGB, **dimensions 256 × 384** (2:3 aspect, half the
|
||||
default `SvgLoaderSettings` of 512 × 768).
|
||||
|
||||
Rationale: cards never exceed ~250 px wide on desktop windows
|
||||
today, and 256 × 384 PNGs are ~6 KB each at this content density
|
||||
(13.4 KB total for a full deck of 52 + 5 backs). The default 512 ×
|
||||
768 is 2× what's needed and quadruples the on-disk asset weight.
|
||||
The existing legacy PNGs are 512 × 768 — reducing the new ones
|
||||
halves the runtime asset size.
|
||||
|
||||
## Lockstep migration — recommended order
|
||||
|
||||
Each step is a separate commit; the constraint is that **steps 4
|
||||
and 5 must land in the same commit** (or at most adjacent commits
|
||||
on the same branch) so the rendered output never diverges between
|
||||
the two paths.
|
||||
|
||||
1. **(Done — this commit)** Land the migration plan doc.
|
||||
2. **Land the SVG generator example.** New
|
||||
`solitaire_engine/examples/card_face_generator.rs`. Output
|
||||
goes to `assets/cards/faces/` and `assets/cards/backs/`. Run
|
||||
once locally to seed the new artwork. The example file stays
|
||||
in-tree as a regenerator for future tweaks.
|
||||
3. **(Optional — can land separately)** Add a one-shot regression
|
||||
test that re-runs the generator into a `tempdir` and compares
|
||||
the resulting bytes against the on-disk artwork; pinning the
|
||||
generator output prevents silent drift if `usvg`/`resvg` ever
|
||||
tweak rendering. Skip if the test runtime cost is unacceptable.
|
||||
4. **Land the new artwork** (PNG bytes from step 2 committed to
|
||||
`assets/cards/`) **and** the constant migration in the *same
|
||||
commit*:
|
||||
- `CARD_FACE_COLOUR` → `Color::srgb(0.102, 0.102, 0.102)` (`#1a1a1a`)
|
||||
- `RED_SUIT_COLOUR` → `Color::srgb(0.984, 0.624, 0.694)` (`#fb9fb1`)
|
||||
- `BLACK_SUIT_COLOUR` → `Color::srgb(0.816, 0.816, 0.816)` (`#d0d0d0`)
|
||||
- `CARD_FACE_COLOUR_RED_CBM` → `Color::srgb(0.435, 0.761, 0.937)` (`#6fc2ef`) — note this is now the colour-blind *suit* colour, not a face tint; semantics shift slightly.
|
||||
- `card_back_colour(idx)` — re-author for the Terminal palette;
|
||||
index 0 stays the canonical "Terminal" back from `design-system.md`.
|
||||
5. **Test updates land in step 4's commit.** The pinning tests at
|
||||
`card_plugin.rs` lines 1749, 1750, 1767, 1768, 2057, 2063,
|
||||
2071, 2081 all assert against the old constants. New
|
||||
assertions update in lockstep with the constant changes.
|
||||
|
||||
## CBM (color-blind mode) semantics shift — flag
|
||||
|
||||
The **legacy** `CARD_FACE_COLOUR_RED_CBM` was a *face tint* — red
|
||||
suits got a light-blue background wash. The **Terminal** spec
|
||||
moves CBM into the *suit colour* itself (red glyphs swap to cyan).
|
||||
Step 4 will rename / repurpose this constant; it's not a 1:1
|
||||
replacement.
|
||||
|
||||
Two options:
|
||||
|
||||
- **Rename + repurpose:** `CARD_FACE_COLOUR_RED_CBM` →
|
||||
`RED_SUIT_COLOUR_CBM`. Communicates the semantic shift in the
|
||||
symbol name. Requires touching every callsite.
|
||||
- **Keep the name, change the meaning:** less code churn but
|
||||
worse for greppability — a future reader hitting the legacy
|
||||
name will assume face-tint behaviour.
|
||||
|
||||
Recommendation: **rename**. The CBM swap is a one-frame operation
|
||||
even if it touches every existing callsite (currently lines 642,
|
||||
2071, 2081 per `grep -n CARD_FACE_COLOUR_RED_CBM`).
|
||||
|
||||
## Theme system — out of scope here
|
||||
|
||||
The card-theme system (`docs/CARD_PLAN.md`, `theme/plugin.rs`)
|
||||
already supports user-supplied themes via `assets/themes/<theme>/`
|
||||
SVG files rasterised by `svg_loader.rs`. The new Terminal artwork
|
||||
is the **default theme**, not a new entry in the theme picker —
|
||||
the theme system continues to overlay user themes on top of the
|
||||
default at runtime.
|
||||
|
||||
If the next session wants to also ship Terminal as a *named theme
|
||||
slot* (so a user can switch back to the legacy artwork via the
|
||||
theme picker), that's an additive change after step 4 and lives
|
||||
in `theme::plugin::apply_theme_to_card_image_set`.
|
||||
|
||||
## Test impact summary
|
||||
|
||||
`grep -n CARD_FACE_COLOUR\\b\|RED_SUIT_COLOUR\\b\|BLACK_SUIT_COLOUR\\b` in
|
||||
`card_plugin.rs`:
|
||||
|
||||
- Line 1749–1750: red-suit text colour assertions (♥ + ♦).
|
||||
- Line 1767–1768: black-suit text colour assertions (♠ + ♣).
|
||||
- Line 2057, 2063: face-colour assertion in default mode.
|
||||
- Line 2071, 2081: face-colour assertion in CBM.
|
||||
|
||||
The four suit-colour and two face-colour tests are **invariant
|
||||
guards** — they exist precisely so a constant tweak surfaces here
|
||||
rather than in a visual review. Step 4 updates each in lockstep
|
||||
with the constant value change. No new test infrastructure
|
||||
needed.
|
||||
|
||||
## Open questions to resolve before step 4
|
||||
|
||||
1. **Border colour conflict.** The spec (line 218) says "Border:
|
||||
1 px solid in suit colour." The fallback path doesn't draw a
|
||||
border today — it draws solid-colour sprites. Step 4 either:
|
||||
(a) leaves the fallback as solid-colour squares (the test
|
||||
environment doesn't visually validate borders anyway), or
|
||||
(b) extends the fallback renderer to paint a 1 px outline.
|
||||
Recommend (a) — fallback fidelity isn't load-bearing.
|
||||
2. **Glyph rendering in the constant fallback.** The fallback
|
||||
today doesn't render suit glyphs at all — it's a coloured
|
||||
square. The spec's filled-vs-outlined glyph differentiation
|
||||
only matters in the PNG path. No change to the constant
|
||||
fallback for glyphs.
|
||||
3. **High-contrast mode.** `design-system.md` line 274 mentions
|
||||
a high-contrast accessibility mode (boosts foreground from
|
||||
`#d0d0d0` to `#f5f5f5`, suit-red from `#fb9fb1` to `#ff8aa0`).
|
||||
Not currently implemented anywhere; out of scope for this
|
||||
migration but worth flagging for a future accessibility pass.
|
||||
|
||||
## Post-migration — what's still open
|
||||
|
||||
- **High-contrast mode** (above).
|
||||
- **Reduced-motion mode** for card lift / drop transitions
|
||||
(also a `design-system.md` accessibility item, separate from
|
||||
artwork).
|
||||
- **The 9 missing-plugin screens** (splash, challenge,
|
||||
time-attack, weekly-goals, leaderboard, sync, level-up,
|
||||
replay, radial-menu) per `project_ui_overhaul` memory still
|
||||
need their plugin ports — separate from the cards arc.
|
||||
|
||||
## Sign-off criteria for "D closed"
|
||||
|
||||
D from the SESSION_HANDOFF Resume prompt is closed when **all of
|
||||
the following hold simultaneously**:
|
||||
|
||||
- The 52 face PNGs + 5 back PNGs in `assets/cards/` are the
|
||||
Terminal-aesthetic artwork (regeneratable via the example).
|
||||
- The five `card_plugin` constants reflect the Terminal palette.
|
||||
- All pinning tests pass against the new values.
|
||||
- A human boots the game and sees Terminal cards (not white
|
||||
cards). This sign-off needs a real `cargo run`, not just
|
||||
`cargo test`.
|
||||