# Ferrous Solitaire — Architecture Document

> **Version:** 1.4  
> **Language:** Rust (Edition 2024)  
> **Engine:** Bevy (latest stable)  
> **Last Updated:** 2026-07-06 — post card_game/klondike migration (PR #88):
> core card/pile types come from the upstream `card_game` workspace and
> score/undo/recycle are derived from the upstream session, not stored.

---

## 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 rules — wraps upstream card_game/klondike (serde + thiserror only otherwise)
├── 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) + web frontend
├── solitaire_wasm/             # WebAssembly bindings — browser-side logic/replay + debug bridge
├── solitaire_web/              # Bevy WASM canvas build for the browser /play route
├── solitaire_assetgen/         # One-shot generator for card/background PNG assets
└── solitaire_app/              # Main binary entry point (desktop + Android cdylib)
```

---

## 3. Crate Responsibilities

### `solitaire_core`
**Dependencies:** `serde`, `thiserror`, plus the upstream `card_game` and
`klondike` crates (pinned via the Quaternions registry — never edit upstream).

The game rules layer. No Bevy, no network, no file I/O. Designed to be tested
in isolation with `cargo test -p solitaire_core`.

Since the card_game migration (2026-06-22, PR #88) the primitive types are
**upstream**: `Card`, `Deck`, `Suit`, `Rank`, `Session` come from `card_game`;
`Klondike`, `KlondikePile`, `KlondikeInstruction`, `DrawStockConfig`,
`Foundation`, `Tableau` come from `klondike`. `solitaire_core` re-exports them
so downstream crates import from one place and never depend on the upstream
crates directly.

Owns:
- `GameState` — a wrapper around the upstream `Session<Klondike>`; the session
  is the single source of truth for board state and stats
- `MoveError` and the `Result`-based mutation API
- `KlondikeConfig` adaptation (`klondike_adapter`) — draw mode, scoring,
  take-from-foundation
- `GameMode` (Classic / Zen / Challenge / TimeAttack) and mode-aware scoring
- Solvability check API (`SolveOutcome`, delegating to `Session::solve`)
- Win / auto-complete detection
- Achievement unlock condition evaluation
- Seeded deals (same seed ⇒ same layout, via the upstream dealer)

**Rules decisions:**
- **Stock recycling is unlimited in every draw mode — by design.** Extra
  passes through the stock are discouraged via the upstream score penalty
  (applied by the `card_game`/`klondike` session), never blocked with a
  `MoveError`. This matches mainstream digital solitaire (unlimited redeals
  in Draw-1) rather than strict tournament rules (3-pass cap). It is load-
  bearing: the difficulty seed catalog and the winnable-deal solver are
  verified under unlimited recycling, so introducing a hard pass limit would
  invalidate both. Locked in by the
  `draw_one_recycling_is_unlimited_by_design` test in
  `solitaire_core/src/game_state.rs`. (Decision record: Gitea issue #117.)

### `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`, `solitaire_data` (+ `jni` on Android).

Thin entry point (desktop binary + Android `cdylib`). Registers all Bevy
plugins and sets initial window properties. The one crate in the workspace
allowed `unsafe`: the Android entry point reconstructs the raw JNI handles and
hands them to the safe `solitaire_data::android_jni` bridge; everything else
is `forbid(unsafe_code)`.

---

## 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`)

Since the card_game migration, the primitives are upstream types re-exported
through `solitaire_core`:

```rust
// From `card_game` (upstream — never edit):
pub enum Suit { /* Clubs, Diamonds, Hearts, Spades */ }
pub enum Rank { /* Ace ..= King */ }
pub struct Card { /* deck + suit + rank; identity type, no face_up flag —
                     facing is positional, tracked by the Klondike board */ }
pub struct Session<G> { /* replayable instruction log + derived stats */ }

// From `klondike` (upstream — never edit):
pub enum KlondikePile {
    Stock,
    Waste,
    Foundation(Foundation),   // 4 slots, any suit may claim any slot
    Tableau(Tableau),         // 7 columns
}
pub enum DrawStockConfig { DrawOne, DrawThree }
pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
                                  serialized move format (schema v4+) */ }
```

Owned by `solitaire_core`:

```rust
/// 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,
    StockEmpty,
}

pub struct GameState {
    pub mode: GameMode,
    pub elapsed_seconds: u64,
    pub seed: u64,                 // same seed ⇒ same deal
    pub take_from_foundation: bool,
    session: Session<Klondike>,    // private — the single source of truth
}
```

**Derived, not stored:** `score()`, `move_count()`, `undo_count()`,
`recycle_count()`, `is_won()`, `is_auto_completable()`, and all pile
accessors read through the session. Undo replays the instruction log
(no snapshot stack); the −15 undo penalty is applied by the upstream
score formula via the session config. Persistence (schema v5) saves
`saved_moves` as upstream `KlondikeInstruction`s and rebuilds the
session by replay on load — older files carrying `score`/`undo_count`/
`recycle_count` keys load fine, the extra fields are ignored.

**Rules decision:** stock recycling is unlimited in every draw mode
(see the "Rules decisions" note in §3 `solitaire_core`).

### 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: DrawStockConfig,
    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 |
