docs(project): add README, CI workflow, migration guide, and fix asset docs

- 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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-28 23:41:16 +00:00
parent 4bd562671e
commit 4997356cb5
6 changed files with 286 additions and 24 deletions
+82
View File
@@ -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
+31 -23
View File
@@ -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)
---
+1 -1
View File
@@ -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.
+73
View File
@@ -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).
+38
View File
@@ -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`.
+61
View File
@@ -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"
);
}