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:
@@ -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
@@ -67,19 +67,18 @@ solitaire_quest/
|
|||||||
├── Dockerfile # Multi-stage server build
|
├── Dockerfile # Multi-stage server build
|
||||||
├── docker-compose.yml # Server + Caddy reverse proxy
|
├── docker-compose.yml # Server + Caddy reverse proxy
|
||||||
│
|
│
|
||||||
├── assets/ # All runtime assets (loaded via Bevy AssetServer)
|
├── assets/ # Audio embedded at compile time via include_bytes!()
|
||||||
│ ├── cards/
|
│ ├── cards/ # Reserved for future art pass (currently unused)
|
||||||
│ │ ├── faces/ # Card face sprites (suit + rank)
|
│ │ ├── faces/
|
||||||
│ │ └── backs/ # Card back designs (back_0.png … back_4.png)
|
│ │ └── backs/
|
||||||
│ ├── backgrounds/ # Table backgrounds (bg_0.png … bg_4.png)
|
│ ├── backgrounds/ # Reserved for future art pass (currently unused)
|
||||||
│ ├── fonts/ # .ttf font files
|
│ ├── fonts/ # Reserved for future art pass (currently unused)
|
||||||
│ └── audio/
|
│ └── audio/
|
||||||
│ ├── card_deal.ogg
|
│ ├── card_deal.wav
|
||||||
│ ├── card_flip.ogg
|
│ ├── card_flip.wav
|
||||||
│ ├── card_place.ogg
|
│ ├── card_place.wav
|
||||||
│ ├── card_invalid.ogg
|
│ ├── card_invalid.wav
|
||||||
│ ├── win_fanfare.ogg
|
│ └── win_fanfare.wav
|
||||||
│ └── ambient_loop.ogg
|
|
||||||
│
|
│
|
||||||
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
|
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
|
||||||
├── solitaire_sync/ # Shared API types — used by client and server
|
├── 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_place.wav` | Valid card placement |
|
||||||
| `card_invalid.wav` | Invalid move attempt |
|
| `card_invalid.wav` | Invalid move attempt |
|
||||||
| `win_fanfare.wav` | Game won |
|
| `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.
|
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
|
## 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:
|
This means the `assets/cards/`, `assets/backgrounds/`, and `assets/fonts/` directories are reserved for a future art pass and are currently empty (`.gitkeep` only).
|
||||||
- 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
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ cargo clippy -p solitaire_core -- -D warnings
|
|||||||
|
|
||||||
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
|
- `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>`.
|
- 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()`.
|
- 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.
|
- 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.
|
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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`.
|
||||||
@@ -1248,3 +1248,64 @@ async fn refresh_token_rejected_on_protected_routes() {
|
|||||||
"refresh token must be rejected on protected endpoints"
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user