# 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` 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), Error(String) } struct SyncStatusResource(SyncStatus); // Currently active drag operation struct DragState { cards: Vec, // 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); struct SettingsResource(Settings); // Pre-loaded card face and back PNG handles struct CardImageSet { faces: [[Handle; 13]; 4], // [suit][rank]: Clubs=0..Spades=3, Ace=0..King=12 backs: [Handle; 5], // indexed by selected_card_back setting } // Project-wide font handle (FiraMono-Medium loaded via AssetServer at startup) struct FontResource(Handle); // Pre-loaded background PNG handles struct BackgroundImageSet { handles: Vec>, // 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 } // Logic → Rendering/UI struct StateChangedEvent; struct CardFlippedEvent(u32); struct GameWonEvent { score: i32, time_seconds: u64 } struct AchievementUnlockedEvent(AchievementRecord); struct SyncCompleteEvent(Result); ``` ### 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 ├── 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` and is backend-agnostic. ```rust #[async_trait] pub trait SyncProvider: Send + Sync { // Required — must be implemented by every backend: async fn pull(&self) -> Result; async fn push(&self, payload: &SyncPayload) -> Result; 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, SyncError>; async fn fetch_daily_challenge(&self) -> Result, 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; } ``` 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>, 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, // 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, } pub struct PlayerProgress { pub total_xp: u64, pub level: u32, pub daily_challenge_last_completed: Option, pub daily_challenge_streak: u32, pub weekly_goal_progress: HashMap, pub unlocked_card_backs: Vec, pub unlocked_backgrounds: Vec, pub last_modified: DateTime, } pub struct AchievementRecord { pub id: String, pub unlocked: bool, pub unlock_date: Option>, 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, // 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` | | 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` | | 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` 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, 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` 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` 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` 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>` 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= 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>` 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 |