d761a150d7
Build and Deploy / build-and-push (push) Successful in 4m40s
Updates all in-tree references: - Android package: com.solitairequest.app → com.ferrousapp.solitaire - APK name: solitaire-quest → ferrous-solitaire - Data dir: solitaire_quest → ferrous_solitaire (across all 6 data modules + engine) - Keyring service: solitaire_quest_server → ferrous_solitaire_server - Android Keystore key: solitaire_quest_token_key → ferrous_solitaire_token_key - Gitea repo: Rusty_Solitare → Ferrous-Solitaire (also fixes "Solitare" typo) - Renamed pkg/solitaire-quest* → pkg/ferrous-solitaire* - Updated ArgoCD, docker-compose, CI workflow, build script, all docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1101 lines
43 KiB
Markdown
1101 lines
43 KiB
Markdown
# Ferrous Solitaire — Architecture Document
|
||
|
||
> **Version:** 1.3
|
||
> **Language:** Rust (Edition 2024)
|
||
> **Engine:** Bevy (latest stable)
|
||
> **Last Updated:** 2026-05-12
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
1. [Project Overview](#1-project-overview)
|
||
2. [Workspace Structure](#2-workspace-structure)
|
||
3. [Crate Responsibilities](#3-crate-responsibilities)
|
||
4. [Data Flow](#4-data-flow)
|
||
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. [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
|
||
|
||
Ferrous Solitaire 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
|
||
|
||
| Platform | Primary Sync | Notes |
|
||
|---|---|---|
|
||
| macOS | Self-hosted server | Full feature set |
|
||
| Windows | Self-hosted server | Full feature set |
|
||
| Linux | Self-hosted server | Full feature set |
|
||
| Android | Self-hosted server | Touch input; safe-area insets via JNI; `cargo-apk` build |
|
||
|
||
### Design Principles
|
||
|
||
- **Offline first.** The local file is always the source of truth. Sync is additive, never destructive.
|
||
- **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
|
||
|
||
```
|
||
ferrous_solitaire/
|
||
│
|
||
├── Cargo.toml # Workspace manifest
|
||
├── .env.example # Server environment variable template
|
||
├── ARCHITECTURE.md # This document
|
||
├── README.md # Player-facing readme
|
||
├── README_SERVER.md # Self-hosting guide
|
||
├── Dockerfile # Multi-stage server build
|
||
├── docker-compose.yml # Server + Caddy reverse proxy
|
||
│
|
||
├── assets/ # Loaded at runtime via AssetServer (audio is embedded via include_bytes!())
|
||
│ ├── cards/
|
||
│ │ ├── 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.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_wasm/ # WebAssembly bindings — browser-side replay player
|
||
└── solitaire_app/ # Main binary entry point
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Crate Responsibilities
|
||
|
||
### `solitaire_core`
|
||
**Dependencies:** `rand`, `serde`, `chrono` only.
|
||
|
||
The entire game rules engine. No Bevy, no network, no file I/O. Designed to be tested in isolation with `cargo test -p solitaire_core`.
|
||
|
||
Owns:
|
||
- All game data models (`Card`, `Suit`, `Rank`, `Pile`, `GameState`)
|
||
- Move validation logic
|
||
- Scoring engine
|
||
- Undo stack
|
||
- Win / auto-complete detection
|
||
- Achievement unlock condition evaluation
|
||
- Seeded RNG for reproducible deals
|
||
|
||
### `solitaire_sync`
|
||
**Dependencies:** `serde`, `serde_json`, `uuid`, `chrono` only.
|
||
|
||
Shared API contract types imported by both the game client (`solitaire_data`) and the server (`solitaire_server`). Changing a type here is a breaking change on both sides — version carefully.
|
||
|
||
Owns:
|
||
- `SyncPayload`, `SyncResponse`, `ConflictReport`
|
||
- `ChallengeGoal`, `LeaderboardEntry`
|
||
- `ApiError` enum
|
||
- Merge logic (pure functions, no I/O)
|
||
|
||
### `solitaire_data`
|
||
**Dependencies:** `solitaire_core`, `solitaire_sync`, `serde_json`, `dirs`, `keyring`, `reqwest`, `tokio` (minimal).
|
||
|
||
All persistence and sync client code. No Bevy dependency — Bevy systems in `solitaire_engine` call into this crate via the `SyncPlugin`.
|
||
|
||
Owns:
|
||
- Local file read/write (atomic via `.tmp` → rename)
|
||
- `StatsSnapshot`, `PlayerProgress`, `AchievementRecord` persistence
|
||
- `SyncBackend` enum and backend selection
|
||
- Solitaire Server sync client (JWT auth, auto-refresh)
|
||
- OS keychain integration (`keyring`)
|
||
- `SyncProvider` trait — implemented by `SolitaireServerClient`
|
||
|
||
### `solitaire_engine`
|
||
**Dependencies:** `bevy`, `kira`, `solitaire_core`, `solitaire_data`.
|
||
|
||
All Bevy-specific code. Structured as a collection of Plugins that `solitaire_app` registers.
|
||
|
||
Owns:
|
||
- Bevy ECS components and resources
|
||
- Rendering systems (card sprites, table, backgrounds)
|
||
- Drag-and-drop input handling
|
||
- Animation systems (slide, flip, win cascade, toast)
|
||
- 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`.
|
||
|
||
Standalone binary. Can be built and run independently of the game.
|
||
|
||
Owns:
|
||
- HTTP API (see Section 9)
|
||
- SQLite database schema and migrations
|
||
- Auth (registration, login, JWT issuance and refresh)
|
||
- Server-side merge logic (delegates to `solitaire_sync`)
|
||
- Rate limiting
|
||
- Daily challenge seed generation
|
||
- Leaderboard management
|
||
|
||
### `solitaire_wasm`
|
||
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
|
||
|
||
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; the output lives in `solitaire_server/web/pkg/` and is served statically by the server.
|
||
|
||
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
|
||
|
||
Owns:
|
||
- `ReplayPlayer` — WASM-exported state machine; steps through a replay's `Vec<ReplayMove>` against a live `GameState`
|
||
- `StateSnapshot` — JS-facing pile snapshot returned by each `step()` call
|
||
- `ReplayMove` / `Replay` mirrors — same serde shape as `solitaire_data` v2 equivalents
|
||
|
||
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
|
||
|
||
### `solitaire_app`
|
||
**Dependencies:** `bevy`, `solitaire_engine`.
|
||
|
||
Thin binary entry point. Registers all Bevy plugins and sets initial window properties.
|
||
|
||
---
|
||
|
||
## 4. Data Flow
|
||
|
||
### Game Loop (local, no sync)
|
||
|
||
```
|
||
User Input
|
||
│
|
||
▼
|
||
Bevy InputSystem
|
||
│ fires GameInputEvent
|
||
▼
|
||
GameLogicSystem (solitaire_engine)
|
||
│ calls solitaire_core::GameState::move_cards() → Result
|
||
▼
|
||
GameStateResource updated
|
||
│ fires StateChangedEvent
|
||
▼
|
||
RenderSystem ScoreSystem AchievementSystem
|
||
(update sprites) (update score HUD) (check unlock conditions)
|
||
│
|
||
│ fires AchievementUnlockedEvent
|
||
▼
|
||
ToastSystem (Bevy UI popup)
|
||
PersistenceSystem (write to disk)
|
||
```
|
||
|
||
### Sync Flow (on launch)
|
||
|
||
```
|
||
App starts
|
||
│
|
||
▼
|
||
SyncPlugin::on_startup()
|
||
│ spawns AsyncComputeTask
|
||
▼
|
||
solitaire_data::sync_pull() ← dispatches to active SyncProvider
|
||
│ SolitaireServerClient
|
||
▼
|
||
solitaire_sync::merge(local, remote)
|
||
│
|
||
▼
|
||
Write merged result to disk
|
||
│ fires SyncCompleteEvent
|
||
▼
|
||
Bevy main thread reads updated StatsResource
|
||
```
|
||
|
||
### Sync Flow (on exit)
|
||
|
||
```
|
||
AppExit event
|
||
│
|
||
▼
|
||
SyncPlugin::on_exit()
|
||
│ blocking push (acceptable on exit, not on main loop)
|
||
▼
|
||
active SyncProvider::push(local)
|
||
│ POST to server
|
||
▼
|
||
Done
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Game Engine Architecture
|
||
|
||
### Bevy Plugins
|
||
|
||
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 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, 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 |
|
||
| `DailyChallengePlugin` | — | Daily challenge resource and completion tracking |
|
||
| `WeeklyGoalsPlugin` | — | Weekly goal progress and completion events |
|
||
| `ChallengePlugin` | — | Challenge mode progression (seeded hard deals) |
|
||
| `TimeAttackPlugin` | — | 10-minute time-attack mode timer |
|
||
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
|
||
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
|
||
| `SettingsPlugin` | O | Settings panel: audio, draw mode, theme, sync, cosmetics |
|
||
| `ThemePlugin` | — | Owns `ActiveTheme` resource; registers the `CardTheme` SVG asset loader; rasterises themes once per (theme, target size) at load time and caches the resulting `Image`; handles the embedded default theme and user themes from `user_theme_dir()` |
|
||
| `SyncSetupPlugin` | — | Sync setup modal (URL / username / password fields, "Log In" / "Register" buttons); account deletion confirm modal; re-auth trigger when `SyncError::Auth` is returned by a pull |
|
||
| `LeaderboardPlugin` | L | Leaderboard overlay |
|
||
| `HelpPlugin` | H | Help / controls overlay |
|
||
| `PausePlugin` | Esc | Pause and resume |
|
||
| `OnboardingPlugin` | — | First-run welcome screen |
|
||
| `SyncPlugin` | — | Async sync lifecycle (pull on start, push on exit, status display) |
|
||
| `WinSummaryPlugin` | — | Win cascade overlay and screen-shake effect |
|
||
|
||
### Key Bevy Resources
|
||
|
||
```rust
|
||
// Current game state — single source of truth for the active game
|
||
struct GameStateResource(GameState);
|
||
|
||
// Sync status shown in Settings screen
|
||
enum SyncStatus { Idle, Syncing, LastSynced(DateTime<Utc>), Error(String) }
|
||
struct SyncStatusResource(SyncStatus);
|
||
|
||
// Currently active drag operation
|
||
struct DragState {
|
||
cards: Vec<u32>, // card ids being dragged
|
||
origin_pile: PileType,
|
||
cursor_offset: Vec2,
|
||
origin_z: f32,
|
||
}
|
||
|
||
// Loaded user data
|
||
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
|
||
}
|
||
|
||
// OS-reserved edge insets (physical px); zero on desktop
|
||
struct SafeAreaInsets { top: f32, bottom: f32, left: f32, right: f32 }
|
||
|
||
// Whether the HUD band is visible (auto-hide chrome feature)
|
||
enum HudVisibility { Visible, Hidden }
|
||
```
|
||
|
||
### Key Bevy Events
|
||
|
||
```rust
|
||
// Input → Logic
|
||
struct MoveRequestEvent { from: PileType, to: PileType, count: usize }
|
||
struct DrawRequestEvent;
|
||
struct UndoRequestEvent;
|
||
struct NewGameRequestEvent { seed: Option<u64> }
|
||
|
||
// Logic → Rendering/UI
|
||
struct StateChangedEvent;
|
||
struct CardFlippedEvent(u32);
|
||
struct GameWonEvent { score: i32, time_seconds: u64 }
|
||
struct AchievementUnlockedEvent(AchievementRecord);
|
||
struct SyncCompleteEvent(Result<SyncResponse, String>);
|
||
```
|
||
|
||
### Layout System
|
||
|
||
Card and pile positions are calculated from window dimensions on startup and on every `WindowResized` event.
|
||
|
||
```
|
||
Window width → card_width = window_width / 9.0 (7 columns + 2 margins)
|
||
Window height → card_height = card_width * 1.4 (standard card aspect ratio)
|
||
Pile spacing → h_gap = (window_width - 7 * card_width) / 8.0
|
||
```
|
||
|
||
Minimum window: 800×600. At this size cards are small but usable.
|
||
|
||
---
|
||
|
||
## 6. Persistence & Sync Architecture
|
||
|
||
### Local Storage
|
||
|
||
All files stored under `dirs::data_dir() / "ferrous_solitaire"/`:
|
||
|
||
```
|
||
~/.local/share/ferrous_solitaire/ (Linux)
|
||
~/Library/Application Support/ferrous_solitaire/ (macOS)
|
||
%APPDATA%\ferrous_solitaire\ (Windows)
|
||
│
|
||
├── stats.json # StatsSnapshot
|
||
├── progress.json # PlayerProgress (XP, level, unlocks, daily challenge)
|
||
├── achievements.json # Vec<AchievementRecord>
|
||
├── settings.json # Settings (draw mode, audio, theme, sync backend)
|
||
└── game_state.json # In-progress game (saved on pause/exit, deleted on win/loss)
|
||
```
|
||
|
||
Atomic writes: all saves go to `filename.json.tmp` first, then `rename()` — ensuring a crash mid-write never corrupts saved data.
|
||
|
||
### `SyncProvider` Trait
|
||
|
||
All sync backends implement a single trait in `solitaire_data`. The `SyncPlugin` holds a `Box<dyn SyncProvider + Send + Sync>` and is backend-agnostic.
|
||
|
||
```rust
|
||
#[async_trait]
|
||
pub trait SyncProvider: Send + Sync {
|
||
// Required — must be implemented by every backend:
|
||
async fn pull(&self) -> Result<SyncPayload, SyncError>;
|
||
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
|
||
fn backend_name(&self) -> &'static str;
|
||
fn is_authenticated(&self) -> bool;
|
||
|
||
// Optional — all have default no-op / empty implementations:
|
||
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError>;
|
||
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError>;
|
||
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError>;
|
||
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError>;
|
||
async fn opt_out_leaderboard(&self) -> Result<(), SyncError>;
|
||
async fn delete_account(&self) -> Result<(), SyncError>;
|
||
// Returns the shareable web URL on success; defaults to Err(UnsupportedPlatform)
|
||
// so LocalOnlyProvider silently no-ops the push-on-win path.
|
||
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError>;
|
||
}
|
||
```
|
||
|
||
Implementations:
|
||
|
||
| Struct | Backend | Platforms |
|
||
|---|---|---|
|
||
| `LocalOnlyProvider` | No-op (default) | All |
|
||
| `SolitaireServerClient` | Self-hosted server | All |
|
||
|
||
Sync always runs on `bevy::tasks::AsyncComputeTaskPool` — the game thread is never blocked.
|
||
|
||
### Sync Backends (Settings enum)
|
||
|
||
```rust
|
||
pub enum SyncBackend {
|
||
Local,
|
||
SolitaireServer {
|
||
url: String,
|
||
username: String,
|
||
// JWT access + refresh tokens stored in OS keychain
|
||
// key: "ferrous_solitaire_server_{username}"
|
||
},
|
||
}
|
||
```
|
||
|
||
### Solitaire Server Sync
|
||
|
||
On launch: `GET /api/sync/pull` with `Authorization: Bearer {access_token}`
|
||
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.
|
||
|
||
---
|
||
|
||
## 7. Sync Server Architecture
|
||
|
||
### Stack
|
||
|
||
| Component | Crate |
|
||
|---|---|
|
||
| HTTP framework | `axum` |
|
||
| Database | `sqlx` with SQLite |
|
||
| Auth | `jsonwebtoken` + `bcrypt` |
|
||
| Rate limiting | `tower-governor` |
|
||
| Logging | `tracing` + `tracing-subscriber` |
|
||
| Config | `dotenvy` |
|
||
| Shared types | `solitaire_sync` (workspace crate) |
|
||
|
||
### Database Schema
|
||
|
||
```sql
|
||
-- migrations/001_initial.sql
|
||
|
||
CREATE TABLE users (
|
||
id TEXT PRIMARY KEY, -- UUID v4
|
||
username TEXT UNIQUE NOT NULL,
|
||
password_hash TEXT NOT NULL, -- bcrypt, cost 12
|
||
created_at TEXT NOT NULL, -- ISO 8601
|
||
leaderboard_opt_in INTEGER DEFAULT 0
|
||
);
|
||
|
||
CREATE TABLE sync_state (
|
||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||
stats_json TEXT NOT NULL,
|
||
achievements_json TEXT NOT NULL,
|
||
progress_json TEXT NOT NULL,
|
||
last_modified TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE daily_challenges (
|
||
date TEXT PRIMARY KEY, -- "YYYY-MM-DD"
|
||
seed INTEGER NOT NULL,
|
||
goal_json TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE leaderboard (
|
||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||
display_name TEXT NOT NULL,
|
||
best_time_secs INTEGER,
|
||
best_score INTEGER,
|
||
recorded_at TEXT NOT NULL,
|
||
PRIMARY KEY (user_id)
|
||
);
|
||
|
||
-- migrations/002_replays.sql
|
||
|
||
CREATE TABLE IF NOT EXISTS replays (
|
||
id TEXT PRIMARY KEY, -- UUID v4 minted server-side
|
||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
seed INTEGER NOT NULL,
|
||
draw_mode TEXT NOT NULL, -- "DrawOne" | "DrawThree"
|
||
mode TEXT NOT NULL, -- "Classic" | "Zen" | "Challenge" | "TimeAttack"
|
||
time_seconds INTEGER NOT NULL,
|
||
final_score INTEGER NOT NULL,
|
||
recorded_at TEXT NOT NULL, -- replay-side date (YYYY-MM-DD)
|
||
received_at TEXT NOT NULL, -- server insert timestamp (ISO 8601)
|
||
replay_json TEXT NOT NULL -- full Replay serialisation
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS replays_received_at_idx ON replays(received_at DESC);
|
||
CREATE INDEX IF NOT EXISTS replays_user_id_idx ON replays(user_id);
|
||
```
|
||
|
||
### Request Lifecycle
|
||
|
||
```
|
||
Client Request
|
||
│
|
||
▼
|
||
tower-governor (rate limiter — 10 req/min on /api/auth/*)
|
||
│
|
||
▼
|
||
axum Router
|
||
│
|
||
├─ /api/auth/* → AuthHandler (no JWT required)
|
||
│
|
||
└─ /api/* → JwtMiddleware → Handler
|
||
│
|
||
├─ Validate JWT signature + expiry
|
||
├─ Reject payload > 1MB
|
||
└─ Extract user_id for handler
|
||
```
|
||
|
||
### Daily Challenge Generation
|
||
|
||
If no row exists in `daily_challenges` for today's date, the server generates one on first request:
|
||
|
||
```rust
|
||
let seed = hash_date_to_u64("2026-04-19"); // deterministic, same for all players
|
||
let goal = generate_goal_from_seed(seed); // seeded RNG picks goal type + params
|
||
```
|
||
|
||
This ensures all players worldwide get the same challenge for a given date, regardless of which server instance handles the request.
|
||
|
||
---
|
||
|
||
## 8. Data Models
|
||
|
||
### Core Game Models (`solitaire_core`)
|
||
|
||
```rust
|
||
pub enum Suit { Clubs, Diamonds, Hearts, Spades }
|
||
pub enum Rank { Ace, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King }
|
||
|
||
pub struct Card {
|
||
pub id: u32,
|
||
pub suit: Suit,
|
||
pub rank: Rank,
|
||
pub face_up: bool,
|
||
}
|
||
|
||
pub enum PileType {
|
||
Stock,
|
||
Waste,
|
||
Foundation(Suit),
|
||
Tableau(usize), // 0–6
|
||
}
|
||
|
||
pub enum DrawMode { DrawOne, DrawThree }
|
||
|
||
/// Active game mode. Classic is the default; others unlock at level 5.
|
||
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
|
||
|
||
pub enum MoveError {
|
||
InvalidSource,
|
||
InvalidDestination,
|
||
EmptySource,
|
||
RuleViolation(String),
|
||
UndoStackEmpty,
|
||
GameAlreadyWon,
|
||
}
|
||
|
||
pub struct GameState {
|
||
pub piles: HashMap<PileType, Vec<Card>>,
|
||
pub draw_mode: DrawMode,
|
||
pub mode: GameMode,
|
||
pub score: i32,
|
||
pub move_count: u32,
|
||
pub undo_count: u32, // number of undos used in this game
|
||
pub recycle_count: u32, // number of stock recycles
|
||
pub elapsed_seconds: u64,
|
||
pub seed: u64,
|
||
pub is_won: bool,
|
||
pub is_auto_completable: bool,
|
||
undo_stack: VecDeque<StateSnapshot>, // private, max 64 (VecDeque for O(1) pop_front)
|
||
}
|
||
```
|
||
|
||
### Persistence Models (`solitaire_data`)
|
||
|
||
```rust
|
||
pub struct StatsSnapshot {
|
||
pub games_played: u32,
|
||
pub games_won: u32,
|
||
pub games_lost: u32,
|
||
pub win_streak_current: u32,
|
||
pub win_streak_best: u32,
|
||
pub avg_time_seconds: u64,
|
||
pub fastest_win_seconds: u64,
|
||
pub lifetime_score: u64,
|
||
pub best_single_score: u32,
|
||
pub draw_one_wins: u32,
|
||
pub draw_three_wins: u32,
|
||
pub last_modified: DateTime<Utc>,
|
||
}
|
||
|
||
pub struct PlayerProgress {
|
||
pub total_xp: u64,
|
||
pub level: u32,
|
||
pub daily_challenge_last_completed: Option<NaiveDate>,
|
||
pub daily_challenge_streak: u32,
|
||
pub weekly_goal_progress: HashMap<String, u32>,
|
||
pub unlocked_card_backs: Vec<usize>,
|
||
pub unlocked_backgrounds: Vec<usize>,
|
||
pub last_modified: DateTime<Utc>,
|
||
}
|
||
|
||
pub struct AchievementRecord {
|
||
pub id: String,
|
||
pub unlocked: bool,
|
||
pub unlock_date: Option<DateTime<Utc>>,
|
||
pub reward_granted: bool,
|
||
}
|
||
|
||
pub struct Settings {
|
||
pub draw_mode: DrawMode,
|
||
pub sfx_volume: f32, // 0.0–1.0
|
||
pub music_volume: f32,
|
||
pub animation_speed: AnimSpeed,
|
||
pub theme: Theme,
|
||
pub sync_backend: SyncBackend, // Local | SolitaireServer
|
||
pub selected_card_back: usize, // index into PlayerProgress::unlocked_card_backs
|
||
pub selected_background: usize, // index into PlayerProgress::unlocked_backgrounds
|
||
pub first_run_complete: bool,
|
||
pub color_blind_mode: bool, // blue tint on red suits
|
||
pub high_contrast_mode: bool, // boosted luminance for low-vision users
|
||
pub reduce_motion_mode: bool, // WCAG reduce-motion — snaps instead of slides
|
||
pub window_geometry: Option<WindowGeometry>, // persisted size + position; None on first run
|
||
}
|
||
|
||
pub struct WindowGeometry {
|
||
pub width: u32, // logical pixels
|
||
pub height: u32,
|
||
pub x: i32, // physical pixels, top-left corner
|
||
pub y: i32,
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. API Reference
|
||
|
||
All endpoints are under the base URL configured by the user (e.g., `https://solitaire.example.com`).
|
||
|
||
### Authentication
|
||
|
||
| Method | Path | Auth | Body | Response |
|
||
|---|---|---|---|---|
|
||
| POST | `/api/auth/register` | None | `{username, password}` | `{access_token, refresh_token}` |
|
||
| POST | `/api/auth/login` | None | `{username, password}` | `{access_token, refresh_token}` |
|
||
| POST | `/api/auth/refresh` | None | `{refresh_token}` | `{access_token, refresh_token}` (rotated) |
|
||
|
||
### Sync
|
||
|
||
| Method | Path | Auth | Body | Response |
|
||
|---|---|---|---|---|
|
||
| GET | `/api/sync/pull` | Bearer JWT | — | `SyncResponse` |
|
||
| POST | `/api/sync/push` | Bearer JWT | `SyncPayload` | `SyncResponse` |
|
||
|
||
### Game Data
|
||
|
||
| Method | Path | Auth | Body | Response |
|
||
|---|---|---|---|---|
|
||
| GET | `/api/daily-challenge` | None | — | `ChallengeGoal` |
|
||
| GET | `/api/leaderboard` | Bearer JWT | — | `Vec<LeaderboardEntry>` |
|
||
| POST | `/api/leaderboard/opt-in` | Bearer JWT | — | `{ok: true}` |
|
||
|
||
### Replays
|
||
|
||
| Method | Path | Auth | Body | Response |
|
||
|---|---|---|---|---|
|
||
| POST | `/api/replays` | Bearer JWT | Replay JSON | `{id, share_url}` |
|
||
| GET | `/api/replays/recent` | None | — (`?limit=N`) | `Vec<ReplaySummary>` |
|
||
| GET | `/api/replays/:id` | None | — | Full Replay JSON |
|
||
|
||
### Web Replay Player
|
||
|
||
| Method | Path | Auth | Notes |
|
||
|---|---|---|---|
|
||
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
|
||
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
|
||
|
||
### Account Management
|
||
|
||
| Method | Path | Auth | Body | Response |
|
||
|---|---|---|---|---|
|
||
| DELETE | `/api/account` | Bearer JWT | — | `{ok: true}` |
|
||
| GET | `/health` | None | — | `{status, version}` |
|
||
|
||
### JWT Details
|
||
|
||
- Access token expiry: 24 hours
|
||
- Refresh token expiry: 30 days
|
||
- Algorithm: HS256
|
||
- Secret: `JWT_SECRET` environment variable (min 64 chars recommended)
|
||
|
||
---
|
||
|
||
## 10. Merge Strategy
|
||
|
||
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 {
|
||
SyncPayload {
|
||
stats: StatsSnapshot {
|
||
games_played: max(local.stats.games_played, remote.stats.games_played),
|
||
games_won: max(local.stats.games_won, remote.stats.games_won),
|
||
games_lost: max(local.stats.games_lost, remote.stats.games_lost),
|
||
win_streak_best: max(local.stats.win_streak_best, remote.stats.win_streak_best),
|
||
win_streak_current: max(local.stats.win_streak_current, remote.stats.win_streak_current),
|
||
fastest_win_seconds: min(local.stats.fastest_win_seconds, remote.stats.fastest_win_seconds),
|
||
best_single_score: max(local.stats.best_single_score, remote.stats.best_single_score),
|
||
lifetime_score: max(local.stats.lifetime_score, remote.stats.lifetime_score),
|
||
// avg_time recomputed from merged games_played/total_time
|
||
last_modified: Utc::now(),
|
||
..
|
||
},
|
||
achievements: union_by_id( // never remove an unlocked achievement
|
||
&local.achievements, // keep earliest unlock_date on conflict
|
||
&remote.achievements,
|
||
),
|
||
progress: PlayerProgress {
|
||
total_xp: max(local.progress.total_xp, remote.progress.total_xp),
|
||
unlocked_card_backs: union_vecs(&local.progress.unlocked_card_backs, &remote.progress.unlocked_card_backs),
|
||
unlocked_backgrounds: union_vecs(&local.progress.unlocked_backgrounds, &remote.progress.unlocked_backgrounds),
|
||
// level recomputed from merged total_xp
|
||
last_modified: Utc::now(),
|
||
..
|
||
},
|
||
last_modified: Utc::now(),
|
||
..
|
||
}
|
||
}
|
||
```
|
||
|
||
**Conflict reporting:** Any case where local and remote have different values for the same field that cannot be merged deterministically (e.g., different daily challenge streak counts) is recorded in `Vec<ConflictReport>` and returned to the client for display — data is never silently discarded.
|
||
|
||
---
|
||
|
||
## 11. Achievement System
|
||
|
||
### Definition Structure
|
||
|
||
Achievements are defined as static data in `solitaire_core`. Runtime unlock state (`unlocked`, `unlock_date`, `reward_granted`) is stored separately in `solitaire_data`.
|
||
|
||
```rust
|
||
pub struct AchievementDef {
|
||
pub id: &'static str,
|
||
pub name: &'static str,
|
||
pub description: &'static str,
|
||
pub icon: &'static str,
|
||
pub secret: bool,
|
||
pub reward: Option<Reward>,
|
||
pub condition: fn(&GameState, &StatsSnapshot, &PlayerProgress) -> bool,
|
||
}
|
||
```
|
||
|
||
### Achievement List
|
||
|
||
| ID | Name | Condition | Secret | Reward |
|
||
|---|---|---|---|---|
|
||
| `first_win` | First Win | Win 1 game | No | — |
|
||
| `on_a_roll` | On a Roll | Win streak ≥ 3 | No | Card back #1 |
|
||
| `unstoppable` | Unstoppable | Win streak ≥ 10 | No | Background #1 |
|
||
| `century` | Century | 100 games played | No | — |
|
||
| `veteran` | Veteran | 500 games played | No | Badge |
|
||
| `speed_demon` | Speed Demon | Win in < 3 min | No | — |
|
||
| `lightning` | Lightning | Win in < 90 sec | No | Card back #2 |
|
||
| `high_scorer` | High Scorer | Score ≥ 5,000 | No | — |
|
||
| `point_machine` | Point Machine | Lifetime score ≥ 50,000 | No | Background #2 |
|
||
| `no_undo` | No Undo | Win without undo | No | +25 XP |
|
||
| `draw_three_master` | Draw 3 Master | 10 Draw 3 wins | No | Card back #3 |
|
||
| `perfectionist` | Perfectionist | Max possible score | No | Badge |
|
||
| `night_owl` | Night Owl | Play after midnight | No | — |
|
||
| `early_bird` | Early Bird | Play before 6am | No | — |
|
||
| `daily_devotee` | Daily Devotee | 7 daily challenges | No | Background #3 |
|
||
| `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.
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 12. Progression System
|
||
|
||
### XP Sources
|
||
|
||
| Action | XP Awarded |
|
||
|---|---|
|
||
| Win a game | +50 |
|
||
| Fast win bonus (< 2 min) | +10 to +50 (scaled) |
|
||
| Win without undo | +25 |
|
||
| Complete daily challenge | +100 |
|
||
| Complete weekly goal | +75 |
|
||
|
||
### Level Formula
|
||
|
||
```
|
||
Levels 1–10: level = floor(total_xp / 500)
|
||
Levels 11+: level = 10 + floor((total_xp - 5000) / 1000)
|
||
```
|
||
|
||
### Special Modes (unlocked at level 5)
|
||
|
||
| Mode | Rules |
|
||
|---|---|
|
||
| **Time Attack** | Play as many games as possible in 10 minutes. Score = total wins. |
|
||
| **Challenge Mode** | Fixed hard seeds. No undo. Must win to advance. |
|
||
| **Zen Mode** | No timer. No score display. Ambient audio. No penalties. |
|
||
|
||
---
|
||
|
||
## 13. Audio System
|
||
|
||
Audio uses `kira`. All sound files are `.wav`.
|
||
|
||
| 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 |
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 14. Asset Pipeline
|
||
|
||
### Rendering approach
|
||
|
||
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()`.
|
||
|
||
Backgrounds are Bevy `Sprite` entities with `Handle<Image>` from `BackgroundImageSet`. `BackgroundImageSet` is populated at startup by `table_plugin::load_background_images` via `AssetServer::load()`.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
The `assets/` directory layout:
|
||
|
||
```
|
||
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
|
||
```
|
||
|
||
### 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 |
|
||
|
||
---
|
||
|
||
## 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 |
|
||
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
|
||
| Android | Active | Self-hosted server | `cargo-apk`; touch + long-press + double-tap; safe-area JNI; portrait layout |
|
||
| 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`.
|
||
|
||
---
|
||
|
||
## 16. Build & Development Guide
|
||
|
||
### Prerequisites
|
||
|
||
- Rust stable toolchain (via `rustup`)
|
||
- For Linux: `libasound2-dev`, `libudev-dev`, `libxkbcommon-dev`
|
||
- For macOS: Xcode Command Line Tools
|
||
|
||
### Common Commands
|
||
|
||
```bash
|
||
# Run the game (dev build with dynamic linking for fast compile)
|
||
cargo run -p solitaire_app --features bevy/dynamic_linking
|
||
|
||
# Run with release optimizations
|
||
cargo run -p solitaire_app --release
|
||
|
||
# Run all tests
|
||
cargo test --workspace
|
||
|
||
# Lint (must pass clean — no warnings allowed)
|
||
cargo clippy --workspace -- -D warnings
|
||
|
||
# Run the sync server locally
|
||
cargo run -p solitaire_server
|
||
|
||
# Build release binaries for all crates
|
||
cargo build --workspace --release
|
||
```
|
||
|
||
### Environment Variables (Server)
|
||
|
||
Copy `.env.example` to `.env` and fill in:
|
||
|
||
```
|
||
DATABASE_URL=sqlite://solitaire.db
|
||
JWT_SECRET=<generate with: openssl rand -hex 32>
|
||
SERVER_PORT=8080
|
||
ADMIN_USERNAME=admin # optional, seeded on first run
|
||
```
|
||
|
||
### Fast Compile Setup
|
||
|
||
The workspace `Cargo.toml` includes:
|
||
|
||
```toml
|
||
[profile.dev]
|
||
opt-level = 1
|
||
|
||
[profile.dev.package."*"]
|
||
opt-level = 3
|
||
|
||
[profile.release]
|
||
opt-level = 3
|
||
lto = "thin"
|
||
```
|
||
|
||
Add `--features bevy/dynamic_linking` during development to dramatically reduce incremental compile times.
|
||
|
||
---
|
||
|
||
## 17. Deployment Guide
|
||
|
||
### Docker Compose (Recommended)
|
||
|
||
```bash
|
||
git clone https://github.com/yourname/ferrous_solitaire
|
||
cd ferrous_solitaire
|
||
cp .env.example .env
|
||
# Edit .env — set JWT_SECRET and SERVER_PORT
|
||
docker compose up -d
|
||
```
|
||
|
||
This starts the sync server + a Caddy reverse proxy with automatic TLS (provide your domain in `docker-compose.yml`).
|
||
|
||
### Systemd Service (Alternative)
|
||
|
||
```bash
|
||
cargo build -p solitaire_server --release
|
||
sudo cp target/release/solitaire_server /usr/local/bin/
|
||
sudo cp solitaire_server.service /etc/systemd/system/
|
||
sudo systemctl enable --now solitaire_server
|
||
```
|
||
|
||
### Backups
|
||
|
||
The entire server state is in a single SQLite file. Back it up by copying it:
|
||
|
||
```bash
|
||
sqlite3 solitaire.db ".backup backup_$(date +%Y%m%d).db"
|
||
```
|
||
|
||
Or just `cp solitaire.db backups/` — SQLite's WAL mode makes this safe while the server is running.
|
||
|
||
### Updating
|
||
|
||
```bash
|
||
git pull
|
||
cargo build -p solitaire_server --release
|
||
sudo systemctl restart solitaire_server
|
||
```
|
||
|
||
Migrations run automatically on startup via `sqlx::migrate!()`.
|
||
|
||
---
|
||
|
||
## 18. Security Model
|
||
|
||
| Concern | Mitigation |
|
||
|---|---|
|
||
| Password storage | bcrypt, cost factor 12 — never stored in plaintext |
|
||
| Token security | JWTs signed with HS256, stored in OS keychain via `keyring` crate |
|
||
| Token expiry | Access: 24h, Refresh: 30d |
|
||
| Refresh token rotation | Each `/api/auth/refresh` call consumes the incoming refresh token (deletes its jti row) and issues a new one. Reuse of a consumed token returns 401. Expired rows are pruned inline. |
|
||
| Brute force | `tower-governor`: 10 req/min per IP on `/api/auth/*` |
|
||
| Payload abuse | 1MB max request body, enforced by Axum middleware |
|
||
| Data deletion | `DELETE /api/account` removes all rows via `ON DELETE CASCADE` |
|
||
| TLS | Handled by reverse proxy (Caddy/nginx) — server runs plain HTTP internally |
|
||
| PII | Only username stored — no email, no real name required |
|
||
| Leaderboard | Opt-in only — display name chosen by user at opt-in time |
|
||
|
||
---
|
||
|
||
## 19. Testing Strategy
|
||
|
||
### Unit Tests (`solitaire_core`)
|
||
|
||
Every public function in `solitaire_core` has corresponding `#[test]` coverage:
|
||
|
||
- All legal move types (tableau→foundation, waste→tableau, etc.)
|
||
- All illegal move types and their `MoveError` variants
|
||
- Undo: state fully restored after 1, 5, and 64 undos
|
||
- Scoring: each action type, time bonus formula, floor at zero
|
||
- Win detection: true positive (complete foundation), true negative
|
||
- Auto-complete detection
|
||
- Seeded deal: same seed produces identical layout across 100 runs
|
||
|
||
### Unit Tests (`solitaire_sync`)
|
||
|
||
- Merge: each field merges correctly (max, min, union)
|
||
- Merge: idempotent (merging identical payloads returns identical payload)
|
||
- Merge: achievements never removed
|
||
|
||
### Integration Tests (`solitaire_server`)
|
||
|
||
Using `axum::test` and an in-memory SQLite database:
|
||
|
||
- Auth flow: register → login → access protected endpoint → refresh → access again
|
||
- Sync roundtrip: push payload → pull → verify merged response
|
||
- Rate limiting: 11th request within 1 minute returns 429
|
||
- Account deletion: all rows removed, subsequent JWT rejected
|
||
|
||
### Manual Test Checklist (per platform, per release)
|
||
|
||
- [ ] New game deals correctly, all 52 cards present
|
||
- [ ] Drag and drop works for all pile type combinations
|
||
- [ ] Win triggers cascade animation and score display
|
||
- [ ] Undo restores previous state visually and in data
|
||
- [ ] Stats persist across app restart
|
||
- [ ] Achievement toast appears and dismisses
|
||
- [ ] Server sync: register, login, push, pull on second machine
|
||
- [ ] Server sync: JWT refresh on 401 works transparently
|
||
|
||
---
|
||
|
||
## 20. Decision Log
|
||
|
||
| Decision | Rationale | Date |
|
||
|---|---|---|
|
||
| Bevy as game engine | Best-in-class Rust game engine; ECS architecture suits card game structure well; active ecosystem | 2026-04-19 |
|
||
| SQLite over Postgres for server | Single-file DB simplifies self-hosting enormously; a card game sync server will never need Postgres-scale throughput | 2026-04-19 |
|
||
| Shared `solitaire_sync` crate | Ensures client and server types are always identical; type errors caught at compile time rather than runtime | 2026-04-19 |
|
||
| `keyring` for credential storage | OS keychain is the correct place for secrets on all three desktop platforms; never store JWTs or passwords in plaintext files | 2026-04-19 |
|
||
| Atomic file writes (tmp → rename) | Prevents corrupt save files on crash or power loss with zero extra dependencies | 2026-04-19 |
|
||
| bcrypt cost 12 | Balances security and registration latency (~300ms on modern hardware); higher than default 10 | 2026-04-19 |
|
||
| No email required for server accounts | Reduces PII collected; simplifies self-hosted deployments; password reset handled by server admin if needed | 2026-04-19 |
|
||
| 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 | `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 |
|
||
| 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 |
|