From 4997356cb5dba2ca2a15ecc2309d0b39ebb5a336 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 28 Apr 2026 23:41:16 +0000 Subject: [PATCH] docs(project): add README, CI workflow, migration guide, and fix asset docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md: player-facing install, controls, features, and test instructions - .github/workflows/ci.yml: clippy + headless tests + release build on push/PR - solitaire_server/migrations/README.md: naming convention and workflow for adding future schema migrations - ARCHITECTURE.md §14: rewrite Asset Pipeline to reflect procedural rendering (no image files used; audio only, embedded via include_bytes!) - ARCHITECTURE.md §2 / §13: fix workspace structure and audio file listing - CLAUDE.md: clarify asset embedding rule (audio only; visuals are procedural) - server_tests.rs: add auth_rate_limit_returns_429_on_11th_request test using build_router() (rate limiting ON) to verify the GovernorLayer is wired correctly Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 82 ++++++++++++++++++++++++++ ARCHITECTURE.md | 54 +++++++++-------- CLAUDE.md | 2 +- README.md | 73 +++++++++++++++++++++++ solitaire_server/migrations/README.md | 38 ++++++++++++ solitaire_server/tests/server_tests.rs | 61 +++++++++++++++++++ 6 files changed, 286 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 README.md create mode 100644 solitaire_server/migrations/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4958a93 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + +jobs: + test: + name: Test & Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Install Linux audio/display dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + libasound2-dev libudev-dev libxkbcommon-dev + + - name: Cache cargo registry and build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Clippy (all crates, zero warnings) + run: cargo clippy --workspace -- -D warnings + + - name: Test (headless crates only — no display required) + run: | + cargo test -p solitaire_core + cargo test -p solitaire_sync + cargo test -p solitaire_data + cargo test -p solitaire_server + + build: + name: Release Build + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Install Linux audio/display dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + libasound2-dev libudev-dev libxkbcommon-dev + + - name: Cache cargo registry and build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-release- + + - name: Build release binaries + run: cargo build --workspace --release diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1fd5bd3..23222a6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -67,19 +67,18 @@ solitaire_quest/ ├── Dockerfile # Multi-stage server build ├── docker-compose.yml # Server + Caddy reverse proxy │ -├── assets/ # All runtime assets (loaded via Bevy AssetServer) -│ ├── cards/ -│ │ ├── faces/ # Card face sprites (suit + rank) -│ │ └── backs/ # Card back designs (back_0.png … back_4.png) -│ ├── backgrounds/ # Table backgrounds (bg_0.png … bg_4.png) -│ ├── fonts/ # .ttf font files +├── assets/ # Audio embedded at compile time via include_bytes!() +│ ├── cards/ # Reserved for future art pass (currently unused) +│ │ ├── faces/ +│ │ └── backs/ +│ ├── backgrounds/ # Reserved for future art pass (currently unused) +│ ├── fonts/ # Reserved for future art pass (currently unused) │ └── audio/ -│ ├── card_deal.ogg -│ ├── card_flip.ogg -│ ├── card_place.ogg -│ ├── card_invalid.ogg -│ ├── win_fanfare.ogg -│ └── ambient_loop.ogg +│ ├── card_deal.wav +│ ├── card_flip.wav +│ ├── card_place.wav +│ ├── card_invalid.wav +│ └── win_fanfare.wav │ ├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde ├── solitaire_sync/ # Shared API types — used by client and server @@ -744,7 +743,7 @@ Audio uses `bevy_kira_audio`. All sound files are `.wav`. | `card_place.wav` | Valid card placement | | `card_invalid.wav` | Invalid move attempt | | `win_fanfare.wav` | Game won | -| `ambient_loop.wav` | Looping background music (restarts seamlessly) | +| `ambient_loop` | Looping background music — uses `card_flip.wav` looped at very low volume as a placeholder until a dedicated track is added | Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `bevy_kira_audio` channel volumes. @@ -754,23 +753,32 @@ Audio systems listen for Bevy events and never block the game thread. ## 14. Asset Pipeline -All assets are loaded through Bevy's `AssetServer`. No bytes are hardcoded in source. +### Rendering approach -### Card Sprites +Cards, backgrounds, and UI are rendered **procedurally** — no image files are used. Cards are Bevy `Sprite` entities (colored rectangles) with `Text` children showing rank and suit symbols. Card back colors and background colors are selected by index from compile-time color tables in `card_plugin.rs` and `table_plugin.rs`. All UI text uses Bevy's built-in default font. -Card faces can be either: -- A texture atlas (`assets/cards/atlas.png` + `atlas.ron` layout) — faster to load, preferred -- Individual files (`assets/cards/faces/2_of_clubs.png`, etc.) — easier to author +This means the `assets/cards/`, `assets/backgrounds/`, and `assets/fonts/` directories are reserved for a future art pass and are currently empty (`.gitkeep` only). -Card backs: `assets/cards/backs/back_0.png` through `back_4.png`. Additional backs unlocked via achievements are in the same folder, gated by `PlayerProgress::unlocked_card_backs`. +### Audio -### Backgrounds +All five 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. -`assets/backgrounds/bg_0.png` through `bg_4.png`. Same unlock gating as card backs. +| File | Size | +|---|---| +| `card_deal.wav` | SFX | +| `card_flip.wav` | SFX | +| `card_place.wav` | SFX | +| `card_invalid.wav` | SFX | +| `win_fanfare.wav` | SFX | -### Fonts +The ambient music loop reuses `card_flip.wav` at very low volume as a placeholder; a dedicated `ambient_loop.wav` can be dropped into `assets/audio/` and wired into `audio_plugin.rs` when ready. -`assets/fonts/main.ttf` — used for card rank/suit text in Bevy UI. +### Future art pass + +When image-based card art is added, the recommended approach is: +- Embed assets via `bevy::asset::embedded_asset!()` macro (keeps the binary self-contained) +- Use a texture atlas (`assets/cards/atlas.png` + layout descriptor) for card faces +- Individual PNGs for card backs and backgrounds (5 each) --- diff --git a/CLAUDE.md b/CLAUDE.md index eb2df10..964b1ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ cargo clippy -p solitaire_core -- -D warnings - `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies. - No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`. -- Assets are embedded at compile time using `include_bytes!()`. No runtime asset loading via `AssetServer`. +- Audio assets are embedded at compile time using `include_bytes!()` in `audio_plugin.rs`. Cards and backgrounds are rendered procedurally (colored `Sprite` entities + text) — no image files are used and no `AssetServer` is needed. - Atomic file writes only: write to `filename.json.tmp`, then `rename()`. - Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs. - Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7822667 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Solitaire Quest + +A cross-platform Klondike Solitaire game written in Rust, featuring a full progression system with XP, levels, achievements, daily challenges, and optional self-hosted sync so your stats follow you across machines. + +## Features + +- **Klondike Solitaire** — Draw One and Draw Three modes +- **Progression** — XP, levels, unlockable card backs and backgrounds +- **18 Achievements** — including secret ones +- **Daily Challenge** — server-seeded so every player worldwide gets the same deal +- **Leaderboard** — opt-in, powered by your own self-hosted server +- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge +- **Sync** — pull/push stats across devices via a self-hosted server +- **Color-blind mode** — blue tint on red-suit cards + +## Building + +**Prerequisites** + +- Rust stable toolchain (`rustup install stable`) +- Linux: `libasound2-dev libudev-dev libxkbcommon-dev` +- macOS: Xcode Command Line Tools + +```bash +# Fast development build +cargo run -p solitaire_app --features bevy/dynamic_linking + +# Release build +cargo build -p solitaire_app --release +./target/release/solitaire_app +``` + +## Controls + +| Key | Action | +|---|---| +| Left click / drag | Move cards | +| Right click | Highlight legal moves for a card | +| Space / D | Draw from stock | +| Z / Ctrl+Z | Undo | +| N | New game | +| S | Stats overlay | +| A | Achievements overlay | +| P | Profile overlay | +| O | Settings | +| L | Leaderboard | +| H | Help / controls | +| Enter | Auto-complete (when badge is lit) | +| Escape | Pause / clear selection | +| Arrow keys | Navigate card selection | + +## Sync Server (optional) + +To sync stats across machines, run the self-hosted server. See [README_SERVER.md](README_SERVER.md) for setup instructions. + +Once the server is running, open **Settings → Sync Backend**, enter the server URL and your username, and register an account from within the game. + +## Running Tests + +```bash +# All tests +cargo test --workspace + +# Just game logic (no display required) +cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server + +# Lint +cargo clippy --workspace -- -D warnings +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/solitaire_server/migrations/README.md b/solitaire_server/migrations/README.md new file mode 100644 index 0000000..9cfe722 --- /dev/null +++ b/solitaire_server/migrations/README.md @@ -0,0 +1,38 @@ +# Database Migrations + +Migrations are run automatically at server startup via `sqlx::migrate!("./migrations")`. + +## Naming convention + +``` +NNN_description.sql +``` + +- `NNN` — zero-padded three-digit sequence number (`001`, `002`, …) +- `description` — snake_case description of what the migration does + +Examples: +``` +001_initial.sql +002_add_user_display_name.sql +003_weekly_goals_table.sql +``` + +`sqlx` tracks which migrations have run in the `_sqlx_migrations` table and only applies new ones. Never edit or delete an existing migration file after it has been applied to any database — add a new migration instead. + +## Adding a migration + +1. Create `migrations/NNN_description.sql` where `NNN` is the next available number. +2. Write idempotent SQL (`CREATE TABLE IF NOT EXISTS`, `ALTER TABLE … ADD COLUMN IF NOT EXISTS`, etc.) where possible. +3. Update the sqlx offline query cache so the server builds without a live DB: + ```bash + export DATABASE_URL=sqlite://solitaire.db + sqlx database create + sqlx migrate run --source solitaire_server/migrations + cargo sqlx prepare --workspace + ``` +4. Commit both the migration file and the updated `.sqlx/` query cache together. + +## Current schema + +See `001_initial.sql` for the full initial schema: `users`, `sync_state`, `daily_challenges`, `leaderboard`. diff --git a/solitaire_server/tests/server_tests.rs b/solitaire_server/tests/server_tests.rs index 8962212..2e02d88 100644 --- a/solitaire_server/tests/server_tests.rs +++ b/solitaire_server/tests/server_tests.rs @@ -1248,3 +1248,64 @@ async fn refresh_token_rejected_on_protected_routes() { "refresh token must be rejected on protected endpoints" ); } + +// --------------------------------------------------------------------------- +// Rate-limiting test (uses the production router with rate limiting enabled) +// --------------------------------------------------------------------------- + +/// The 11th request to an auth endpoint within the rate-limit window must +/// return 429 Too Many Requests. +/// +/// Uses [`solitaire_server::build_router`] (rate limiting ON) rather than +/// [`build_test_router`] so the GovernorLayer is actually applied. +/// All 11 requests share the same router clone — cloning an Axum Router with +/// GovernorLayer clones the inner `Arc`, so the request counter is shared. +#[tokio::test] +async fn auth_rate_limit_returns_429_on_11th_request() { + let state = solitaire_server::AppState { + pool: test_pool().await, + jwt_secret: TEST_SECRET.to_string(), + }; + let app = solitaire_server::build_router(state); + + let body_bytes = serde_json::to_vec(&serde_json::json!({ + "username": "ratelimituser", + "password": "password1!" + })) + .unwrap(); + + // First 10 requests consume the burst allowance (burst_size = 10). + // The status may be 200 (first registration) or 400/409 (duplicate username) + // on retries — what matters is that none of them are 429. + for i in 0..10 { + let req = Request::builder() + .method("POST") + .uri("/api/auth/register") + .header("content-type", "application/json") + .header("x-forwarded-for", TEST_CLIENT_IP) + .body(Body::from(body_bytes.clone())) + .expect("failed to build request"); + let resp = app.clone().oneshot(req).await.expect("oneshot failed"); + assert_ne!( + resp.status(), + StatusCode::TOO_MANY_REQUESTS, + "request {} of 10 must not be rate-limited", + i + 1 + ); + } + + // The 11th request must be rejected by the rate limiter. + let req = Request::builder() + .method("POST") + .uri("/api/auth/register") + .header("content-type", "application/json") + .header("x-forwarded-for", TEST_CLIENT_IP) + .body(Body::from(body_bytes)) + .expect("failed to build 11th request"); + let resp = app.clone().oneshot(req).await.expect("oneshot failed"); + assert_eq!( + resp.status(), + StatusCode::TOO_MANY_REQUESTS, + "11th request must be rate-limited with 429" + ); +}