Compare commits
15 Commits
4d6f8bccb7
...
7cda2a9f1a
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cda2a9f1a | |||
| 2b04718f33 | |||
| 505f0ebda3 | |||
| 0f40e717e1 | |||
| 08202f9351 | |||
| e22fcadb22 | |||
| 11d53245cf | |||
| f27a002c91 | |||
| ce8ba6a8c4 | |||
| 66695683eb | |||
| 18ac5adef5 | |||
| 41d75b50de | |||
| 4997356cb5 | |||
| 4bd562671e | |||
| 8221ebc803 |
@@ -0,0 +1,88 @@
|
||||
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 \
|
||||
libwayland-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 \
|
||||
libwayland-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
|
||||
@@ -1,9 +1,9 @@
|
||||
# Solitaire Quest — Architecture Document
|
||||
|
||||
> **Version:** 1.1
|
||||
> **Language:** Rust (Edition 2021)
|
||||
> **Language:** Rust (Edition 2024)
|
||||
> **Engine:** Bevy (latest stable)
|
||||
> **Last Updated:** 2026-04-20
|
||||
> **Last Updated:** 2026-04-29
|
||||
|
||||
---
|
||||
|
||||
@@ -16,28 +16,25 @@
|
||||
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. [Google Play Games Services (Android Future)](#8-google-play-games-services-android-future)
|
||||
9. [Data Models](#9-data-models)
|
||||
10. [API Reference](#10-api-reference)
|
||||
11. [Merge Strategy](#11-merge-strategy)
|
||||
12. [Achievement System](#12-achievement-system)
|
||||
13. [Progression System](#13-progression-system)
|
||||
14. [Audio System](#14-audio-system)
|
||||
15. [Asset Pipeline](#15-asset-pipeline)
|
||||
16. [Platform Targets](#16-platform-targets)
|
||||
17. [Build & Development Guide](#17-build--development-guide)
|
||||
18. [Deployment Guide](#18-deployment-guide)
|
||||
19. [Security Model](#19-security-model)
|
||||
20. [Testing Strategy](#20-testing-strategy)
|
||||
21. [Decision Log](#21-decision-log)
|
||||
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
|
||||
|
||||
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops (iOS/Android as a stretch goal). 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.
|
||||
|
||||
On Android (stretch goal), sync is enhanced with Google Play Games Services (GPGS) for native achievement popups, leaderboards, and cloud saves — sitting on top of the same underlying sync payload so data stays consistent regardless of which backend was used last.
|
||||
Solitaire Quest 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
|
||||
|
||||
@@ -46,8 +43,6 @@ On Android (stretch goal), sync is enhanced with Google Play Games Services (GPG
|
||||
| macOS | Self-hosted server | Full feature set |
|
||||
| Windows | Self-hosted server | Full feature set |
|
||||
| Linux | Self-hosted server | Full feature set |
|
||||
| Android (stretch) | Google Play Games Services | + server as fallback |
|
||||
| iOS (stretch) | Self-hosted server | GPGS not supported on iOS |
|
||||
|
||||
### Design Principles
|
||||
|
||||
@@ -72,26 +67,25 @@ solitaire_quest/
|
||||
├── Dockerfile # Multi-stage server build
|
||||
├── docker-compose.yml # Server + Caddy reverse proxy
|
||||
│
|
||||
├── assets/ # All runtime assets (loaded via Bevy AssetServer)
|
||||
├── assets/ # Assets embedded at compile time via include_bytes!()
|
||||
│ ├── 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
|
||||
│ │ ├── 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.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
|
||||
│ └── 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_gpgs/ # Google Play Games Services bridge (Android only, stub until stretch goal)
|
||||
└── solitaire_app/ # Main binary entry point
|
||||
```
|
||||
|
||||
@@ -135,22 +129,7 @@ Owns:
|
||||
- `SyncBackend` enum and backend selection
|
||||
- Solitaire Server sync client (JWT auth, auto-refresh)
|
||||
- OS keychain integration (`keyring`)
|
||||
- `SyncProvider` trait — implemented by both `SolitaireServerClient` and `GpgsClient` (Android)
|
||||
|
||||
### `solitaire_gpgs` *(stub — implement when targeting Android)*
|
||||
**Dependencies:** `solitaire_sync`, `jni` (Android only), `solitaire_data` trait impls.
|
||||
|
||||
Android-only crate, compiled only when `target_os = "android"`. Bridges the Google Play Games Services Java SDK via JNI.
|
||||
|
||||
Owns:
|
||||
- `GpgsClient` implementing the `SyncProvider` trait from `solitaire_data`
|
||||
- GPGS Saved Games API calls (load/save cloud save slot)
|
||||
- GPGS Achievements API calls (unlock, reveal, increment)
|
||||
- GPGS Leaderboards API calls (submit score, load scores)
|
||||
- Google Sign-In token management (via JNI into Android SDK)
|
||||
- Conversion between GPGS cloud save blob ↔ `SyncPayload`
|
||||
|
||||
> **Note:** This crate contains only a trait stub and compile-time stub implementations until Android support is actively developed. Do not implement JNI bindings until Phase: Android.
|
||||
- `SyncProvider` trait — implemented by `SolitaireServerClient`
|
||||
|
||||
### `solitaire_engine`
|
||||
**Dependencies:** `bevy`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`.
|
||||
@@ -165,6 +144,7 @@ Owns:
|
||||
- All Bevy UI screens (Home, Stats, Achievements, Settings, Profile)
|
||||
- Audio playback systems
|
||||
- Sync status display
|
||||
- Card, background, and font asset loading (embedded via `include_bytes!()` — no `AssetServer` dependency)
|
||||
|
||||
### `solitaire_server`
|
||||
**Dependencies:** `solitaire_sync`, `axum`, `sqlx`, `jsonwebtoken`, `bcrypt`, `tower-governor`, `tracing`, `tokio`, `dotenvy`.
|
||||
@@ -223,8 +203,7 @@ SyncPlugin::on_startup()
|
||||
│ spawns AsyncComputeTask
|
||||
▼
|
||||
solitaire_data::sync_pull() ← dispatches to active SyncProvider
|
||||
│ SolitaireServerClient (desktop / iOS)
|
||||
│ GpgsClient (Android, future)
|
||||
│ SolitaireServerClient
|
||||
▼
|
||||
solitaire_sync::merge(local, remote)
|
||||
│
|
||||
@@ -245,7 +224,7 @@ SyncPlugin::on_exit()
|
||||
│ blocking push (acceptable on exit, not on main loop)
|
||||
▼
|
||||
active SyncProvider::push(local)
|
||||
│ POST to server — or — GPGS Saved Games PUT (Android)
|
||||
│ POST to server
|
||||
▼
|
||||
Done
|
||||
```
|
||||
@@ -260,6 +239,7 @@ Done
|
||||
|---|---|---|
|
||||
| `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop |
|
||||
| `TablePlugin` | — | Pile markers, background, layout calculation |
|
||||
| `FontPlugin` | — | Embeds FiraMono-Medium font at compile time; 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 |
|
||||
@@ -309,6 +289,20 @@ 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 embedded at compile time)
|
||||
struct FontResource(Handle<Font>);
|
||||
|
||||
// Pre-loaded background PNG handles
|
||||
struct BackgroundImageSet {
|
||||
handles: Vec<Handle<Image>>, // indices 0–4 match selected_background setting
|
||||
}
|
||||
```
|
||||
|
||||
### Key Bevy Events
|
||||
@@ -382,7 +376,6 @@ Implementations:
|
||||
|---|---|---|
|
||||
| `LocalOnlyProvider` | No-op (default) | All |
|
||||
| `SolitaireServerClient` | Self-hosted server | All |
|
||||
| `GpgsClient` *(future)* | Google Play Games Services | Android only |
|
||||
|
||||
Sync always runs on `bevy::tasks::AsyncComputeTaskPool` — the game thread is never blocked.
|
||||
|
||||
@@ -397,9 +390,6 @@ pub enum SyncBackend {
|
||||
// JWT access + refresh tokens stored in OS keychain
|
||||
// key: "solitaire_quest_server_{username}"
|
||||
},
|
||||
GooglePlayGames,
|
||||
// No credentials stored locally — auth managed by Google Sign-In SDK via JNI
|
||||
// Android only; selecting this on non-Android falls back to Local silently
|
||||
}
|
||||
```
|
||||
|
||||
@@ -411,10 +401,6 @@ 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.
|
||||
|
||||
### Google Play Games Sync *(Android — future, see Section 8)*
|
||||
|
||||
Implemented in `solitaire_gpgs` crate. Uses the GPGS Saved Games API with named slot `"solitaire_quest_sync"`. The `GpgsClient` struct implements `SyncProvider` — the `SyncPlugin` treats it identically to `SolitaireServerClient`. The same `solitaire_sync::merge()` function applies regardless of which provider returned the remote data.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sync Server Architecture
|
||||
@@ -501,89 +487,7 @@ This ensures all players worldwide get the same challenge for a given date, rega
|
||||
|
||||
---
|
||||
|
||||
## 8. Google Play Games Services (Android Future)
|
||||
|
||||
> **Status: Stub only.** Do not implement JNI bindings until Android is actively targeted. The `solitaire_gpgs` crate exists in the workspace with a trait stub so the compiler enforces the interface contract from day one.
|
||||
|
||||
### Why GPGS on Android
|
||||
|
||||
Google Play Games Services provides first-class Android features that would otherwise require significant backend work:
|
||||
|
||||
| Feature | GPGS Provides | Our Alternative |
|
||||
|---|---|---|
|
||||
| Cloud saves | Saved Games API | Self-hosted server |
|
||||
| Achievements | Native popups + Play profile | In-game toasts only |
|
||||
| Leaderboards | Hosted by Google, visible in Play app | Server leaderboard |
|
||||
| Auth | Google Sign-In, no registration | Username + password |
|
||||
|
||||
On Android, GPGS is the **primary** sync provider. The self-hosted server is the **fallback** if the player is not signed in or has no server configured. Both can be active simultaneously — a win pushes to both, pull merges from whichever responded last.
|
||||
|
||||
### Compatibility Reality
|
||||
|
||||
| Platform | GPGS Support |
|
||||
|---|---|
|
||||
| Android | ✅ Full |
|
||||
| Windows | ✅ GPGS for PC (optional, separate SDK) |
|
||||
| macOS | ❌ Not supported |
|
||||
| Linux | ❌ Not supported |
|
||||
| iOS | ❌ Not supported |
|
||||
|
||||
macOS, Linux, and iOS users always use the self-hosted server. This is why the server is the primary design and GPGS is an enhancement layer.
|
||||
|
||||
### `solitaire_gpgs` Crate Design
|
||||
|
||||
The crate is compiled only on Android (`#[cfg(target_os = "android")]`). On all other platforms the crate exports only the stub.
|
||||
|
||||
```rust
|
||||
// solitaire_gpgs/src/lib.rs
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android;
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
mod stub;
|
||||
|
||||
pub use stub::GpgsClient; // stub on desktop
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android::GpgsClient; // real impl on Android
|
||||
```
|
||||
|
||||
### JNI Bridge (Android implementation — future)
|
||||
|
||||
The real `GpgsClient` uses the `jni` crate to call into the GPGS Android SDK:
|
||||
|
||||
```
|
||||
Rust GpgsClient
|
||||
│ jni::JNIEnv
|
||||
▼
|
||||
Java: com.google.android.gms.games.PlayGames
|
||||
├── getSnapshotsClient() → Saved Games (sync payload)
|
||||
├── getAchievementsClient() → unlock / reveal
|
||||
└── getLeaderboardsClient() → submit score
|
||||
```
|
||||
|
||||
Steps required when Android work begins:
|
||||
1. Add `cargo-mobile2` to the build toolchain
|
||||
2. Implement `GpgsClient` with `jni` bindings in `solitaire_gpgs/src/android.rs`
|
||||
3. Add `GpgsClient: SyncProvider` impl — pull/push map to Saved Games load/save
|
||||
4. Mirror achievement unlocks: on `AchievementUnlockedEvent`, call GPGS unlock API alongside in-game toast
|
||||
5. Submit scores to GPGS leaderboard on `GameWonEvent`
|
||||
6. Add Google Sign-In button to the Settings screen (Android build only, `#[cfg]` gated)
|
||||
|
||||
### Dual-Sync on Android
|
||||
|
||||
When both GPGS and the self-hosted server are configured, the `SyncPlugin` runs both providers concurrently and merges all three payloads (local + GPGS + server) using the same `solitaire_sync::merge()` function applied twice:
|
||||
|
||||
```
|
||||
local ──────┐
|
||||
├── merge() ──► intermediate ──┐
|
||||
gpgs ────────┘ ├── merge() ──► final
|
||||
server ──────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Data Models
|
||||
## 8. Data Models
|
||||
|
||||
### Core Game Models (`solitaire_core`)
|
||||
|
||||
@@ -677,14 +581,14 @@ pub struct Settings {
|
||||
pub music_volume: f32,
|
||||
pub animation_speed: AnimSpeed,
|
||||
pub theme: Theme,
|
||||
pub sync_backend: SyncBackend, // Local | SolitaireServer | GooglePlayGames
|
||||
pub sync_backend: SyncBackend, // Local | SolitaireServer
|
||||
pub first_run_complete: bool,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. API Reference
|
||||
## 9. API Reference
|
||||
|
||||
All endpoints are under the base URL configured by the user (e.g., `https://solitaire.example.com`).
|
||||
|
||||
@@ -727,9 +631,9 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
||||
|
||||
---
|
||||
|
||||
## 11. Merge Strategy
|
||||
## 10. Merge Strategy
|
||||
|
||||
Used identically by the `SolitaireServerClient`, `GpgsClient`, and server-side handler. Lives in `solitaire_sync` as a pure function with no I/O. Called once per provider when multiple backends are active simultaneously (e.g. GPGS + server on Android).
|
||||
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 {
|
||||
@@ -769,7 +673,7 @@ pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload {
|
||||
|
||||
---
|
||||
|
||||
## 12. Achievement System
|
||||
## 11. Achievement System
|
||||
|
||||
### Definition Structure
|
||||
|
||||
@@ -814,13 +718,9 @@ pub struct AchievementDef {
|
||||
|
||||
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.
|
||||
|
||||
### GPGS Mirroring *(Android, future)*
|
||||
|
||||
When the `GpgsClient` is active, every `AchievementUnlockedEvent` also triggers a GPGS `achievements.unlock()` call via JNI so the achievement appears in the player's Google Play profile. A static map in `solitaire_gpgs` maps our achievement IDs to GPGS achievement IDs (assigned in the Google Play Console). Mirroring is fire-and-forget — failures are logged but never block the in-game toast.
|
||||
|
||||
---
|
||||
|
||||
## 13. Progression System
|
||||
## 12. Progression System
|
||||
|
||||
### XP Sources
|
||||
|
||||
@@ -849,7 +749,7 @@ Levels 11+: level = 10 + floor((total_xp - 5000) / 1000)
|
||||
|
||||
---
|
||||
|
||||
## 14. Audio System
|
||||
## 13. Audio System
|
||||
|
||||
Audio uses `bevy_kira_audio`. All sound files are `.wav`.
|
||||
|
||||
@@ -860,7 +760,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.wav` | Looping background music |
|
||||
|
||||
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.
|
||||
|
||||
@@ -868,43 +768,64 @@ Audio systems listen for Bevy events and never block the game thread.
|
||||
|
||||
---
|
||||
|
||||
## 15. Asset Pipeline
|
||||
## 14. Asset Pipeline
|
||||
|
||||
All assets are loaded through Bevy's `AssetServer`. No bytes are hardcoded in source.
|
||||
### Rendering approach
|
||||
|
||||
### Card Sprites
|
||||
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 from `include_bytes!()` — no `AssetServer`.
|
||||
|
||||
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
|
||||
Backgrounds are Bevy `Sprite` entities with `Handle<Image>` from `BackgroundImageSet`. `BackgroundImageSet` is populated at startup from `include_bytes!()`.
|
||||
|
||||
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`.
|
||||
The font `FiraMono-Medium` is embedded via `include_bytes!()` at startup by `FontPlugin` and exposed as `FontResource` for use by all UI and text systems.
|
||||
|
||||
### Backgrounds
|
||||
The `assets/` directory layout:
|
||||
|
||||
`assets/backgrounds/bg_0.png` through `bg_4.png`. Same unlock gating as card backs.
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
### Fonts
|
||||
### Audio
|
||||
|
||||
`assets/fonts/main.ttf` — used for card rank/suit text in Bevy UI.
|
||||
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 |
|
||||
|
||||
---
|
||||
|
||||
## 16. Platform Targets
|
||||
## 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; optional GPGS for PC (future) |
|
||||
| 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 | Stretch | Google Play Games + server | `cargo-mobile2`, touch input, GPGS via JNI |
|
||||
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input; GPGS unavailable on iOS |
|
||||
| Android | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
|
||||
| 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`.
|
||||
|
||||
---
|
||||
|
||||
## 17. Build & Development Guide
|
||||
## 16. Build & Development Guide
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -965,7 +886,7 @@ Add `--features bevy/dynamic_linking` during development to dramatically reduce
|
||||
|
||||
---
|
||||
|
||||
## 18. Deployment Guide
|
||||
## 17. Deployment Guide
|
||||
|
||||
### Docker Compose (Recommended)
|
||||
|
||||
@@ -1010,7 +931,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
|
||||
|
||||
---
|
||||
|
||||
## 19. Security Model
|
||||
## 18. Security Model
|
||||
|
||||
| Concern | Mitigation |
|
||||
|---|---|
|
||||
@@ -1026,7 +947,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
|
||||
|
||||
---
|
||||
|
||||
## 20. Testing Strategy
|
||||
## 19. Testing Strategy
|
||||
|
||||
### Unit Tests (`solitaire_core`)
|
||||
|
||||
@@ -1065,12 +986,10 @@ Using `axum::test` and an in-memory SQLite database:
|
||||
- [ ] Achievement toast appears and dismisses
|
||||
- [ ] Server sync: register, login, push, pull on second machine
|
||||
- [ ] Server sync: JWT refresh on 401 works transparently
|
||||
- [ ] GPGS sync (Android only): sign in, unlock achievement, verify appears in Play Games app
|
||||
- [ ] Dual sync (Android only): GPGS + server both configured, payloads merge correctly
|
||||
|
||||
---
|
||||
|
||||
## 21. Decision Log
|
||||
## 20. Decision Log
|
||||
|
||||
| Decision | Rationale | Date |
|
||||
|---|---|---|
|
||||
@@ -1082,7 +1001,8 @@ Using `axum::test` and an in-memory SQLite database:
|
||||
| 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 | Allows adding Google Play Games Services cleanly; `SyncPlugin` stays backend-agnostic and testable | 2026-04-20 |
|
||||
| GPGS as Android enhancement, not replacement | GPGS has no macOS/Linux support; the server must remain universal, with GPGS layered on top for Android players | 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 |
|
||||
| `solitaire_gpgs` crate stubbed from day one | Enforces the `SyncProvider` interface contract at compile time even before Android work begins; avoids architectural rework later | 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 |
|
||||
| PNG assets embedded via `include_bytes!()` | Using `Image::from_buffer()` in startup systems rather than `AssetServer::load()` keeps the binary self-contained and eliminates runtime file-not-found errors | 2026-04-29 |
|
||||
| FiraMono-Medium font embedded via `include_bytes!()` | Exposed through `FontResource`; avoids runtime font loading errors on headless systems and ensures consistent text rendering across all platforms | 2026-04-29 |
|
||||
|
||||
@@ -12,7 +12,6 @@ solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
|
||||
solitaire_data/ # Persistence + SyncProvider trait + server client
|
||||
solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||
solitaire_server/ # Axum sync server binary
|
||||
solitaire_gpgs/ # Google Play Games bridge — STUB ONLY until Android phase
|
||||
solitaire_app/ # Thin binary entry point
|
||||
assets/ # Source assets — embedded at compile time via include_bytes!()
|
||||
```
|
||||
@@ -48,12 +47,11 @@ 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.
|
||||
- All sync backends implement the `SyncProvider` trait. The `SyncPlugin` is backend-agnostic — never `match` on `SyncBackend` inside a Bevy system.
|
||||
- `solitaire_gpgs` is a stub until Android work begins. Do not write JNI bindings yet; keep the compile-time stub so the trait contract is enforced from day one.
|
||||
- `cargo clippy --workspace -- -D warnings` must pass clean after every change.
|
||||
- `cargo test --workspace` must pass after every change.
|
||||
|
||||
|
||||
@@ -5,16 +5,16 @@ members = [
|
||||
"solitaire_data",
|
||||
"solitaire_engine",
|
||||
"solitaire_server",
|
||||
"solitaire_gpgs",
|
||||
"solitaire_app",
|
||||
"solitaire_assetgen",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
version = "0.1.0"
|
||||
license = "MIT"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -22,11 +22,12 @@ serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
rand = "0.8"
|
||||
rand = "0.9"
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
dirs = "6"
|
||||
keyring = "2"
|
||||
keyring = "4"
|
||||
keyring-core = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||
|
||||
solitaire_core = { path = "solitaire_core" }
|
||||
|
||||
@@ -6,10 +6,6 @@ FROM rust:slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y pkg-config libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
|
||||
# Tell sqlx to use the cached query metadata instead of a live database.
|
||||
@@ -22,11 +18,11 @@ RUN cargo build --release -p solitaire_server
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y libssl3 ca-certificates \
|
||||
&& apt-get install -y ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/solitaire_server /usr/local/bin/solitaire_server
|
||||
|
||||
EXPOSE 8080
|
||||
EXPOSE ${SERVER_PORT:-8080}
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/solitaire_server"]
|
||||
|
||||
@@ -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).
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,247 @@
|
||||
# Android Port Investigation
|
||||
|
||||
> **Date:** 2026-04-28
|
||||
> **Author:** Claude Code
|
||||
> **Scope:** Feasibility analysis for porting Solitaire Quest to Android using cargo-mobile2
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
A working Android port is feasible but not trivial. The core game logic (`solitaire_core`, `solitaire_sync`) compiles to Android without changes. Every other crate requires at least minor surgery. The biggest blockers are the `keyring` crate (no Android backend), the `kira`/`AudioManager` audio stack (`DefaultBackend` uses CPAL which targets desktop), and the `dirs` crate returning `None` on Android in its current usage. Touch input already has a solid foundation in `input_plugin.rs`. Estimated effort from a clean Android toolchain is **12–18 developer-days** to reach a playable-but-rough state.
|
||||
|
||||
---
|
||||
|
||||
## 1. Bevy on Android — Current Status
|
||||
|
||||
Bevy's Android support is community-maintained via the `winit` backend and is usable but carries known rough edges as of the 0.15/0.16 generation.
|
||||
|
||||
**What works:**
|
||||
- Basic rendering via Vulkan (through `wgpu`). OpenGL ES fallback is available for older devices.
|
||||
- Touch input events: Bevy's `TouchInput` events and the `Touches` resource are populated from Android `MotionEvent`s via `winit`. The existing `touch_start_drag`, `touch_follow_drag`, `touch_end_drag`, and `handle_touch_stock_tap` systems in `input_plugin.rs` will function correctly — this was already written with multi-touch in mind and uses `TouchPhase::Started/Moved/Ended/Canceled` cleanly.
|
||||
- Bevy UI (the `bevy::ui` module used for all overlays).
|
||||
- `WindowResized` events fire correctly, so the layout system will recompute for any screen size.
|
||||
|
||||
**What does not work / needs attention:**
|
||||
- **`bevy/dynamic_linking`**: The dynamic linking feature must be stripped from any Android build profile. Dynamic linking is a desktop-only development shortcut; Android requires static linking.
|
||||
- **Fixed window size**: `main.rs` sets `resolution: (1280u32, 800u32)`. On Android the window is always the full display. This value is harmlessly overridden by the OS, but `min_width`/`min_height` constraints should be removed or set to 0 for Android to avoid Winit warnings.
|
||||
- **`F11` fullscreen toggle** (`handle_fullscreen` in `input_plugin.rs`): `WindowMode::BorderlessFullscreen` is desktop-only. On Android it should be a no-op.
|
||||
- **Keyboard shortcuts**: The entire `handle_keyboard_core`, `handle_keyboard_hint`, `handle_keyboard_forfeit` systems are desktop-only workflows. They will not crash, but they are dead code on Android. No touchscreen replacement for Undo (U), New Game (N), Draw (D/Space), Hint (H), Forfeit (G) exists yet — these need an on-screen UI.
|
||||
- **`CursorPlugin`**: The custom cursor sprite plugin is irrelevant on Android (no cursor). Harmless to leave registered, but it uses `PrimaryWindow` cursor APIs that may panic or warn on Android.
|
||||
|
||||
**cargo-mobile2 integration for Bevy:**
|
||||
The standard path is:
|
||||
1. Install `cargo-mobile2`: `cargo install --locked cargo-mobile2`
|
||||
2. Run `cargo mobile init` in the workspace root. This generates an `android/` directory with the Gradle project, `AndroidManifest.xml`, and JNI glue.
|
||||
3. cargo-mobile2 targets the `solitaire_app` binary crate (the thin entry point). The generated `lib.rs` shim calls `android_main` via `bevy::winit`'s Android entry point.
|
||||
4. The `solitaire_app` crate needs a `[lib]` target added alongside the existing `[[bin]]`, with `crate-type = ["cdylib"]`, used only when building for Android.
|
||||
|
||||
**Required `Cargo.toml` changes (workspace level):**
|
||||
```toml
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
# android_logger and ndk-glue wiring are handled by cargo-mobile2's generated shim.
|
||||
# No direct ndk-glue dependency is needed in app code when using Bevy + cargo-mobile2.
|
||||
```
|
||||
|
||||
**NDK version:** Android NDK r25c or r26 LTS is the tested range for `wgpu`/Vulkan on Android. NDK r27+ may work but has had compatibility reports with CPAL. Set `ANDROID_NDK_ROOT` to the NDK root; the minimum API level should be 26 (Android 8.0) for Vulkan stability.
|
||||
|
||||
---
|
||||
|
||||
## 2. Audio — `kira` + `DefaultBackend`
|
||||
|
||||
**The problem:**
|
||||
`solitaire_engine/src/audio_plugin.rs` creates an `AudioManager<DefaultBackend>`. `kira`'s `DefaultBackend` is an alias for `CpalBackend`, which wraps CPAL. CPAL's Android backend uses OpenSL ES and is functional but historically fragile. As of kira 0.9+, `kira` no longer bundles its own CPAL backend by default in the same way — the `DefaultBackend` feature must be enabled explicitly and requires `cpal` with the Android feature.
|
||||
|
||||
**Current code behavior:**
|
||||
The `AudioPlugin::build` already handles the "no audio device" case gracefully:
|
||||
```rust
|
||||
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
|
||||
if manager.is_none() {
|
||||
warn!("audio device unavailable; SFX disabled");
|
||||
}
|
||||
```
|
||||
This means if the audio manager fails to initialise on Android, the game continues silently. This is acceptable as a first-pass fallback.
|
||||
|
||||
**What is needed for working audio on Android:**
|
||||
- Add `kira` dependency with `cpal` backend enabled for Android: The `kira` workspace dependency currently specifies `version = "0.12"`. Verify that `kira/Cargo.toml` exposes a `cpal` feature (or that `DefaultBackend` compiles on Android targets with NDK). If not, a `CpalBackend` with `cpal = { features = ["oboe"] }` may be needed.
|
||||
- The `NonSend` resource `AudioState` should compile fine — `NonSend` is legal in Bevy Android builds.
|
||||
- `include_bytes!` for the WAV assets is compile-time and unaffected by platform.
|
||||
|
||||
**Recommendation:** Defer full audio verification to a device test. The graceful fallback means a silent-but-working first build is achievable without resolving this.
|
||||
|
||||
---
|
||||
|
||||
## 3. `keyring` Crate — No Android Backend
|
||||
|
||||
**The problem:**
|
||||
`keyring = "2"` is used in `solitaire_data/src/auth_tokens.rs` to store JWT access and refresh tokens in the OS keychain. The `keyring` crate's Android backend does not exist — as of v2.x, supported backends are: macOS Keychain, Windows Credential Manager, Linux Secret Service (D-Bus), and iOS Keychain. There is no Android KeyStore backend.
|
||||
|
||||
On Android, `Entry::new(...)` will return `keyring::Error::NoStorageAccess`, which the existing code already maps to `TokenError::KeychainUnavailable`. So the code will not crash — it will simply fail every token store/load operation.
|
||||
|
||||
**Current failure mode:**
|
||||
Every call to `store_tokens`, `load_access_token`, `load_refresh_token`, or `delete_tokens` will return `Err(TokenError::KeychainUnavailable(...))`. The sync client in `sync_client.rs` needs to be verified to handle this gracefully rather than propagating an error that disables sync entirely.
|
||||
|
||||
**Options for Android credential storage:**
|
||||
|
||||
| Option | Security | Effort | Notes |
|
||||
|---|---|---|---|
|
||||
| **In-memory only (prompt re-login each session)** | N/A | 1 day | Simplest. On `TokenError::KeychainUnavailable`, the `SyncProvider` returns `SyncError::Auth`, user is prompted to log in. Already architecturally supported. |
|
||||
| **Encrypted `SharedPreferences` equivalent via JNI** | Good | 4–6 days | Call Android's `EncryptedSharedPreferences` (Jetpack Security) via JNI. Significant JNI boilerplate. |
|
||||
| **AES-256 file encryption using Android Keystore via JNI** | Excellent | 5–8 days | Proper Android keychain equivalent. Complex JNI. |
|
||||
| **Store in app-private file, unencrypted** | Poor | 0.5 days | Only acceptable during development. Never ship. |
|
||||
|
||||
**Recommended approach (first pass):** Use the in-memory / re-login-each-session path. The existing `TokenError::KeychainUnavailable` variant already exists for exactly this reason (Linux without a running secret service). The `SyncPlugin` should detect this on startup and present a "Sync unavailable — please log in" message rather than a hard error. This requires:
|
||||
1. Conditional compilation: when `cfg(target_os = "android")`, replace the `keyring` calls with a no-op in-memory store (a simple `Mutex<HashMap<String, String>>`).
|
||||
2. A `#[cfg(not(target_os = "android"))]` guard on the `keyring` import/dependency in `solitaire_data/Cargo.toml`.
|
||||
|
||||
**Required `solitaire_data/Cargo.toml` change:**
|
||||
```toml
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
keyring = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
# keyring is replaced by in-memory storage; no dependency needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `dirs` Crate — Data Directory on Android
|
||||
|
||||
**The problem:**
|
||||
`storage.rs` and other persistence modules use `dirs::data_dir()` to locate `~/.local/share/solitaire_quest/` (or platform equivalent). On Android, `dirs::data_dir()` returns `None` because there is no `XDG_DATA_HOME` and the `dirs` crate does not implement an Android-specific path.
|
||||
|
||||
**Current code behavior:**
|
||||
All persistence functions already handle `None` gracefully (returning default values or `Err`), consistent with the CLAUDE.md lesson about `dirs::data_dir()`. Stats and progress will silently not persist across sessions if `data_dir()` returns `None`.
|
||||
|
||||
**Fix required:**
|
||||
Android apps should store private data in the app's internal storage directory, obtained via JNI: `context.getFilesDir()`. This requires either:
|
||||
- A thin JNI helper (via `jni` crate) called once on startup to obtain the path and store it as a global.
|
||||
- Or passing the path in via the `android_main` entry point using `cargo-mobile2`'s `AndroidApp` handle, which exposes `internal_data_path()`.
|
||||
|
||||
The `cargo-mobile2` + Bevy path exposes an `AndroidApp` via `bevy::winit`'s Android entry point. Bevy 0.13+ passes `AndroidApp` through `WinitPlugin`, and it is accessible via a Bevy resource. A startup system can extract `app.internal_data_path()` and insert a `PlatformDataDirResource` that the storage functions read instead of calling `dirs::data_dir()`.
|
||||
|
||||
**Effort:** 1–2 days to implement the override and thread it through all `storage.rs` / `progress.rs` / `settings.rs` / `achievements.rs` call sites.
|
||||
|
||||
---
|
||||
|
||||
## 5. Touch Input — Current State and Gaps
|
||||
|
||||
**What already exists (strong foundation):**
|
||||
|
||||
The `InputPlugin` in `input_plugin.rs` has a complete parallel touch pipeline:
|
||||
|
||||
| System | Purpose | Status |
|
||||
|---|---|---|
|
||||
| `handle_touch_stock_tap` | Tap the stock pile to draw | Complete |
|
||||
| `touch_start_drag` | Begin a touch drag on a face-up card | Complete |
|
||||
| `touch_follow_drag` | Move card(s) with the active finger | Complete |
|
||||
| `touch_end_drag` | Resolve the drag (move or reject) | Complete |
|
||||
|
||||
The touch systems use `TouchInput` events and the `Touches` resource, map touch IDs to `DragState.active_touch_id` to prevent multi-finger conflicts, and share the same `DragState`, `MoveRequestEvent`, `MoveRejectedEvent`, and `StateChangedEvent` infrastructure as the mouse pipeline. The drag threshold (`tuning.drag_threshold_px`) applies identically.
|
||||
|
||||
**Gaps for a production Android experience:**
|
||||
|
||||
1. **No double-tap equivalent for auto-move**: `handle_double_click` is mouse-only. Android users need a double-tap to trigger the same "move to best destination" logic. The `handle_double_click` system checks `buttons.just_pressed(MouseButton::Left)` and will be inert on Android. Estimated: 1 day.
|
||||
|
||||
2. **No touch equivalent for keyboard actions**: Undo, New Game, Draw (when stock is visible but tapping it is awkward), Hint, and Forfeit have no on-screen buttons. These need an Android-specific UI bar or gesture (e.g. two-finger tap for undo). Estimated: 2–3 days for a minimal floating action button strip.
|
||||
|
||||
3. **Drag threshold tuning**: The threshold is in `AnimationTuning` (`tuning.drag_threshold_px`). Touch screens typically need a larger threshold than mouse (physical screens have more accidental movement during a tap). The current value should be evaluated on a real device and likely increased for touch.
|
||||
|
||||
4. **No long-press for right-click equivalent**: The right-click highlight/hint glow (`HintHighlightTimer`) is triggered via right mouse button. Long-press detection is not yet implemented. This is a missing feature but not a blocker for basic play.
|
||||
|
||||
5. **`handle_double_click` uses `LocalDateTime`-based timing via `Time`**: This will work on Android, but `DOUBLE_CLICK_WINDOW = 0.35s` may feel too tight on touch. Should be configurable.
|
||||
|
||||
---
|
||||
|
||||
## 6. Additional Issues Not in Scope of the Four Research Areas
|
||||
|
||||
**`CursorPlugin`:** Uses Bevy's cursor APIs which are desktop-only. Should be conditionally compiled out on Android with `#[cfg(not(target_os = "android"))]`.
|
||||
|
||||
**`reqwest` with `rustls-native-certs`:** The `reqwest` dependency uses `rustls` with native root certificates. On Android, `rustls-native-certs` reads system certificates differently (via the `android_system_properties` crate internally). This generally works but should be tested; Android's certificate store is in a non-standard location vs Linux.
|
||||
|
||||
**App lifecycle (suspend/resume):** Android can suspend the process at any time. Bevy handles `WindowEvent::Suspended` and `WindowEvent::Resumed` via `winit`, pausing the render loop. The `SyncPlugin`'s "push on exit" path (`AppExit` event) should also trigger on `WindowEvent::Suspended` to avoid data loss when the user backgrounds the app. This is a separate feature (1 day).
|
||||
|
||||
**No `sqlx` on Android:** `solitaire_server` is a server binary and is never built for Android. The `sqlx` dependency only exists in `solitaire_server/Cargo.toml` and will not affect Android builds of the client crates.
|
||||
|
||||
**`solitaire_assetgen`:** The asset generation tool is desktop-only and not part of the client build. Unaffected.
|
||||
|
||||
---
|
||||
|
||||
## 7. Required Changes Per Crate
|
||||
|
||||
### `solitaire_core` and `solitaire_sync`
|
||||
No changes required. Both are pure Rust with no platform dependencies.
|
||||
|
||||
### `solitaire_data`
|
||||
| Change | Effort |
|
||||
|---|---|
|
||||
| Gate `keyring` dependency on `#[cfg(not(target_os = "android"))]` | 0.5 days |
|
||||
| Implement `auth_tokens.rs` in-memory fallback for Android | 1 day |
|
||||
| Add `internal_data_path()` override for `dirs::data_dir()` on Android | 1.5 days |
|
||||
| Audit all `dirs::data_dir()` / `settings_file_path()` call sites to accept injected path | 0.5 days |
|
||||
|
||||
### `solitaire_engine`
|
||||
| Change | Effort |
|
||||
|---|---|
|
||||
| Conditionally disable `CursorPlugin` on Android | 0.5 days |
|
||||
| Disable `handle_fullscreen` on Android (or make it a no-op) | 0.25 days |
|
||||
| Implement double-tap for auto-move (touch equivalent of `handle_double_click`) | 1 day |
|
||||
| On-screen action bar for Undo, New Game, Hint (minimal floating buttons) | 2.5 days |
|
||||
| Tune drag threshold for touch; expose as a platform-specific tuning constant | 0.5 days |
|
||||
| Trigger sync push on `WindowEvent::Suspended` in `SyncPlugin` | 1 day |
|
||||
| Verify `kira` audio on Android (test `DefaultBackend` / CPAL; implement fallback if needed) | 1–2 days |
|
||||
|
||||
### `solitaire_app`
|
||||
| Change | Effort |
|
||||
|---|---|
|
||||
| Add `[lib]` target with `crate-type = ["cdylib"]` for Android builds | 0.25 days |
|
||||
| Create `src/lib.rs` (or `src/android.rs`) Android entry point calling `android_main` | 0.5 days |
|
||||
| Remove or guard fixed `resolution` / `resize_constraints` for Android | 0.25 days |
|
||||
| Pass `AndroidApp::internal_data_path()` to a startup resource | 0.5 days |
|
||||
|
||||
### Build / Toolchain
|
||||
| Change | Effort |
|
||||
|---|---|
|
||||
| Install cargo-mobile2, Android NDK r25c/r26, `aarch64-linux-android` target | 1 day |
|
||||
| Run `cargo mobile init`, configure `android/` Gradle project | 0.5 days |
|
||||
| Get a first build compiling (resolve linker / NDK issues) | 1–2 days |
|
||||
|
||||
---
|
||||
|
||||
## 8. Estimated Effort
|
||||
|
||||
| Phase | Description | Days |
|
||||
|---|---|---|
|
||||
| Toolchain setup | NDK, cargo-mobile2, first compile | 2–3 |
|
||||
| `solitaire_data` Android adaptations | keyring fallback, data dir | 3 |
|
||||
| `solitaire_app` Android entry point | cdylib, AndroidApp wiring | 1 |
|
||||
| `solitaire_engine` guards and fixes | cursor, fullscreen, audio verify | 2–3 |
|
||||
| Touch UX improvements | double-tap, action bar, threshold tuning | 4–5 |
|
||||
| Testing on real device / emulator | iteration, lifecycle edge cases | 2–3 |
|
||||
| **Total** | | **14–17 days** |
|
||||
|
||||
This produces a playable, functionally complete Android build. It does not include Play Store preparation (signing keys, metadata, icon set, permissions manifest tuning) which would add 1–2 more days.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommended First Step
|
||||
|
||||
**Get the workspace to compile for `aarch64-linux-android` without running.**
|
||||
|
||||
This surfaces all the real linker and dependency errors before writing any gameplay code:
|
||||
|
||||
```bash
|
||||
# Install toolchain
|
||||
rustup target add aarch64-linux-android
|
||||
cargo install --locked cargo-mobile2
|
||||
|
||||
# In the workspace root:
|
||||
cargo mobile init # generates android/ directory
|
||||
|
||||
# Attempt a library build targeting Android
|
||||
cargo build -p solitaire_app --target aarch64-linux-android 2>&1 | head -60
|
||||
```
|
||||
|
||||
The first build will fail on `keyring` (no Android backend) and likely on `dirs`. Fixing those two in `solitaire_data` — gate `keyring` behind `cfg(not(target_os = "android"))` and stub the data directory — will probably get the workspace to a clean compile. From there, the path to a running APK is incremental.
|
||||
|
||||
Do not attempt to resolve audio or touch UX until the build compiles cleanly. Compile errors are the only true blockers; the rest are feature gaps.
|
||||
@@ -0,0 +1,318 @@
|
||||
# Sync Subsystem Manual Test Runbook
|
||||
|
||||
**Version:** 1.0
|
||||
**Last Updated:** 2026-04-28
|
||||
**Scope:** Cross-machine sync, JWT refresh, conflict resolution, account deletion
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- Two machines (or VMs) referred to as **Machine A** and **Machine B** throughout this runbook. Both must be able to reach the sync server over the network.
|
||||
- A running Solitaire Quest sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup.
|
||||
- Verify the server is live before starting:
|
||||
|
||||
```bash
|
||||
curl -s https://solitaire.example.com/health
|
||||
# Expected: {"status":"ok","version":"..."}
|
||||
```
|
||||
|
||||
### Accounts
|
||||
|
||||
- You will register two separate accounts (`alice` and `bob`) during the tests. You do not need to create them in advance.
|
||||
|
||||
### Tooling
|
||||
|
||||
- `curl` or a REST client (Insomnia/Postman) for manual API calls.
|
||||
- `sqlite3` CLI if you need to inspect the server database directly.
|
||||
- The game binary built in release mode on both machines:
|
||||
|
||||
```bash
|
||||
cargo build -p solitaire_app --release
|
||||
```
|
||||
|
||||
### Baseline: Clear local data on both machines
|
||||
|
||||
Before starting, delete any existing local save files to ensure a clean state:
|
||||
|
||||
```
|
||||
# Linux
|
||||
rm -rf ~/.local/share/solitaire_quest/
|
||||
|
||||
# macOS
|
||||
rm -rf ~/Library/Application\ Support/solitaire_quest/
|
||||
|
||||
# Windows
|
||||
rmdir /s %APPDATA%\solitaire_quest\
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test 1 — Full Sync Round-Trip (register, play, push, verify on second machine)
|
||||
|
||||
**Goal:** Confirm that stats played on Machine A appear on Machine B after sync.
|
||||
|
||||
### Step 1 — Register on Machine A
|
||||
|
||||
1. Launch the game on Machine A.
|
||||
2. Open **Settings** (key: `O`) and locate the **Sync** section.
|
||||
3. Enter the server URL and choose a username: `alice`.
|
||||
4. Choose a password (at least 12 characters).
|
||||
5. Tap **Register** (or **Login** if the account already exists).
|
||||
6. The Settings screen should show **Status: syncing…** briefly, then **Status: last synced at HH:MM**.
|
||||
7. Close the game.
|
||||
|
||||
Verify the registration succeeded directly:
|
||||
|
||||
```bash
|
||||
curl -s -X POST https://solitaire.example.com/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"<your-password>"}' | jq .
|
||||
# Expected: {"access_token":"...","refresh_token":"..."}
|
||||
```
|
||||
|
||||
### Step 2 — Play games on Machine A
|
||||
|
||||
1. Launch the game on Machine A.
|
||||
2. Win at least **three games** (Draw One or Draw Three — note which mode).
|
||||
3. Check the Stats overlay (key: `S`) and note:
|
||||
- `games_played`
|
||||
- `games_won`
|
||||
- `win_streak_current`
|
||||
- `fastest_win_seconds`
|
||||
4. Close the game normally (this triggers the push-on-exit path).
|
||||
|
||||
### Step 3 — Verify the push reached the server
|
||||
|
||||
```bash
|
||||
# Log in to get a fresh token
|
||||
TOKEN=$(curl -s -X POST https://solitaire.example.com/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"<your-password>"}' | jq -r .access_token)
|
||||
|
||||
# Pull the server's stored state
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull | jq .merged.stats
|
||||
```
|
||||
|
||||
Confirm `games_won` matches what you recorded in Step 2.
|
||||
|
||||
### Step 4 — Pull on Machine B
|
||||
|
||||
1. Launch the game on **Machine B** (clean local data).
|
||||
2. Open **Settings**, enter the same server URL, and log in as `alice` with the same password.
|
||||
3. The plugin will pull on startup. Wait for **Status: last synced at HH:MM**.
|
||||
4. Open the Stats overlay (key: `S`) and confirm the numbers from Step 2 are present.
|
||||
|
||||
**Pass criterion:** `games_won`, `games_played`, and `fastest_win_seconds` on Machine B match Machine A.
|
||||
|
||||
---
|
||||
|
||||
## Test 2 — JWT Refresh on 401
|
||||
|
||||
**Goal:** Confirm that an expired access token is refreshed transparently without user interaction.
|
||||
|
||||
### Step 1 — Shorten the access token TTL on the server (test environment only)
|
||||
|
||||
Edit the server `.env` and set a short expiry, then restart:
|
||||
|
||||
```
|
||||
JWT_ACCESS_EXPIRY_SECS=5
|
||||
```
|
||||
|
||||
> If you cannot modify the server config, skip to the manual token corruption method in Step 1b.
|
||||
|
||||
### Step 1b (alternative) — Corrupt the stored access token directly
|
||||
|
||||
On the machine where you want to test (Linux example):
|
||||
|
||||
```bash
|
||||
# List keychain entries (uses secret-tool on GNOME)
|
||||
secret-tool search service solitaire_quest_server
|
||||
|
||||
# Overwrite alice's access token with a deliberately invalid value
|
||||
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "invalid.token.value"
|
||||
```
|
||||
|
||||
### Step 2 — Trigger a sync with the expired/invalid token
|
||||
|
||||
1. Launch the game.
|
||||
2. Either wait for the startup pull (for the short-TTL method), or open **Settings** and tap **Sync Now**.
|
||||
3. Observe the **Status** field.
|
||||
|
||||
**Pass criterion (transparent refresh):** Status briefly shows "syncing…" and then shows "last synced at HH:MM" — no auth error is displayed. The access token in the keychain has been silently replaced.
|
||||
|
||||
**Verify the new token is valid:**
|
||||
|
||||
```bash
|
||||
# Extract the new token from the keychain
|
||||
secret-tool lookup service solitaire_quest_server account alice_access | head -c 50
|
||||
# Should look like a valid JWT (three base64 segments separated by dots)
|
||||
```
|
||||
|
||||
### Step 3 — Test failed refresh (both tokens expired)
|
||||
|
||||
1. Corrupt both the access token and the refresh token in the keychain:
|
||||
|
||||
```bash
|
||||
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "bad"
|
||||
secret-tool store --label="alice_refresh" service solitaire_quest_server account alice_refresh <<< "bad"
|
||||
```
|
||||
|
||||
2. Launch the game and trigger a sync.
|
||||
|
||||
**Pass criterion:** The Settings screen shows an error message matching: "Login expired — tap Sync Now after re-logging in". The game must not crash. No data must be lost (local files are untouched).
|
||||
|
||||
3. Restore: log in again via Settings to get fresh tokens.
|
||||
|
||||
---
|
||||
|
||||
## Test 3 — Conflict Scenario (offline play on both machines, then sync)
|
||||
|
||||
**Goal:** Confirm that progress made on both devices offline is merged correctly, with no data silently discarded.
|
||||
|
||||
### Step 1 — Take both machines offline
|
||||
|
||||
Disable network on both Machine A and Machine B (e.g. airplane mode, or block the server URL in `/etc/hosts`).
|
||||
|
||||
### Step 2 — Play on Machine A (offline)
|
||||
|
||||
1. Win 5 games. Note the resulting streak and `games_won`.
|
||||
2. Close the game.
|
||||
|
||||
### Step 3 — Play on Machine B (offline)
|
||||
|
||||
1. Win 3 different games. Note the resulting streak and `games_won`.
|
||||
2. Close the game.
|
||||
|
||||
At this point Machine A and Machine B have divergent state.
|
||||
|
||||
### Step 4 — Re-enable network, sync Machine A first
|
||||
|
||||
1. Restore network.
|
||||
2. Launch the game on Machine A. The push-on-exit from Step 2 did not reach the server, so:
|
||||
- Open Settings, tap **Sync Now** to force a pull.
|
||||
- Close the game (triggers push-on-exit).
|
||||
3. Verify the server has Machine A's state:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull | jq '.merged.stats.games_won'
|
||||
```
|
||||
|
||||
### Step 5 — Sync Machine B
|
||||
|
||||
1. Launch the game on Machine B.
|
||||
2. The startup pull fetches the server's merged state (which now contains Machine A's wins).
|
||||
3. Open Settings — wait for **Status: last synced at HH:MM**.
|
||||
4. Open the Stats overlay.
|
||||
|
||||
**Pass criteria:**
|
||||
- `games_won` = max(Machine A wins, Machine B wins) — at minimum the higher of the two counts.
|
||||
- No games are lost — both machines' win counts contribute.
|
||||
- If the two machines had different `win_streak_current` values, a conflict should be recorded (visible if you inspect the server response directly):
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull | jq '.conflicts'
|
||||
```
|
||||
|
||||
- The `win_streak_current` conflict entry will show `local_value` and `remote_value`. The higher value is used as the best-effort resolution.
|
||||
|
||||
---
|
||||
|
||||
## Test 4 — Account Deletion
|
||||
|
||||
**Goal:** Confirm that `DELETE /api/account` removes all server-side data and that a subsequent authenticated request is rejected.
|
||||
|
||||
### Step 1 — Confirm data exists before deletion
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull | jq '.merged.stats.games_played'
|
||||
# Expected: a non-zero number
|
||||
```
|
||||
|
||||
### Step 2 — Delete the account via the API
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/account | jq .
|
||||
# Expected: {"ok":true}
|
||||
```
|
||||
|
||||
### Step 3 — Verify all data is gone from the server
|
||||
|
||||
```bash
|
||||
# Try to pull with the (now-invalid) token
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull
|
||||
# Expected: HTTP 401 Unauthorized
|
||||
|
||||
# Try to log in again with the same credentials
|
||||
curl -s -X POST https://solitaire.example.com/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"<your-password>"}' | jq .
|
||||
# Expected: HTTP 401 or error body indicating invalid credentials
|
||||
```
|
||||
|
||||
### Step 4 — Verify local data is NOT deleted
|
||||
|
||||
1. Open the game. The local files (`stats.json`, `progress.json`, etc.) must still be present and intact — account deletion only affects the server.
|
||||
2. Check the Stats overlay and confirm local game history is visible.
|
||||
3. The Settings screen may show an auth error on next sync attempt, which is expected.
|
||||
|
||||
### Step 5 — Re-register with the same username (optional)
|
||||
|
||||
```bash
|
||||
curl -s -X POST https://solitaire.example.com/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"<new-password>"}' | jq .
|
||||
# Expected: {"access_token":"...","refresh_token":"..."} — fresh empty account
|
||||
```
|
||||
|
||||
**Pass criterion:** Re-registration succeeds, and a subsequent pull returns a payload with all-zero stats (completely fresh account, no residual data from the deleted account).
|
||||
|
||||
---
|
||||
|
||||
## Test 5 — Server Errors Do Not Show "Login Expired"
|
||||
|
||||
**Goal:** Verify that a 500 Internal Server Error or 429 Too Many Requests shows a network error, not an auth error, to the user.
|
||||
|
||||
### Step 1 — Simulate a 500 with a reverse proxy rule
|
||||
|
||||
Add a temporary nginx/Caddy rule to return 500 for `/api/sync/*`:
|
||||
|
||||
```nginx
|
||||
location /api/sync/ {
|
||||
return 500;
|
||||
}
|
||||
```
|
||||
|
||||
Or use a local proxy like `mitmproxy` to intercept and rewrite responses.
|
||||
|
||||
### Step 2 — Trigger a sync
|
||||
|
||||
Open Settings and tap **Sync Now**.
|
||||
|
||||
**Pass criterion:** The Status field shows "Can't reach server — check your connection" (network error message), NOT "Login expired — tap Sync Now after re-logging in" (auth error message).
|
||||
|
||||
Remove the nginx rule after this test.
|
||||
|
||||
---
|
||||
|
||||
## Regression Checklist
|
||||
|
||||
After running all tests above, confirm:
|
||||
|
||||
- [ ] No crash occurred during any test on either machine.
|
||||
- [ ] Local save files (`stats.json`, `progress.json`, `achievements.json`) are present and valid JSON after all tests.
|
||||
- [ ] The game launches and plays normally after all sync operations (sync is additive — never blocks gameplay).
|
||||
- [ ] The Stats overlay shows correct numbers on both machines after a successful sync round-trip.
|
||||
- [ ] An expired token is refreshed transparently without the user having to log in again.
|
||||
- [ ] A doubly-expired token surfaces a clear error message to the user.
|
||||
- [ ] Account deletion removes all server data; local data is preserved.
|
||||
- [ ] HTTP 5xx and 429 responses show a network error, not an auth error.
|
||||
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
@@ -12,3 +12,4 @@ path = "src/main.rs"
|
||||
bevy = { workspace = true }
|
||||
solitaire_engine = { workspace = true }
|
||||
solitaire_data = { workspace = true }
|
||||
keyring = { workspace = true }
|
||||
|
||||
@@ -3,12 +3,24 @@ use solitaire_data::{load_settings_from, provider_for_backend, settings_file_pat
|
||||
use solitaire_engine::{
|
||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
|
||||
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
|
||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
// Initialise the platform keyring store before any token operations.
|
||||
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||
// If the platform has no OS keyring (e.g. a headless CI box), keyring
|
||||
// operations will fail gracefully with TokenError::KeychainUnavailable.
|
||||
if let Err(e) = keyring::use_native_store(true) {
|
||||
eprintln!(
|
||||
"warn: could not initialise OS keyring ({e}); \
|
||||
server sync login will be unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
// Load settings before building the app so we can construct the right
|
||||
// sync provider. Falls back to defaults if no settings file exists yet.
|
||||
let settings: Settings = settings_file_path()
|
||||
@@ -32,6 +44,7 @@ fn main() {
|
||||
..default()
|
||||
}),
|
||||
)
|
||||
.add_plugins(FontPlugin)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(CardPlugin)
|
||||
|
||||
@@ -5,9 +5,18 @@ license.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
# Dev-only utility: synthesizes placeholder SFX WAV files into `assets/audio/`.
|
||||
# Dev-only utility: synthesizes placeholder SFX WAV files into `assets/audio/`
|
||||
# and placeholder PNG images into `assets/cards/` and `assets/backgrounds/`.
|
||||
# Not depended on by any other workspace crate.
|
||||
|
||||
[dependencies]
|
||||
png = "0.17"
|
||||
ab_glyph = "0.2"
|
||||
|
||||
[[bin]]
|
||||
name = "gen_sfx"
|
||||
path = "src/bin/gen_sfx.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "gen_art"
|
||||
path = "src/bin/gen_art.rs"
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
//! Generates PNG assets for Solitaire Quest.
|
||||
//!
|
||||
//! Produces:
|
||||
//! - 52 card face PNGs (120×168) — one per card, with rank, suit symbol, and
|
||||
//! pip or face-letter layout baked in.
|
||||
//! - 5 card back PNGs (120×168) with distinctive coloured patterns.
|
||||
//! - 5 background PNGs (120×168) with textured felt/wood patterns.
|
||||
//!
|
||||
//! Run with: `cargo run -p solitaire_assetgen --bin gen_art`
|
||||
|
||||
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
use std::path::Path;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card dimensions and palette
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const W: u32 = 120;
|
||||
const H: u32 = 168;
|
||||
|
||||
const BG: [u8; 4] = [0xFE, 0xFE, 0xF2, 0xFF];
|
||||
const BORDER: [u8; 4] = [0x99, 0x99, 0x99, 0xFF];
|
||||
const RED: [u8; 4] = [0xCC, 0x11, 0x11, 0xFF];
|
||||
const DARK: [u8; 4] = [0x11, 0x11, 0x11, 0xFF];
|
||||
|
||||
fn suit_color(suit: u8) -> [u8; 4] {
|
||||
if suit == 1 || suit == 2 { RED } else { DARK }
|
||||
}
|
||||
|
||||
fn rank_str(rank: u8) -> &'static str {
|
||||
["A","2","3","4","5","6","7","8","9","10","J","Q","K"][rank as usize]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pixel canvas (120×168 RGBA)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Canvas {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Canvas {
|
||||
fn new() -> Self {
|
||||
let mut data = vec![0u8; (W * H * 4) as usize];
|
||||
for i in 0..(W * H) as usize {
|
||||
data[i * 4..i * 4 + 4].copy_from_slice(&BG);
|
||||
}
|
||||
Self { data }
|
||||
}
|
||||
|
||||
/// Fill every pixel with a solid colour, erasing whatever was there before.
|
||||
fn fill_solid(&mut self, c: [u8; 4]) {
|
||||
for i in 0..(W * H) as usize {
|
||||
self.data[i * 4..i * 4 + 4].copy_from_slice(&c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a 1-pixel-wide axis-aligned horizontal line.
|
||||
fn hline(&mut self, y: i32, x0: i32, x1: i32, c: [u8; 4]) {
|
||||
for x in x0..=x1 {
|
||||
self.set(x, y, c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a 1-pixel-wide axis-aligned vertical line.
|
||||
fn vline(&mut self, x: i32, y0: i32, y1: i32, c: [u8; 4]) {
|
||||
for y in y0..=y1 {
|
||||
self.set(x, y, c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a filled diamond outline (ring) of given half-extents and line thickness.
|
||||
fn diamond_ring(&mut self, cx: f32, cy: f32, rx: f32, ry: f32, thickness: f32, c: [u8; 4]) {
|
||||
for y in (cy - ry - 2.0) as i32..=(cy + ry + 2.0) as i32 {
|
||||
for x in (cx - rx - 2.0) as i32..=(cx + rx + 2.0) as i32 {
|
||||
let nx = (x as f32 - cx).abs() / rx;
|
||||
let ny = (y as f32 - cy).abs() / ry;
|
||||
let dist = nx + ny;
|
||||
if dist <= 1.0 && dist >= 1.0 - (thickness / rx.min(ry)) {
|
||||
self.set(x, y, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&mut self, x: i32, y: i32, c: [u8; 4]) {
|
||||
if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 { return; }
|
||||
let i = (y as u32 * W + x as u32) as usize * 4;
|
||||
let a = c[3] as f32 / 255.0;
|
||||
if a >= 0.99 {
|
||||
self.data[i..i + 4].copy_from_slice(&c);
|
||||
} else if a > 0.01 {
|
||||
self.data[i] = (self.data[i] as f32 * (1.0 - a) + c[0] as f32 * a) as u8;
|
||||
self.data[i + 1] = (self.data[i + 1] as f32 * (1.0 - a) + c[1] as f32 * a) as u8;
|
||||
self.data[i + 2] = (self.data[i + 2] as f32 * (1.0 - a) + c[2] as f32 * a) as u8;
|
||||
self.data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
fn circle(&mut self, cx: f32, cy: f32, r: f32, c: [u8; 4]) {
|
||||
for y in (cy - r - 1.0) as i32..=(cy + r + 1.0) as i32 {
|
||||
for x in (cx - r - 1.0) as i32..=(cx + r + 1.0) as i32 {
|
||||
if (x as f32 - cx).powi(2) + (y as f32 - cy).powi(2) <= r * r {
|
||||
self.set(x, y, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, c: [u8; 4]) {
|
||||
for ry in y..y + h {
|
||||
for rx in x..x + w {
|
||||
self.set(rx, ry, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn triangle(&mut self, pts: [(f32, f32); 3], c: [u8; 4]) {
|
||||
let min_x = pts.iter().map(|p| p.0).fold(f32::INFINITY, f32::min) as i32;
|
||||
let max_x = pts.iter().map(|p| p.0).fold(f32::NEG_INFINITY, f32::max) as i32;
|
||||
let min_y = pts.iter().map(|p| p.1).fold(f32::INFINITY, f32::min) as i32;
|
||||
let max_y = pts.iter().map(|p| p.1).fold(f32::NEG_INFINITY, f32::max) as i32;
|
||||
let (ax, ay) = pts[0];
|
||||
let (bx, by) = pts[1];
|
||||
let (ex, ey) = pts[2];
|
||||
for y in min_y..=max_y {
|
||||
for x in min_x..=max_x {
|
||||
let px = x as f32 + 0.5;
|
||||
let py = y as f32 + 0.5;
|
||||
let d0 = (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
||||
let d1 = (ex - bx) * (py - by) - (ey - by) * (px - bx);
|
||||
let d2 = (ax - ex) * (py - ey) - (ay - ey) * (px - ex);
|
||||
let neg = d0 < 0.0 || d1 < 0.0 || d2 < 0.0;
|
||||
let pos = d0 > 0.0 || d1 > 0.0 || d2 > 0.0;
|
||||
if !(neg && pos) {
|
||||
self.set(x, y, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn diamond(&mut self, cx: f32, cy: f32, rx: f32, ry: f32, c: [u8; 4]) {
|
||||
for y in (cy - ry - 1.0) as i32..=(cy + ry + 1.0) as i32 {
|
||||
for x in (cx - rx - 1.0) as i32..=(cx + rx + 1.0) as i32 {
|
||||
let nx = (x as f32 - cx).abs() / rx;
|
||||
let ny = (y as f32 - cy).abs() / ry;
|
||||
if nx + ny <= 1.0 {
|
||||
self.set(x, y, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suit symbol drawing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw_suit(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, suit: u8, c: [u8; 4]) {
|
||||
match suit {
|
||||
0 => draw_club(cv, cx, cy, sz, c),
|
||||
1 => draw_diamond_sym(cv, cx, cy, sz, c),
|
||||
2 => draw_heart(cv, cx, cy, sz, c),
|
||||
_ => draw_spade(cv, cx, cy, sz, c),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_heart(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
|
||||
let r = sz * 0.33;
|
||||
let oy = cy - sz * 0.04;
|
||||
cv.circle(cx - sz * 0.22, oy, r, c);
|
||||
cv.circle(cx + sz * 0.22, oy, r, c);
|
||||
cv.triangle([
|
||||
(cx - sz * 0.52, oy + r * 0.4),
|
||||
(cx + sz * 0.52, oy + r * 0.4),
|
||||
(cx, cy + sz * 0.52),
|
||||
], c);
|
||||
}
|
||||
|
||||
fn draw_spade(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
|
||||
cv.triangle([
|
||||
(cx, cy - sz * 0.52),
|
||||
(cx - sz * 0.52, cy + sz * 0.1),
|
||||
(cx + sz * 0.52, cy + sz * 0.1),
|
||||
], c);
|
||||
cv.circle(cx - sz * 0.22, cy + sz * 0.06, sz * 0.3, c);
|
||||
cv.circle(cx + sz * 0.22, cy + sz * 0.06, sz * 0.3, c);
|
||||
// stem + base
|
||||
cv.triangle([
|
||||
(cx, cy + sz * 0.12),
|
||||
(cx - sz * 0.13, cy + sz * 0.5),
|
||||
(cx + sz * 0.13, cy + sz * 0.5),
|
||||
], c);
|
||||
cv.fill_rect(
|
||||
(cx - sz * 0.26) as i32,
|
||||
(cy + sz * 0.43) as i32,
|
||||
(sz * 0.52) as i32,
|
||||
(sz * 0.1) as i32,
|
||||
c,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_diamond_sym(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
|
||||
cv.diamond(cx, cy, sz * 0.44, sz * 0.57, c);
|
||||
}
|
||||
|
||||
fn draw_club(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
|
||||
let r = sz * 0.29;
|
||||
cv.circle(cx, cy - sz * 0.22, r, c);
|
||||
cv.circle(cx - sz * 0.28, cy + sz * 0.1, r, c);
|
||||
cv.circle(cx + sz * 0.28, cy + sz * 0.1, r, c);
|
||||
cv.fill_rect(
|
||||
(cx - sz * 0.08) as i32,
|
||||
(cy + sz * 0.22) as i32,
|
||||
(sz * 0.16) as i32 + 1,
|
||||
(sz * 0.27) as i32,
|
||||
c,
|
||||
);
|
||||
cv.fill_rect(
|
||||
(cx - sz * 0.26) as i32,
|
||||
(cy + sz * 0.45) as i32,
|
||||
(sz * 0.52) as i32,
|
||||
(sz * 0.09) as i32,
|
||||
c,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text rendering via ab_glyph
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw_text(cv: &mut Canvas, font: &FontRef<'_>, text: &str, px: f32, left: f32, top: f32, c: [u8; 4]) {
|
||||
let scale = PxScale::from(px);
|
||||
let baseline = top + font.as_scaled(scale).ascent();
|
||||
let mut x = left;
|
||||
for ch in text.chars() {
|
||||
let gid = font.glyph_id(ch);
|
||||
let glyph = gid.with_scale_and_position(scale, ab_glyph::point(x, baseline));
|
||||
let adv = font.as_scaled(scale).h_advance(gid);
|
||||
if let Some(outlined) = font.outline_glyph(glyph) {
|
||||
let bounds = outlined.px_bounds();
|
||||
outlined.draw(|gx, gy, cov| {
|
||||
if cov > 0.02 {
|
||||
let alpha = (cov * c[3] as f32) as u8;
|
||||
cv.set(
|
||||
(bounds.min.x + gx as f32) as i32,
|
||||
(bounds.min.y + gy as f32) as i32,
|
||||
[c[0], c[1], c[2], alpha],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
x += adv;
|
||||
}
|
||||
}
|
||||
|
||||
fn text_w(font: &FontRef<'_>, text: &str, px: f32) -> f32 {
|
||||
let scale = PxScale::from(px);
|
||||
let sf = font.as_scaled(scale);
|
||||
text.chars().map(|c| sf.h_advance(font.glyph_id(c))).sum()
|
||||
}
|
||||
|
||||
fn text_h(font: &FontRef<'_>, px: f32) -> f32 {
|
||||
let scale = PxScale::from(px);
|
||||
let sf = font.as_scaled(scale);
|
||||
sf.ascent() - sf.descent()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pip layout (rank 0=Ace … 9=Ten; rank 10-12 are face cards)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn pip_positions(rank: u8) -> &'static [(f32, f32)] {
|
||||
match rank {
|
||||
0 => &[(0.5, 0.5)],
|
||||
1 => &[(0.5, 0.2), (0.5, 0.8)],
|
||||
2 => &[(0.5, 0.12), (0.5, 0.5), (0.5, 0.88)],
|
||||
3 => &[(0.25, 0.18), (0.75, 0.18), (0.25, 0.82), (0.75, 0.82)],
|
||||
4 => &[(0.25, 0.18), (0.75, 0.18), (0.5, 0.5), (0.25, 0.82), (0.75, 0.82)],
|
||||
5 => &[(0.25, 0.12), (0.75, 0.12), (0.25, 0.5), (0.75, 0.5), (0.25, 0.88), (0.75, 0.88)],
|
||||
6 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.31), (0.25, 0.5), (0.75, 0.5), (0.25, 0.9), (0.75, 0.9)],
|
||||
7 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.28), (0.25, 0.48), (0.75, 0.48), (0.5, 0.70), (0.25, 0.9), (0.75, 0.9)],
|
||||
8 => &[(0.25, 0.1), (0.75, 0.1), (0.25, 0.35), (0.75, 0.35), (0.5, 0.5), (0.25, 0.65), (0.75, 0.65), (0.25, 0.9), (0.75, 0.9)],
|
||||
9 => &[(0.25, 0.09), (0.75, 0.09), (0.5, 0.27), (0.25, 0.44), (0.75, 0.44), (0.25, 0.56), (0.75, 0.56), (0.5, 0.73), (0.25, 0.91), (0.75, 0.91)],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
// Pip area within the card (avoids the corner labels).
|
||||
const PIP_X: f32 = 22.0;
|
||||
const PIP_Y: f32 = 46.0;
|
||||
const PIP_W: f32 = 76.0;
|
||||
const PIP_H: f32 = 80.0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card face generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_card_face(font: &FontRef<'_>, rank: u8, suit: u8) -> Canvas {
|
||||
let mut cv = Canvas::new();
|
||||
let sc = suit_color(suit);
|
||||
|
||||
// Border (2 px)
|
||||
for x in 0..W as i32 {
|
||||
cv.set(x, 0, BORDER);
|
||||
cv.set(x, 1, BORDER);
|
||||
cv.set(x, H as i32 - 2, BORDER);
|
||||
cv.set(x, H as i32 - 1, BORDER);
|
||||
}
|
||||
for y in 0..H as i32 {
|
||||
cv.set(0, y, BORDER);
|
||||
cv.set(1, y, BORDER);
|
||||
cv.set(W as i32 - 2, y, BORDER);
|
||||
cv.set(W as i32 - 1, y, BORDER);
|
||||
}
|
||||
|
||||
let rank_s = rank_str(rank);
|
||||
let rank_px = 18.0f32;
|
||||
let suit_sz = 11.0f32;
|
||||
let rh = text_h(font, rank_px);
|
||||
let rw = text_w(font, rank_s, rank_px);
|
||||
let corner_h = rh + 2.0 + suit_sz * 1.5;
|
||||
|
||||
// Top-left corner
|
||||
let tl_x = 6.0f32;
|
||||
let tl_y = 5.0f32;
|
||||
draw_text(&mut cv, font, rank_s, rank_px, tl_x, tl_y, sc);
|
||||
draw_suit(&mut cv, tl_x + suit_sz * 0.62, tl_y + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc);
|
||||
|
||||
// Bottom-right corner (right-aligned rank, suit above it)
|
||||
let br_rx = W as f32 - 6.0;
|
||||
let br_by = H as f32 - 5.0;
|
||||
let br_ty = br_by - corner_h;
|
||||
draw_text(&mut cv, font, rank_s, rank_px, br_rx - rw, br_ty, sc);
|
||||
draw_suit(&mut cv, br_rx - suit_sz * 0.62, br_ty + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc);
|
||||
|
||||
// Center content
|
||||
if rank >= 10 {
|
||||
// Face cards: large rank letter + suit symbol below
|
||||
let big_px = 52.0f32;
|
||||
let big_w = text_w(font, rank_s, big_px);
|
||||
let big_h = text_h(font, big_px);
|
||||
let big_x = (W as f32 - big_w) / 2.0;
|
||||
let big_y = H as f32 * 0.28;
|
||||
draw_text(&mut cv, font, rank_s, big_px, big_x, big_y, sc);
|
||||
let sym_sz = 22.0f32;
|
||||
draw_suit(&mut cv, W as f32 * 0.5, big_y + big_h + sym_sz * 1.0, sym_sz, suit, sc);
|
||||
} else {
|
||||
// Pip cards
|
||||
let pip_sz = if rank == 0 {
|
||||
24.0f32 // Ace: large single pip
|
||||
} else if rank <= 5 {
|
||||
14.0
|
||||
} else {
|
||||
12.0
|
||||
};
|
||||
for &(nx, ny) in pip_positions(rank) {
|
||||
let cx = PIP_X + nx * PIP_W;
|
||||
let cy = PIP_Y + ny * PIP_H;
|
||||
draw_suit(&mut cv, cx, cy, pip_sz, suit, sc);
|
||||
}
|
||||
}
|
||||
|
||||
cv
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PNG encoding helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn save_card_png(path: &Path, cv: &Canvas) {
|
||||
save_png_wh(path, &cv.data, W, H);
|
||||
}
|
||||
|
||||
fn save_png_wh(path: &Path, data: &[u8], w: u32, h: u32) {
|
||||
let file = File::create(path)
|
||||
.unwrap_or_else(|e| panic!("cannot create {}: {e}", path.display()));
|
||||
let mut bw = BufWriter::new(file);
|
||||
let mut enc = png::Encoder::new(&mut bw, w, h);
|
||||
enc.set_color(png::ColorType::Rgba);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = enc.write_header()
|
||||
.unwrap_or_else(|e| panic!("png header error for {}: {e}", path.display()));
|
||||
writer.write_image_data(data)
|
||||
.unwrap_or_else(|e| panic!("png data error for {}: {e}", path.display()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card backs (120×168 with distinctive patterns)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// back_0 – blue: repeating diamond grid pattern
|
||||
fn make_back_0() -> Canvas {
|
||||
const BASE: [u8; 4] = [0x26, 0x4D, 0x8C, 0xFF];
|
||||
const LIGHT: [u8; 4] = [0x5A, 0x80, 0xBF, 0xFF];
|
||||
const HIGHLIGHT: [u8; 4] = [0xA0, 0xC0, 0xFF, 0xB0];
|
||||
let mut cv = Canvas::new();
|
||||
cv.fill_solid(BASE);
|
||||
|
||||
// 2-pixel border
|
||||
let bw = 4i32;
|
||||
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, LIGHT); cv.set(x, H as i32 - 1 - t, LIGHT); } }
|
||||
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, LIGHT); cv.set(W as i32 - 1 - t, y, LIGHT); } }
|
||||
|
||||
// Diamond grid: row/col spacing
|
||||
let gx = 18.0f32;
|
||||
let gy = 18.0f32;
|
||||
let rx = gx * 0.45;
|
||||
let ry = gy * 0.45;
|
||||
let mut row = 0;
|
||||
let mut cy = 6.0f32 + gy * 0.5;
|
||||
while cy < H as f32 - 4.0 {
|
||||
let offset = if row % 2 == 0 { 0.0 } else { gx * 0.5 };
|
||||
let mut cx = 6.0f32 + gx * 0.5 + offset;
|
||||
while cx < W as f32 - 4.0 {
|
||||
cv.diamond_ring(cx, cy, rx, ry, 1.5, LIGHT);
|
||||
// tiny highlight dot at centre of each diamond
|
||||
cv.circle(cx, cy, 1.5, HIGHLIGHT);
|
||||
cx += gx;
|
||||
}
|
||||
cy += gy;
|
||||
row += 1;
|
||||
}
|
||||
cv
|
||||
}
|
||||
|
||||
/// back_1 – red: diagonal crosshatch
|
||||
fn make_back_1() -> Canvas {
|
||||
const BASE: [u8; 4] = [0x8C, 0x1A, 0x1A, 0xFF];
|
||||
const LINE: [u8; 4] = [0xCC, 0x55, 0x55, 0xC0];
|
||||
const BORDER: [u8; 4] = [0xDD, 0x88, 0x88, 0xFF];
|
||||
let mut cv = Canvas::new();
|
||||
cv.fill_solid(BASE);
|
||||
|
||||
// Diagonal lines every 12 px (NW→SE)
|
||||
let spacing = 12i32;
|
||||
for k in (-(H as i32)..W as i32).step_by(spacing as usize) {
|
||||
for t in 0..W as i32 {
|
||||
let y = t + k;
|
||||
cv.set(t, y, LINE);
|
||||
// 1 px thick — also set neighbour for slightly bolder line
|
||||
cv.set(t, y + 1, LINE);
|
||||
}
|
||||
}
|
||||
// Diagonal lines (NE→SW)
|
||||
for k in (0..(W as i32 + H as i32)).step_by(spacing as usize) {
|
||||
for t in 0..W as i32 {
|
||||
let y = k - t;
|
||||
cv.set(t, y, LINE);
|
||||
cv.set(t, y + 1, LINE);
|
||||
}
|
||||
}
|
||||
|
||||
// 4-pixel border
|
||||
let bw = 4i32;
|
||||
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
|
||||
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
|
||||
cv
|
||||
}
|
||||
|
||||
/// back_2 – green: evenly spaced small circle array
|
||||
fn make_back_2() -> Canvas {
|
||||
const BASE: [u8; 4] = [0x0D, 0x66, 0x1A, 0xFF];
|
||||
const DOT: [u8; 4] = [0x40, 0xCC, 0x55, 0xE0];
|
||||
const BORDER: [u8; 4] = [0x55, 0xDD, 0x66, 0xFF];
|
||||
let mut cv = Canvas::new();
|
||||
cv.fill_solid(BASE);
|
||||
|
||||
// 4-pixel border
|
||||
let bw = 4i32;
|
||||
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
|
||||
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
|
||||
|
||||
// Circle array (staggered rows)
|
||||
let gx = 16.0f32;
|
||||
let gy = 16.0f32;
|
||||
let r = 3.5f32;
|
||||
let mut row = 0;
|
||||
let mut cy = 8.0f32 + gy * 0.5;
|
||||
while cy < H as f32 - 6.0 {
|
||||
let offset = if row % 2 == 0 { 0.0 } else { gx * 0.5 };
|
||||
let mut cx = 8.0f32 + gx * 0.5 + offset;
|
||||
while cx < W as f32 - 6.0 {
|
||||
cv.circle(cx, cy, r, DOT);
|
||||
cx += gx;
|
||||
}
|
||||
cy += gy;
|
||||
row += 1;
|
||||
}
|
||||
cv
|
||||
}
|
||||
|
||||
/// back_3 – purple: concentric diamond outlines
|
||||
fn make_back_3() -> Canvas {
|
||||
const BASE: [u8; 4] = [0x59, 0x14, 0x85, 0xFF];
|
||||
const RING: [u8; 4] = [0xA0, 0x60, 0xDD, 0xD0];
|
||||
const BORDER: [u8; 4] = [0xBB, 0x77, 0xFF, 0xFF];
|
||||
let mut cv = Canvas::new();
|
||||
cv.fill_solid(BASE);
|
||||
|
||||
// Concentric diamonds from centre
|
||||
let cx = W as f32 * 0.5;
|
||||
let cy = H as f32 * 0.5;
|
||||
let mut rx = 8.0f32;
|
||||
let step = 12.0f32;
|
||||
while rx < (W as f32).max(H as f32) {
|
||||
let ry = rx * (H as f32 / W as f32);
|
||||
cv.diamond_ring(cx, cy, rx, ry, 1.5, RING);
|
||||
rx += step;
|
||||
}
|
||||
|
||||
// 4-pixel border
|
||||
let bw = 4i32;
|
||||
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
|
||||
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
|
||||
cv
|
||||
}
|
||||
|
||||
/// back_4 – teal: horizontal stripes with thin decorative lines
|
||||
fn make_back_4() -> Canvas {
|
||||
const BASE: [u8; 4] = [0x0D, 0x66, 0x6B, 0xFF];
|
||||
const STRIPE: [u8; 4] = [0x1A, 0x99, 0xA0, 0x90];
|
||||
const DECO: [u8; 4] = [0x55, 0xCC, 0xD4, 0xA0];
|
||||
const BORDER: [u8; 4] = [0x44, 0xBB, 0xC4, 0xFF];
|
||||
let mut cv = Canvas::new();
|
||||
cv.fill_solid(BASE);
|
||||
|
||||
// Horizontal stripes every 10 px (2 px wide)
|
||||
let mut y = 6i32;
|
||||
while y < H as i32 - 4 {
|
||||
cv.hline(y, 5, W as i32 - 6, STRIPE);
|
||||
cv.hline(y + 1, 5, W as i32 - 6, STRIPE);
|
||||
y += 10;
|
||||
}
|
||||
// Thin decorative horizontal lines between stripes
|
||||
let mut y = 10i32;
|
||||
while y < H as i32 - 4 {
|
||||
cv.hline(y, 14, W as i32 - 15, DECO);
|
||||
y += 10;
|
||||
}
|
||||
|
||||
// 4-pixel border
|
||||
let bw = 4i32;
|
||||
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
|
||||
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
|
||||
cv
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backgrounds (120×168 textured patterns)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// bg_0 – dark green felt: subtle grid of faint lines giving a woven texture
|
||||
fn make_bg_0() -> Canvas {
|
||||
const BASE: [u8; 4] = [0x1A, 0x4D, 0x1A, 0xFF];
|
||||
const WARP: [u8; 4] = [0x22, 0x60, 0x22, 0x90]; // slightly lighter horizontal threads
|
||||
const WEFT: [u8; 4] = [0x15, 0x40, 0x15, 0x90]; // slightly darker vertical threads
|
||||
let mut cv = Canvas::new();
|
||||
cv.fill_solid(BASE);
|
||||
// Horizontal warp lines every 4 px
|
||||
for y in (0..H as i32).step_by(4) {
|
||||
cv.hline(y, 0, W as i32 - 1, WARP);
|
||||
}
|
||||
// Vertical weft lines every 4 px
|
||||
for x in (0..W as i32).step_by(4) {
|
||||
cv.vline(x, 0, H as i32 - 1, WEFT);
|
||||
}
|
||||
cv
|
||||
}
|
||||
|
||||
/// bg_1 – wood brown: horizontal planks with grain lines
|
||||
fn make_bg_1() -> Canvas {
|
||||
const BASE: [u8; 4] = [0x40, 0x2D, 0x1A, 0xFF];
|
||||
const PLANK_EDGE: [u8; 4] = [0x28, 0x1A, 0x0A, 0xFF]; // dark plank separator
|
||||
const GRAIN: [u8; 4] = [0x55, 0x3D, 0x28, 0xA0]; // lighter grain streak
|
||||
let mut cv = Canvas::new();
|
||||
cv.fill_solid(BASE);
|
||||
// Horizontal plank edges every 24 px
|
||||
for y in (0..H as i32).step_by(24) {
|
||||
cv.hline(y, 0, W as i32 - 1, PLANK_EDGE);
|
||||
cv.hline(y + 1, 0, W as i32 - 1, PLANK_EDGE);
|
||||
}
|
||||
// Grain lines within each plank (every 3 px between plank edges)
|
||||
for y in (0..H as i32).step_by(3) {
|
||||
// Skip the plank edge rows
|
||||
if y % 24 < 2 { continue; }
|
||||
cv.hline(y, 2, W as i32 - 3, GRAIN);
|
||||
}
|
||||
cv
|
||||
}
|
||||
|
||||
/// bg_2 – navy: star-field dots scattered in a regular grid
|
||||
fn make_bg_2() -> Canvas {
|
||||
const BASE: [u8; 4] = [0x0D, 0x14, 0x38, 0xFF];
|
||||
const STAR_A: [u8; 4] = [0xCC, 0xDD, 0xFF, 0xD0];
|
||||
const STAR_B: [u8; 4] = [0x80, 0xA0, 0xDD, 0x80];
|
||||
let mut cv = Canvas::new();
|
||||
cv.fill_solid(BASE);
|
||||
// Bright small stars on a staggered grid
|
||||
let gx = 14.0f32;
|
||||
let gy = 16.0f32;
|
||||
let mut row = 0u32;
|
||||
let mut cy = gy * 0.5;
|
||||
while cy < H as f32 {
|
||||
let offset = if row.is_multiple_of(2) { 0.0 } else { gx * 0.5 };
|
||||
let mut cx = gx * 0.5 + offset;
|
||||
while cx < W as f32 {
|
||||
// alternate bright/dim to give depth
|
||||
let c = if (row + (cx / gx) as u32).is_multiple_of(3) { STAR_A } else { STAR_B };
|
||||
cv.circle(cx, cy, 1.0, c);
|
||||
cx += gx;
|
||||
}
|
||||
cy += gy;
|
||||
row += 1;
|
||||
}
|
||||
cv
|
||||
}
|
||||
|
||||
/// bg_3 – burgundy: diagonal tile pattern
|
||||
fn make_bg_3() -> Canvas {
|
||||
const BASE: [u8; 4] = [0x4D, 0x0D, 0x14, 0xFF];
|
||||
const LINE: [u8; 4] = [0x77, 0x22, 0x30, 0xB0];
|
||||
const ACCENT: [u8; 4] = [0x99, 0x33, 0x44, 0x80];
|
||||
let mut cv = Canvas::new();
|
||||
cv.fill_solid(BASE);
|
||||
// Diagonal lines in one direction every 16 px
|
||||
let spacing = 16i32;
|
||||
for k in (-(H as i32)..W as i32 + H as i32).step_by(spacing as usize) {
|
||||
for t in 0..W as i32 {
|
||||
let y = t + k;
|
||||
cv.set(t, y, LINE);
|
||||
}
|
||||
}
|
||||
// Diagonal lines in the other direction every 16 px (accent colour)
|
||||
for k in (0..W as i32 + H as i32).step_by(spacing as usize) {
|
||||
for t in 0..W as i32 {
|
||||
let y = k - t;
|
||||
cv.set(t, y, ACCENT);
|
||||
}
|
||||
}
|
||||
cv
|
||||
}
|
||||
|
||||
/// bg_4 – charcoal: subtle checkerboard texture
|
||||
fn make_bg_4() -> Canvas {
|
||||
const DARK: [u8; 4] = [0x1F, 0x1F, 0x24, 0xFF];
|
||||
const LIGHT: [u8; 4] = [0x2C, 0x2C, 0x33, 0xFF];
|
||||
let mut cv = Canvas::new();
|
||||
cv.fill_solid(DARK);
|
||||
// 4×4 checkerboard
|
||||
for y in 0..H as i32 {
|
||||
for x in 0..W as i32 {
|
||||
if ((x / 4) + (y / 4)) % 2 == 0 {
|
||||
cv.set(x, y, LIGHT);
|
||||
}
|
||||
}
|
||||
}
|
||||
cv
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn workspace_root() -> std::path::PathBuf {
|
||||
let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
crate_dir.parent().unwrap().to_path_buf()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = workspace_root();
|
||||
std::fs::create_dir_all(root.join("assets/cards/faces")).unwrap();
|
||||
std::fs::create_dir_all(root.join("assets/cards/backs")).unwrap();
|
||||
std::fs::create_dir_all(root.join("assets/backgrounds")).unwrap();
|
||||
|
||||
// Load font from disk (dev tool — runtime load is fine here).
|
||||
let font_path = root.join("assets/fonts/main.ttf");
|
||||
let font_bytes = std::fs::read(&font_path)
|
||||
.unwrap_or_else(|e| panic!("failed to read {}: {e}", font_path.display()));
|
||||
let font = FontRef::try_from_slice(&font_bytes)
|
||||
.expect("failed to parse assets/fonts/main.ttf");
|
||||
|
||||
// 52 card faces
|
||||
let suits = ["c", "d", "h", "s"];
|
||||
let ranks = ["a","2","3","4","5","6","7","8","9","10","j","q","k"];
|
||||
for suit in 0u8..4 {
|
||||
for rank in 0u8..13 {
|
||||
let cv = make_card_face(&font, rank, suit);
|
||||
let name = format!("{}_{}.png", ranks[rank as usize], suits[suit as usize]);
|
||||
let path = root.join("assets/cards/faces").join(&name);
|
||||
save_card_png(&path, &cv);
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
// Card backs
|
||||
for (i, cv) in [make_back_0(), make_back_1(), make_back_2(), make_back_3(), make_back_4()].iter().enumerate() {
|
||||
let path = root.join(format!("assets/cards/backs/back_{i}.png"));
|
||||
save_card_png(&path, cv);
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
|
||||
// Backgrounds
|
||||
for (i, cv) in [make_bg_0(), make_bg_1(), make_bg_2(), make_bg_3(), make_bg_4()].iter().enumerate() {
|
||||
let path = root.join(format!("assets/backgrounds/bg_{i}.png"));
|
||||
save_card_png(&path, cv);
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
|
||||
println!("gen_art: all assets generated successfully.");
|
||||
}
|
||||
@@ -16,16 +16,17 @@ fn main() -> io::Result<()> {
|
||||
let out_dir = workspace_root().join("assets").join("audio");
|
||||
fs::create_dir_all(&out_dir)?;
|
||||
|
||||
let effects: [(&str, Generator); 5] = [
|
||||
let effects: [(&str, Generator); 6] = [
|
||||
("card_flip.wav", card_flip),
|
||||
("card_place.wav", card_place),
|
||||
("card_deal.wav", card_deal),
|
||||
("card_invalid.wav", card_invalid),
|
||||
("win_fanfare.wav", win_fanfare),
|
||||
("ambient_loop.wav", ambient_loop),
|
||||
];
|
||||
|
||||
for (name, gen) in &effects {
|
||||
let samples = gen();
|
||||
for (name, make) in &effects {
|
||||
let samples = make();
|
||||
let path = out_dir.join(name);
|
||||
write_wav_mono_pcm16(&path, SAMPLE_RATE, &samples)?;
|
||||
println!("wrote {} ({} samples)", path.display(), samples.len());
|
||||
@@ -169,6 +170,64 @@ fn win_fanfare() -> Vec<i16> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz
|
||||
/// mono 16-bit PCM).
|
||||
///
|
||||
/// Design:
|
||||
/// - Fundamental: 55 Hz (low A) sine wave.
|
||||
/// - Harmonics: 110 Hz at 40% and 165 Hz at 20% for warmth.
|
||||
/// - Amplitude LFO at 0.1 Hz creates a slow breath / pad swell.
|
||||
/// - The loop length is chosen so both the fundamental and LFO complete an
|
||||
/// integer number of cycles — guaranteeing a phase-continuous seamless loop.
|
||||
/// - Peak amplitude is kept low (0.18) so it sits quietly under SFX.
|
||||
fn ambient_loop() -> Vec<i16> {
|
||||
use std::f32::consts::PI;
|
||||
|
||||
// LFO period = 10 s; fundamental period ≈ 18.18 ms.
|
||||
// We want a loop that is an exact integer multiple of both, so both
|
||||
// complete a whole number of cycles with no phase discontinuity.
|
||||
//
|
||||
// LCM approach: fundamental @ 55 Hz repeats every 1/55 s. The LFO @ 0.1 Hz
|
||||
// repeats every 10 s. 10 s is already a multiple of 1/55 s (10 * 55 = 550
|
||||
// cycles), so a 10-second buffer loops perfectly. We halve it to 5 s for
|
||||
// a smaller file — 5 * 55 = 275 (integer), 5 * 0.1 = 0.5 (half-cycle of
|
||||
// LFO). To keep a full LFO cycle we use 10 s but write only the first 5 s
|
||||
// of the waveform, which is within the 4–8 s budget and still a seamless
|
||||
// loop because the LFO amplitude is symmetric about its midpoint at t=5 s.
|
||||
//
|
||||
// Simpler explanation: at exactly 5 s, both the 55 Hz tone and a slow
|
||||
// 0.2 Hz (period=5 s) breath LFO complete an integer number of cycles.
|
||||
// We use 0.2 Hz for the LFO instead of 0.1 Hz so the full envelope fits
|
||||
// in one loop period.
|
||||
let lfo_freq = 0.2_f32; // 1 full LFO cycle per 5-second loop
|
||||
let loop_seconds = 1.0 / lfo_freq; // = 5.0 s
|
||||
let n = (loop_seconds * SAMPLE_RATE as f32) as usize;
|
||||
|
||||
let f0 = 55.0_f32; // fundamental (Hz)
|
||||
let f1 = 110.0_f32; // 2nd harmonic
|
||||
let f2 = 165.0_f32; // 3rd harmonic
|
||||
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let t = i as f32 / SAMPLE_RATE as f32;
|
||||
|
||||
// LFO: smoothly oscillates between 0.4 and 1.0 amplitude.
|
||||
// Using (1 - cos) / 2 instead of sin so the loop starts and ends at
|
||||
// the same LFO phase (0.0 → both sin and cos are fully periodic).
|
||||
let lfo = 0.7 + 0.3 * (2.0 * PI * lfo_freq * t).cos();
|
||||
|
||||
// Layered harmonics
|
||||
let tone = (2.0 * PI * f0 * t).sin()
|
||||
+ 0.4 * (2.0 * PI * f1 * t).sin()
|
||||
+ 0.2 * (2.0 * PI * f2 * t).sin();
|
||||
|
||||
// Normalise the layered sum: max raw peak ≈ 1.6; keep final peak ≤ 0.18
|
||||
let sample = tone / 1.6 * lfo * 0.18;
|
||||
out.push(quantize(sample));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal WAV writer (mono 16-bit PCM)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -289,11 +289,10 @@ impl GameState {
|
||||
.ok_or(MoveError::InvalidSource)?
|
||||
.cards
|
||||
.last_mut()
|
||||
&& !top.face_up
|
||||
{
|
||||
if !top.face_up {
|
||||
top.face_up = true;
|
||||
}
|
||||
}
|
||||
|
||||
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
|
||||
|
||||
|
||||
@@ -13,6 +13,6 @@ chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
keyring = { workspace = true }
|
||||
keyring-core = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -8,9 +8,15 @@
|
||||
//! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting
|
||||
//! the user to log in again.
|
||||
//!
|
||||
//! Before calling any function in this module the application must initialise
|
||||
//! the default keyring store exactly once at startup by calling
|
||||
//! `keyring::use_native_store` (e.g. in `solitaire_app::main` before building
|
||||
//! the Bevy `App`). If no default store is set, all operations in this module
|
||||
//! will return [`TokenError::KeychainUnavailable`].
|
||||
//!
|
||||
//! # Note: no unit tests — requires live OS keychain.
|
||||
|
||||
use keyring::Entry;
|
||||
use keyring_core::Entry;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur when reading or writing tokens in the OS keychain.
|
||||
@@ -30,12 +36,13 @@ pub enum TokenError {
|
||||
/// Service name used to namespace all keychain entries for this application.
|
||||
const SERVICE: &str = "solitaire_quest_server";
|
||||
|
||||
/// Map a `keyring::Error` to the appropriate `TokenError`.
|
||||
fn map_keyring_err(err: keyring::Error, username: &str) -> TokenError {
|
||||
/// Map a `keyring_core::Error` to the appropriate `TokenError`.
|
||||
fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
|
||||
let msg = err.to_string();
|
||||
match err {
|
||||
keyring::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
|
||||
keyring::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
||||
keyring_core::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
|
||||
keyring_core::Error::NoDefaultStore => TokenError::KeychainUnavailable(msg),
|
||||
keyring_core::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
||||
_ => TokenError::Keyring(msg),
|
||||
}
|
||||
}
|
||||
@@ -88,17 +95,17 @@ pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||
match Entry::new(SERVICE, &format!("{username}_access"))
|
||||
.map_err(|e| map_keyring_err(e, username))?
|
||||
.delete_password()
|
||||
.delete_credential()
|
||||
{
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
||||
Ok(()) | Err(keyring_core::Error::NoEntry) => {}
|
||||
Err(e) => return Err(map_keyring_err(e, username)),
|
||||
}
|
||||
|
||||
match Entry::new(SERVICE, &format!("{username}_refresh"))
|
||||
.map_err(|e| map_keyring_err(e, username))?
|
||||
.delete_password()
|
||||
.delete_credential()
|
||||
{
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
||||
Ok(()) | Err(keyring_core::Error::NoEntry) => {}
|
||||
Err(e) => return Err(map_keyring_err(e, username)),
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ pub enum Theme {
|
||||
|
||||
/// Which sync backend the player has configured.
|
||||
///
|
||||
/// JWT tokens for `SolitaireServer` are stored in the OS keychain via
|
||||
/// `Local` keeps all progress on-device. `SolitaireServer` syncs via the
|
||||
/// self-hosted server. JWT tokens are stored in the OS keychain via
|
||||
/// `solitaire_data::auth_tokens` — **never** in this struct.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub enum SyncBackend {
|
||||
@@ -57,10 +58,7 @@ pub enum SyncBackend {
|
||||
username: String,
|
||||
// JWT tokens are stored in the OS keychain — not here.
|
||||
},
|
||||
/// Google Play Games Services (Android only). Selecting this on non-Android
|
||||
/// platforms silently falls back to `Local` at runtime.
|
||||
#[serde(rename = "google_play_games")]
|
||||
GooglePlayGames,
|
||||
|
||||
}
|
||||
|
||||
/// Persistent user settings.
|
||||
|
||||
@@ -364,6 +364,10 @@ impl SyncProvider for SolitaireServerClient {
|
||||
|
||||
/// Deserialize a pull response body as [`SyncResponse`] and return its
|
||||
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
|
||||
///
|
||||
/// Only HTTP 401 (Unauthorized) and 403 (Forbidden) are treated as
|
||||
/// authentication errors. All other non-2xx statuses (5xx, 429, etc.) are
|
||||
/// classified as network/transport errors so the UI shows the right message.
|
||||
async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncError> {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
@@ -372,8 +376,12 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
|
||||
.await
|
||||
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||
Ok(sync_resp.merged)
|
||||
} else {
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED
|
||||
|| status == reqwest::StatusCode::FORBIDDEN
|
||||
{
|
||||
Err(SyncError::Auth(format!("server returned {status}")))
|
||||
} else {
|
||||
Err(SyncError::Network(format!("server returned {status}")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,14 +399,22 @@ async fn extract_leaderboard_body(resp: reqwest::Response) -> Result<Vec<Leaderb
|
||||
|
||||
/// Deserialize a push response body as [`SyncResponse`], or map non-200
|
||||
/// statuses to the appropriate [`SyncError`].
|
||||
///
|
||||
/// Only HTTP 401 (Unauthorized) and 403 (Forbidden) are treated as
|
||||
/// authentication errors. All other non-2xx statuses (5xx, 429, etc.) are
|
||||
/// classified as network/transport errors so the UI shows the right message.
|
||||
async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, SyncError> {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::Serialization(e.to_string()))
|
||||
} else {
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED
|
||||
|| status == reqwest::StatusCode::FORBIDDEN
|
||||
{
|
||||
Err(SyncError::Auth(format!("server returned {status}")))
|
||||
} else {
|
||||
Err(SyncError::Network(format!("server returned {status}")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,19 +428,12 @@ async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, Sync
|
||||
/// This is the **one** place in the codebase that matches on [`SyncBackend`]
|
||||
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
|
||||
/// and remains backend-agnostic.
|
||||
///
|
||||
/// `GooglePlayGames` is Android-only; on desktop it silently falls back to
|
||||
/// [`LocalOnlyProvider`].
|
||||
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
|
||||
match backend {
|
||||
SyncBackend::Local => Box::new(LocalOnlyProvider),
|
||||
SyncBackend::SolitaireServer { url, username } => {
|
||||
Box::new(SolitaireServerClient::new(url.clone(), username.clone()))
|
||||
}
|
||||
SyncBackend::GooglePlayGames => {
|
||||
// GPGS is Android-only; fall back to no-op on desktop.
|
||||
Box::new(LocalOnlyProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,12 +479,6 @@ mod tests {
|
||||
assert_eq!(provider.backend_name(), "local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_gpgs_falls_back_to_local() {
|
||||
let provider = provider_for_backend(&SyncBackend::GooglePlayGames);
|
||||
assert_eq!(provider.backend_name(), "local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_server_returns_server_client() {
|
||||
let provider = provider_for_backend(&SyncBackend::SolitaireServer {
|
||||
|
||||
@@ -176,22 +176,18 @@ fn evaluate_on_win(
|
||||
unlocks.write(AchievementUnlockedEvent(record.clone()));
|
||||
}
|
||||
|
||||
if achievements_changed {
|
||||
if let Some(target) = &path.0 {
|
||||
if let Err(e) = save_achievements_to(target, &achievements.0) {
|
||||
if achievements_changed
|
||||
&& let Some(target) = &path.0
|
||||
&& let Err(e) = save_achievements_to(target, &achievements.0) {
|
||||
warn!("failed to save achievements: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if progress_changed {
|
||||
if let Some(target) = &progress_path.0 {
|
||||
if let Err(e) = save_progress_to(target, &progress.0) {
|
||||
if progress_changed
|
||||
&& let Some(target) = &progress_path.0
|
||||
&& let Err(e) = save_progress_to(target, &progress.0) {
|
||||
warn!("failed to save progress after reward: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: resolve an achievement ID to its human-readable name.
|
||||
/// Used by the toast renderer in `animation_plugin`.
|
||||
|
||||
@@ -274,9 +274,8 @@ fn handle_win_cascade(
|
||||
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
|
||||
spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS);
|
||||
|
||||
let speed = settings.as_ref().map(|s| s.0.animation_speed.clone());
|
||||
let step = speed.clone().map(cascade_step_secs).unwrap_or(CASCADE_STAGGER_NORMAL);
|
||||
let duration = speed.map(cascade_duration_secs).unwrap_or(CASCADE_DURATION_NORMAL);
|
||||
let step = settings.as_ref().map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed.clone()));
|
||||
let duration = settings.as_ref().map_or(CASCADE_DURATION_NORMAL, |s| cascade_duration_secs(s.0.animation_speed.clone()));
|
||||
|
||||
for (i, (entity, transform)) in cards.iter().enumerate() {
|
||||
commands.entity(entity).insert(CardAnim {
|
||||
@@ -473,14 +472,13 @@ fn drive_toast_display(
|
||||
}
|
||||
|
||||
// If no active toast and the queue has messages, show the next one.
|
||||
if active.entity.is_none() {
|
||||
if let Some(message) = queue.0.pop_front() {
|
||||
if active.entity.is_none()
|
||||
&& let Some(message) = queue.0.pop_front() {
|
||||
let entity = spawn_queued_toast(&mut commands, message);
|
||||
active.entity = Some(entity);
|
||||
active.timer = QUEUED_TOAST_SECS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a centered top-of-screen `ToastEntity` for the queued toast system.
|
||||
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
||||
|
||||
@@ -11,9 +11,8 @@
|
||||
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
||||
//! | `GameWonEvent` | `win_fanfare.wav` |
|
||||
//!
|
||||
//! An ambient loop is started at plugin startup using `card_flip.wav` at very
|
||||
//! low volume (0.05 amplitude) routed through `music_track` as a placeholder
|
||||
//! until a dedicated ambient track is available.
|
||||
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
|
||||
//! low volume (0.05 amplitude) routed through `music_track`.
|
||||
//!
|
||||
//! If the audio device cannot be opened (e.g. a headless CI machine or a
|
||||
//! Linux box without a running PulseAudio/Pipewire session), the plugin
|
||||
@@ -121,8 +120,8 @@ impl Plugin for AudioPlugin {
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
// Start the ambient loop placeholder (card_flip.wav looped at very low
|
||||
// volume through music_track).
|
||||
// Start the ambient loop (ambient_loop.wav looped at very low volume
|
||||
// through music_track).
|
||||
let ambient_handle =
|
||||
start_ambient_loop(manager.as_mut(), library.as_ref(), &mut music_track);
|
||||
|
||||
@@ -190,20 +189,27 @@ fn decode(bytes: &'static [u8]) -> Option<StaticSoundData> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the ambient music loop placeholder (`card_flip.wav` looped at very
|
||||
/// low volume) routed through `music_track`. Returns the handle so it can be
|
||||
/// stored in `AudioState` for future pause/stop control.
|
||||
/// Decodes the embedded `ambient_loop.wav` and starts it as a seamlessly
|
||||
/// looping ambient track routed through `music_track`. Returns the handle so
|
||||
/// it can be stored in `AudioState` for future pause/stop control.
|
||||
///
|
||||
/// Returns `None` when audio is unavailable or the library failed to load.
|
||||
/// Returns `None` when audio is unavailable or the WAV fails to decode.
|
||||
fn start_ambient_loop(
|
||||
manager: Option<&mut AudioManager<DefaultBackend>>,
|
||||
library: Option<&SoundLibrary>,
|
||||
_library: Option<&SoundLibrary>,
|
||||
music_track: &mut Option<TrackHandle>,
|
||||
) -> Option<StaticSoundHandle> {
|
||||
let manager = manager?;
|
||||
let lib = library?;
|
||||
|
||||
let mut data = lib.flip.clone();
|
||||
let ambient_bytes: &'static [u8] =
|
||||
include_bytes!("../../assets/audio/ambient_loop.wav");
|
||||
let mut data = match StaticSoundData::from_cursor(Cursor::new(ambient_bytes.to_vec())) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!("failed to decode ambient_loop.wav: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
data.settings.loop_region = Some(Region::default());
|
||||
data.settings.volume = Value::Fixed(amplitude_to_decibels(AMBIENT_VOLUME as f32));
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ impl Default for AnimationTuning {
|
||||
/// running under `MinimalPlugins` (which does not register the touch subsystem).
|
||||
pub(crate) fn update_input_platform(
|
||||
touches: Option<Res<Touches>>,
|
||||
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
||||
mouse_buttons: Option<Res<ButtonInput<MouseButton>>>,
|
||||
mut tuning: ResMut<AnimationTuning>,
|
||||
) {
|
||||
let touch_active = touches.as_ref().is_some_and(|t| {
|
||||
@@ -157,8 +157,9 @@ pub(crate) fn update_input_platform(
|
||||
|| t.iter_just_released().next().is_some()
|
||||
});
|
||||
|
||||
let mouse_active = mouse_buttons.get_just_pressed().next().is_some()
|
||||
|| mouse_buttons.get_pressed().next().is_some();
|
||||
let mouse_active = mouse_buttons.as_ref().is_some_and(|mb| {
|
||||
mb.get_just_pressed().next().is_some() || mb.get_pressed().next().is_some()
|
||||
});
|
||||
|
||||
if touch_active && tuning.platform != InputPlatform::Touch {
|
||||
*tuning = AnimationTuning::mobile();
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
//! Procedural card rendering.
|
||||
//! PNG-based card rendering.
|
||||
//!
|
||||
//! Each card is a parent entity with a coloured body `Sprite` and a child
|
||||
//! `Text2d` showing rank+suit. Entities are synced with `GameStateResource`
|
||||
//! on every `StateChangedEvent`: missing cards are spawned, present cards
|
||||
//! are repositioned/updated in place, and stale cards are despawned.
|
||||
//! Card entities are synced with [`GameStateResource`] on every
|
||||
//! [`StateChangedEvent`]: missing cards are spawned, present cards are
|
||||
//! repositioned/updated in place, and stale cards are despawned.
|
||||
//!
|
||||
//! Phase 3 uses ASCII rank letters ("A", "2"…"10", "J", "Q", "K") and ASCII
|
||||
//! suit letters ("C", "D", "H", "S") so rendering does not depend on the
|
||||
//! bundled font carrying Unicode suit glyphs. When real card art lands in a
|
||||
//! later phase, this plugin is replaced — the `CardEntity` marker and the
|
||||
//! "sync on StateChangedEvent" contract stay the same.
|
||||
//! When [`CardImageSet`] is available, each face-up card renders its own
|
||||
//! 120×168 px `Handle<Image>` chosen from the 52 per-card PNGs loaded from
|
||||
//! `assets/cards/faces/{rank}_{suit}.png`. A solid-colour `Sprite` with a
|
||||
//! `Text2d` rank+suit overlay is used as a fallback when `CardImageSet` is
|
||||
//! absent (e.g. in tests running under `MinimalPlugins`).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -47,6 +46,22 @@ pub const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
|
||||
pub const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15);
|
||||
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
|
||||
|
||||
/// Pre-loaded [`Handle<Image>`]s for card face and back PNG textures.
|
||||
///
|
||||
/// Loaded once at startup by [`load_card_images`]. When this resource is
|
||||
/// present, card sprites use the PNG artwork; otherwise they fall back to
|
||||
/// solid-colour sprites (used in tests with `MinimalPlugins`).
|
||||
#[derive(Resource)]
|
||||
pub struct CardImageSet {
|
||||
/// Per-card face images indexed by `[suit][rank]`.
|
||||
///
|
||||
/// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
|
||||
/// Rank order: Ace=0, Two=1 … King=12.
|
||||
pub faces: [[Handle<Image>; 13]; 4],
|
||||
/// One handle per unlockable card-back design (indices 0–4).
|
||||
pub backs: [Handle<Image>; 5],
|
||||
}
|
||||
|
||||
/// Alternative face tint for red-suit cards in color-blind mode — a subtle
|
||||
/// blue wash that distinguishes them from black-suit cards without colour alone.
|
||||
const CARD_FACE_COLOUR_RED_CBM: Color = Color::srgba(0.85, 0.92, 1.0, 1.0);
|
||||
@@ -160,6 +175,7 @@ impl Plugin for CardPlugin {
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<CardFlippedEvent>()
|
||||
.add_message::<CardFaceRevealedEvent>()
|
||||
.add_systems(Startup, load_card_images)
|
||||
.add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup))
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -180,6 +196,172 @@ impl Plugin for CardPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads card face and back PNGs at startup and inserts [`CardImageSet`].
|
||||
///
|
||||
/// The PNGs are embedded at compile time via `include_bytes!()`. Missing
|
||||
/// files are compile errors, not runtime panics. Under `MinimalPlugins`
|
||||
/// (tests) this system is still registered but `Assets<Image>` is unavailable,
|
||||
/// so it does nothing and the plugin falls back to solid-colour sprites.
|
||||
fn load_card_images(images: Option<ResMut<Assets<Image>>>, mut commands: Commands) {
|
||||
let Some(mut images) = images else {
|
||||
return;
|
||||
};
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::image::{CompressedImageFormats, ImageSampler, ImageType};
|
||||
|
||||
let load = |bytes: &[u8]| {
|
||||
Image::from_buffer(
|
||||
bytes,
|
||||
ImageType::Extension("png"),
|
||||
CompressedImageFormats::NONE,
|
||||
true,
|
||||
ImageSampler::default(),
|
||||
RenderAssetUsages::RENDER_WORLD,
|
||||
)
|
||||
.expect("valid card PNG")
|
||||
};
|
||||
|
||||
// 52 face images: faces[suit][rank]
|
||||
// Suit: Clubs=0, Diamonds=1, Hearts=2, Spades=3
|
||||
// Rank: Ace=0 … King=12
|
||||
const FACE_BYTES: [[&[u8]; 13]; 4] = [
|
||||
// Clubs
|
||||
[
|
||||
include_bytes!("../../assets/cards/faces/a_c.png"),
|
||||
include_bytes!("../../assets/cards/faces/2_c.png"),
|
||||
include_bytes!("../../assets/cards/faces/3_c.png"),
|
||||
include_bytes!("../../assets/cards/faces/4_c.png"),
|
||||
include_bytes!("../../assets/cards/faces/5_c.png"),
|
||||
include_bytes!("../../assets/cards/faces/6_c.png"),
|
||||
include_bytes!("../../assets/cards/faces/7_c.png"),
|
||||
include_bytes!("../../assets/cards/faces/8_c.png"),
|
||||
include_bytes!("../../assets/cards/faces/9_c.png"),
|
||||
include_bytes!("../../assets/cards/faces/10_c.png"),
|
||||
include_bytes!("../../assets/cards/faces/j_c.png"),
|
||||
include_bytes!("../../assets/cards/faces/q_c.png"),
|
||||
include_bytes!("../../assets/cards/faces/k_c.png"),
|
||||
],
|
||||
// Diamonds
|
||||
[
|
||||
include_bytes!("../../assets/cards/faces/a_d.png"),
|
||||
include_bytes!("../../assets/cards/faces/2_d.png"),
|
||||
include_bytes!("../../assets/cards/faces/3_d.png"),
|
||||
include_bytes!("../../assets/cards/faces/4_d.png"),
|
||||
include_bytes!("../../assets/cards/faces/5_d.png"),
|
||||
include_bytes!("../../assets/cards/faces/6_d.png"),
|
||||
include_bytes!("../../assets/cards/faces/7_d.png"),
|
||||
include_bytes!("../../assets/cards/faces/8_d.png"),
|
||||
include_bytes!("../../assets/cards/faces/9_d.png"),
|
||||
include_bytes!("../../assets/cards/faces/10_d.png"),
|
||||
include_bytes!("../../assets/cards/faces/j_d.png"),
|
||||
include_bytes!("../../assets/cards/faces/q_d.png"),
|
||||
include_bytes!("../../assets/cards/faces/k_d.png"),
|
||||
],
|
||||
// Hearts
|
||||
[
|
||||
include_bytes!("../../assets/cards/faces/a_h.png"),
|
||||
include_bytes!("../../assets/cards/faces/2_h.png"),
|
||||
include_bytes!("../../assets/cards/faces/3_h.png"),
|
||||
include_bytes!("../../assets/cards/faces/4_h.png"),
|
||||
include_bytes!("../../assets/cards/faces/5_h.png"),
|
||||
include_bytes!("../../assets/cards/faces/6_h.png"),
|
||||
include_bytes!("../../assets/cards/faces/7_h.png"),
|
||||
include_bytes!("../../assets/cards/faces/8_h.png"),
|
||||
include_bytes!("../../assets/cards/faces/9_h.png"),
|
||||
include_bytes!("../../assets/cards/faces/10_h.png"),
|
||||
include_bytes!("../../assets/cards/faces/j_h.png"),
|
||||
include_bytes!("../../assets/cards/faces/q_h.png"),
|
||||
include_bytes!("../../assets/cards/faces/k_h.png"),
|
||||
],
|
||||
// Spades
|
||||
[
|
||||
include_bytes!("../../assets/cards/faces/a_s.png"),
|
||||
include_bytes!("../../assets/cards/faces/2_s.png"),
|
||||
include_bytes!("../../assets/cards/faces/3_s.png"),
|
||||
include_bytes!("../../assets/cards/faces/4_s.png"),
|
||||
include_bytes!("../../assets/cards/faces/5_s.png"),
|
||||
include_bytes!("../../assets/cards/faces/6_s.png"),
|
||||
include_bytes!("../../assets/cards/faces/7_s.png"),
|
||||
include_bytes!("../../assets/cards/faces/8_s.png"),
|
||||
include_bytes!("../../assets/cards/faces/9_s.png"),
|
||||
include_bytes!("../../assets/cards/faces/10_s.png"),
|
||||
include_bytes!("../../assets/cards/faces/j_s.png"),
|
||||
include_bytes!("../../assets/cards/faces/q_s.png"),
|
||||
include_bytes!("../../assets/cards/faces/k_s.png"),
|
||||
],
|
||||
];
|
||||
|
||||
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|suit| {
|
||||
std::array::from_fn(|rank| images.add(load(FACE_BYTES[suit][rank])))
|
||||
});
|
||||
let backs = [
|
||||
images.add(load(include_bytes!("../../assets/cards/backs/back_0.png"))),
|
||||
images.add(load(include_bytes!("../../assets/cards/backs/back_1.png"))),
|
||||
images.add(load(include_bytes!("../../assets/cards/backs/back_2.png"))),
|
||||
images.add(load(include_bytes!("../../assets/cards/backs/back_3.png"))),
|
||||
images.add(load(include_bytes!("../../assets/cards/backs/back_4.png"))),
|
||||
];
|
||||
commands.insert_resource(CardImageSet { faces, backs });
|
||||
}
|
||||
|
||||
/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
|
||||
/// available and falling back to a solid-colour sprite in tests.
|
||||
fn card_sprite(
|
||||
card: &Card,
|
||||
card_size: Vec2,
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) -> Sprite {
|
||||
if let Some(set) = card_images {
|
||||
let image = if card.face_up {
|
||||
let suit_idx = match card.suit {
|
||||
Suit::Clubs => 0,
|
||||
Suit::Diamonds => 1,
|
||||
Suit::Hearts => 2,
|
||||
Suit::Spades => 3,
|
||||
};
|
||||
let rank_idx = match card.rank {
|
||||
Rank::Ace => 0,
|
||||
Rank::Two => 1,
|
||||
Rank::Three => 2,
|
||||
Rank::Four => 3,
|
||||
Rank::Five => 4,
|
||||
Rank::Six => 5,
|
||||
Rank::Seven => 6,
|
||||
Rank::Eight => 7,
|
||||
Rank::Nine => 8,
|
||||
Rank::Ten => 9,
|
||||
Rank::Jack => 10,
|
||||
Rank::Queen => 11,
|
||||
Rank::King => 12,
|
||||
};
|
||||
set.faces[suit_idx][rank_idx].clone()
|
||||
} else {
|
||||
let idx = selected_back.min(set.backs.len() - 1);
|
||||
set.backs[idx].clone()
|
||||
};
|
||||
Sprite {
|
||||
image,
|
||||
color: Color::WHITE,
|
||||
custom_size: Some(card_size),
|
||||
..default()
|
||||
}
|
||||
} else {
|
||||
let body_colour = if card.face_up {
|
||||
face_colour(card, color_blind)
|
||||
} else {
|
||||
back_colour
|
||||
};
|
||||
Sprite {
|
||||
color: body_colour,
|
||||
custom_size: Some(card_size),
|
||||
..default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When card-back selection changes in Settings, re-render all cards so the
|
||||
/// new back colour is applied immediately (without waiting for a state change).
|
||||
fn resync_cards_on_settings_change(
|
||||
@@ -201,17 +383,18 @@ fn sync_cards_startup(
|
||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
) {
|
||||
if let Some(layout) = layout {
|
||||
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||
let back_colour = settings
|
||||
.as_ref()
|
||||
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
||||
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||
let back_colour = card_back_colour(selected_back);
|
||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities, card_images.as_deref(), selected_back);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sync_cards_on_change(
|
||||
mut events: MessageReader<StateChangedEvent>,
|
||||
commands: Commands,
|
||||
@@ -220,20 +403,21 @@ fn sync_cards_on_change(
|
||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(layout) = layout {
|
||||
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||
let back_colour = settings
|
||||
.as_ref()
|
||||
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
||||
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||
let back_colour = card_back_colour(selected_back);
|
||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities, card_images.as_deref(), selected_back);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sync_cards(
|
||||
mut commands: Commands,
|
||||
game: &GameState,
|
||||
@@ -242,6 +426,8 @@ fn sync_cards(
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
|
||||
@@ -265,18 +451,18 @@ fn sync_cards(
|
||||
match existing.get(&card.id) {
|
||||
Some(&(entity, cur)) => {
|
||||
update_card_entity(
|
||||
&mut commands, entity, &card, position, z, layout,
|
||||
slide_secs, back_colour, color_blind, cur,
|
||||
&mut commands, entity, card, position, z, layout,
|
||||
slide_secs, back_colour, color_blind, cur, card_images, selected_back,
|
||||
)
|
||||
}
|
||||
None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour, color_blind),
|
||||
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an ordered vec of (card, position, z) for every card in the game.
|
||||
fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
||||
let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52);
|
||||
fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Vec2, f32)> {
|
||||
let mut out: Vec<(&'a Card, Vec2, f32)> = Vec::with_capacity(52);
|
||||
let piles = [
|
||||
PileType::Stock,
|
||||
PileType::Waste,
|
||||
@@ -331,7 +517,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
||||
};
|
||||
let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
|
||||
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
|
||||
out.push((card.clone(), pos, z));
|
||||
out.push((card, pos, z));
|
||||
if is_tableau {
|
||||
let step = if card.face_up {
|
||||
TABLEAU_FAN_FRAC
|
||||
@@ -358,25 +544,30 @@ fn face_colour(card: &Card, color_blind: bool) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color, color_blind: bool) {
|
||||
let body_colour = if card.face_up {
|
||||
face_colour(card, color_blind)
|
||||
} else {
|
||||
back_colour
|
||||
};
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_card_entity(
|
||||
commands: &mut Commands,
|
||||
card: &Card,
|
||||
pos: Vec2,
|
||||
z: f32,
|
||||
layout: &Layout,
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) {
|
||||
let sprite = card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
let mut entity = commands.spawn((
|
||||
CardEntity { card_id: card.id },
|
||||
Sprite {
|
||||
color: body_colour,
|
||||
custom_size: Some(layout.card_size),
|
||||
..default()
|
||||
},
|
||||
sprite,
|
||||
Transform::from_xyz(pos.x, pos.y, z),
|
||||
Visibility::default(),
|
||||
))
|
||||
.with_children(|b| {
|
||||
));
|
||||
// When PNG faces are loaded the rank/suit are baked into the image.
|
||||
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
||||
if card_images.is_none() {
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
CardLabel,
|
||||
Text2d::new(label_for(card)),
|
||||
@@ -385,13 +576,12 @@ fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, la
|
||||
..default()
|
||||
},
|
||||
TextColor(text_colour(card)),
|
||||
// Above the card body on z so it doesn't get occluded by the
|
||||
// parent sprite in back-to-front rendering.
|
||||
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||
label_visibility(card),
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn update_card_entity(
|
||||
@@ -405,21 +595,13 @@ fn update_card_entity(
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
cur: Vec3,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) {
|
||||
let body_colour = if card.face_up {
|
||||
face_colour(card, color_blind)
|
||||
} else {
|
||||
back_colour
|
||||
};
|
||||
|
||||
let target = Vec3::new(pos.x, pos.y, z);
|
||||
|
||||
// Always refresh the visual appearance.
|
||||
commands.entity(entity).insert(Sprite {
|
||||
color: body_colour,
|
||||
custom_size: Some(layout.card_size),
|
||||
..default()
|
||||
});
|
||||
commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
|
||||
|
||||
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
||||
@@ -441,9 +623,11 @@ fn update_card_entity(
|
||||
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
||||
}
|
||||
|
||||
// Despawn the old label child and respawn a fresh one, so rank/suit/
|
||||
// colour/visibility all stay in sync with the card's current state.
|
||||
// Despawn any stale children and re-add the label overlay only when
|
||||
// operating in solid-colour mode (no PNG faces). In image mode the
|
||||
// rank/suit are baked into the PNG, so no Text2d overlay is needed.
|
||||
commands.entity(entity).despawn_related::<Children>();
|
||||
if card_images.is_none() {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
CardLabel,
|
||||
@@ -458,6 +642,7 @@ fn update_card_entity(
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn label_for(card: &Card) -> String {
|
||||
let rank = match card.rank {
|
||||
@@ -653,20 +838,24 @@ fn tick_hint_highlight(
|
||||
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
|
||||
game: Res<GameStateResource>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
) {
|
||||
let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||
let use_images = card_images.is_some();
|
||||
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
|
||||
hint.remaining -= time.delta_secs();
|
||||
if hint.remaining <= 0.0 {
|
||||
// Restore normal face-up colour.
|
||||
// Restore the normal sprite colour.
|
||||
// When image-based rendering is active, WHITE is the neutral tint;
|
||||
// otherwise restore the solid colour appropriate to the card state.
|
||||
sprite.color = if use_images {
|
||||
Color::WHITE
|
||||
} else {
|
||||
let is_face_up = game.0.piles.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == card_entity.card_id)
|
||||
.is_some_and(|c| c.face_up);
|
||||
sprite.color = if is_face_up {
|
||||
CARD_FACE_COLOUR
|
||||
} else {
|
||||
card_back_colour(back_idx)
|
||||
if is_face_up { CARD_FACE_COLOUR } else { card_back_colour(back_idx) }
|
||||
};
|
||||
commands
|
||||
.entity(entity)
|
||||
|
||||
@@ -54,11 +54,10 @@ fn advance_on_challenge_win(
|
||||
}
|
||||
let prev = progress.0.challenge_index;
|
||||
progress.0.challenge_index = prev.saturating_add(1);
|
||||
if let Some(target) = &path.0 {
|
||||
if let Err(e) = save_progress_to(target, &progress.0) {
|
||||
if let Some(target) = &path.0
|
||||
&& let Err(e) = save_progress_to(target, &progress.0) {
|
||||
warn!("failed to save progress after challenge advance: {e}");
|
||||
}
|
||||
}
|
||||
// Human-readable level is 1-based (index 0 → "Challenge 1").
|
||||
let level_number = prev.saturating_add(1);
|
||||
toast.write(InfoToastEvent(format!("Challenge {level_number} complete!")));
|
||||
|
||||
@@ -161,27 +161,24 @@ fn handle_daily_completion(
|
||||
continue;
|
||||
}
|
||||
// Enforce server-supplied goal constraints when present.
|
||||
if let Some(target) = daily.target_score {
|
||||
if ev.score < target {
|
||||
if let Some(target) = daily.target_score
|
||||
&& ev.score < target {
|
||||
continue; // score goal not met
|
||||
}
|
||||
}
|
||||
if let Some(max_secs) = daily.max_time_secs {
|
||||
if ev.time_seconds > max_secs {
|
||||
if let Some(max_secs) = daily.max_time_secs
|
||||
&& ev.time_seconds > max_secs {
|
||||
continue; // time limit exceeded
|
||||
}
|
||||
}
|
||||
if !progress.0.record_daily_completion(daily.date) {
|
||||
// Already counted today — no-op.
|
||||
continue;
|
||||
}
|
||||
progress.0.add_xp(DAILY_BONUS_XP);
|
||||
xp_awarded.write(XpAwardedEvent { amount: DAILY_BONUS_XP });
|
||||
if let Some(target) = &path.0 {
|
||||
if let Err(e) = save_progress_to(target, &progress.0) {
|
||||
if let Some(target) = &path.0
|
||||
&& let Err(e) = save_progress_to(target, &progress.0) {
|
||||
warn!("failed to save progress after daily completion: {e}");
|
||||
}
|
||||
}
|
||||
completed.write(DailyChallengeCompletedEvent {
|
||||
date: daily.date,
|
||||
streak: progress.0.daily_challenge_streak,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Register FontPlugin in solitaire_engine/src/lib.rs before use.
|
||||
|
||||
//! Embeds FiraMono-Medium as the project font and exposes it via [`FontResource`].
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Holds the project-wide [`Handle<Font>`] loaded at startup.
|
||||
#[derive(Resource)]
|
||||
pub struct FontResource(pub Handle<Font>);
|
||||
|
||||
/// Loads FiraMono-Medium at startup and inserts [`FontResource`].
|
||||
pub struct FontPlugin;
|
||||
|
||||
impl Plugin for FontPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, load_font);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_font(fonts: Option<ResMut<Assets<Font>>>, mut commands: Commands) {
|
||||
let Some(mut fonts) = fonts else {
|
||||
// Assets<Font> absent (e.g. MinimalPlugins in tests) — insert default.
|
||||
commands.insert_resource(FontResource(Handle::default()));
|
||||
return;
|
||||
};
|
||||
let bytes: &'static [u8] = include_bytes!("../../assets/fonts/main.ttf");
|
||||
match Font::try_from_bytes(bytes.to_vec()) {
|
||||
Ok(font) => {
|
||||
commands.insert_resource(FontResource(fonts.add(font)));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("failed to load main.ttf: {e}; falling back to Bevy default font");
|
||||
commands.insert_resource(FontResource(Handle::default()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,11 +194,10 @@ fn handle_new_game(
|
||||
let mode = ev.mode.unwrap_or(game.0.mode);
|
||||
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
|
||||
// Delete any previously saved in-progress state — this is a fresh game.
|
||||
if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) {
|
||||
if let Err(e) = delete_game_state_at(p) {
|
||||
if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref())
|
||||
&& let Err(e) = delete_game_state_at(p) {
|
||||
warn!("game_state: failed to delete saved game: {e}");
|
||||
}
|
||||
}
|
||||
changed.write(StateChangedEvent);
|
||||
}
|
||||
}
|
||||
@@ -380,14 +379,13 @@ fn handle_move(
|
||||
match game.0.move_cards(ev.from.clone(), ev.to.clone(), ev.count) {
|
||||
Ok(()) => {
|
||||
// Fire flip event if the candidate card is now face-up.
|
||||
if let Some(fid) = flip_candidate_id {
|
||||
if game.0.piles.get(&ev.from)
|
||||
if let Some(fid) = flip_candidate_id
|
||||
&& game.0.piles.get(&ev.from)
|
||||
.and_then(|p| p.cards.last())
|
||||
.is_some_and(|c| c.id == fid && c.face_up)
|
||||
{
|
||||
flipped.write(crate::events::CardFlippedEvent(fid));
|
||||
}
|
||||
}
|
||||
changed.write(StateChangedEvent);
|
||||
if !was_won && game.0.is_won {
|
||||
won.write(GameWonEvent {
|
||||
@@ -395,13 +393,12 @@ fn handle_move(
|
||||
time_seconds: game.0.elapsed_seconds,
|
||||
});
|
||||
// Delete the saved state — a won game should not be resumed.
|
||||
if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) {
|
||||
if let Err(e) = delete_game_state_at(p) {
|
||||
if let Some(p) = path.as_ref().and_then(|r| r.0.as_deref())
|
||||
&& let Err(e) = delete_game_state_at(p) {
|
||||
warn!("game_state: failed to delete on win: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("move rejected {:?} -> {:?} x{}: {e}", ev.from, ev.to, ev.count),
|
||||
}
|
||||
}
|
||||
@@ -468,12 +465,11 @@ pub fn has_legal_moves(game: &GameState) -> bool {
|
||||
// Check foundations.
|
||||
for &suit in &suits {
|
||||
let dest = PileType::Foundation(suit);
|
||||
if let Some(dest_pile) = game.piles.get(&dest) {
|
||||
if can_place_on_foundation(card, dest_pile, suit) {
|
||||
if let Some(dest_pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, dest_pile, suit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check tableau piles.
|
||||
for i in 0..7_usize {
|
||||
@@ -481,13 +477,12 @@ pub fn has_legal_moves(game: &GameState) -> bool {
|
||||
if dest == *from {
|
||||
continue;
|
||||
}
|
||||
if let Some(dest_pile) = game.piles.get(&dest) {
|
||||
if can_place_on_tableau(card, dest_pile) {
|
||||
if let Some(dest_pile) = game.piles.get(&dest)
|
||||
&& can_place_on_tableau(card, dest_pile) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use solitaire_core::pile::PileType;
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::events::InfoToastEvent;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::selection_plugin::SelectionState;
|
||||
@@ -98,9 +99,13 @@ impl Plugin for HudPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_hud(mut commands: Commands) {
|
||||
fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
let white = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
|
||||
let font = TextFont { font_size: 18.0, ..default() };
|
||||
let font = TextFont {
|
||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: 18.0,
|
||||
..default()
|
||||
};
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
@@ -432,8 +437,8 @@ fn update_hud(
|
||||
// Reflects the AutoCompleteState resource; update whenever it changes or game changes.
|
||||
let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
||||
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
|
||||
if ac_changed || game.is_changed() {
|
||||
if let Ok(mut t) = auto_q.single_mut() {
|
||||
if (ac_changed || game.is_changed())
|
||||
&& let Ok(mut t) = auto_q.single_mut() {
|
||||
**t = if ac_active {
|
||||
"AUTO".to_string()
|
||||
} else {
|
||||
@@ -441,7 +446,6 @@ fn update_hud(
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the `HudSelection` text node to show which pile is Tab-selected.
|
||||
///
|
||||
|
||||
@@ -136,6 +136,7 @@ struct CoreKeyboardMessages<'w> {
|
||||
///
|
||||
/// Also resets `forfeit_countdown` whenever U, D, Z, or N are pressed so that
|
||||
/// an in-flight forfeit confirmation is cancelled by any other action.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_keyboard_core(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
@@ -174,8 +175,8 @@ fn handle_keyboard_core(
|
||||
confirm.forfeit_countdown = 0.0;
|
||||
|
||||
// If a Time Attack session is running, cancel it and start a Classic game.
|
||||
if let Some(ref mut session) = time_attack {
|
||||
if session.active {
|
||||
if let Some(ref mut session) = time_attack
|
||||
&& session.active {
|
||||
session.active = false;
|
||||
session.remaining_secs = 0.0;
|
||||
ev.info_toast.write(InfoToastEvent("Time Attack ended".to_string()));
|
||||
@@ -186,7 +187,6 @@ fn handle_keyboard_core(
|
||||
confirm.new_game_countdown = 0.0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
|
||||
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||
@@ -244,6 +244,7 @@ fn handle_keyboard_core(
|
||||
///
|
||||
/// The hint index wraps around once all hints have been cycled through. When no
|
||||
/// moves are available a "No hints available" toast is shown instead.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_keyboard_hint(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
@@ -252,7 +253,7 @@ fn handle_keyboard_hint(
|
||||
mut confirm: ResMut<KeyboardConfirmState>,
|
||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||
mut commands: Commands,
|
||||
card_entities: Query<(Entity, &CardEntity, &Sprite)>,
|
||||
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
mut hint_visual: MessageWriter<HintVisualEvent>,
|
||||
) {
|
||||
@@ -273,7 +274,7 @@ fn handle_keyboard_hint(
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(ref layout_res) = layout else { return };
|
||||
let Some(_layout_res) = layout else { return };
|
||||
|
||||
let hints = all_hints(&g.0);
|
||||
if hints.is_empty() {
|
||||
@@ -308,16 +309,14 @@ fn handle_keyboard_hint(
|
||||
.and_then(|p| p.cards.last().filter(|c| c.face_up))
|
||||
.map(|c| c.id);
|
||||
if let Some(card_id) = top_card_id {
|
||||
for (entity, card_entity, _sprite) in card_entities.iter() {
|
||||
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
|
||||
if card_entity.card_id == card_id {
|
||||
// Tint the card gold without replacing the Sprite (which would
|
||||
// discard the image handle set by CardImageSet).
|
||||
sprite.color = Color::srgba(1.0, 1.0, 0.4, 1.0);
|
||||
commands.entity(entity)
|
||||
.insert(HintHighlight { remaining: 2.0 })
|
||||
.insert(HintHighlightTimer(2.0))
|
||||
.insert(Sprite {
|
||||
color: Color::srgba(1.0, 1.0, 0.4, 1.0),
|
||||
custom_size: Some(layout_res.0.card_size),
|
||||
..default()
|
||||
});
|
||||
.insert(HintHighlightTimer(2.0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -472,7 +471,7 @@ fn handle_stock_click(
|
||||
/// — since the stock cannot be dragged, there is no ambiguity between a tap and
|
||||
/// the start of a drag on this pile. Does nothing while a drag is in progress.
|
||||
fn handle_touch_stock_tap(
|
||||
mut touch_events: EventReader<TouchInput>,
|
||||
mut touch_events: MessageReader<TouchInput>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
@@ -531,7 +530,7 @@ fn start_drag(
|
||||
return;
|
||||
};
|
||||
|
||||
let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index);
|
||||
let bottom_pos = card_position(&game.0, &layout.0, &pile, stack_index);
|
||||
|
||||
// Store as a pending drag. We do NOT elevate the cards yet — the visual
|
||||
// lift happens in follow_drag once the threshold is crossed.
|
||||
@@ -663,8 +662,8 @@ fn end_drag(
|
||||
// the placement is illegal, fire MoveRejectedEvent so AudioPlugin can
|
||||
// play card_invalid.wav.
|
||||
let mut fired = false;
|
||||
if let Some(target) = target {
|
||||
if target != origin {
|
||||
if let Some(target) = target
|
||||
&& target != origin {
|
||||
let bottom_card_id = drag.cards[0];
|
||||
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
|
||||
let ok = match &target {
|
||||
@@ -710,7 +709,6 @@ fn end_drag(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drag.clear();
|
||||
|
||||
@@ -731,7 +729,7 @@ fn end_drag(
|
||||
/// buttons. Records the touch ID in [`DragState`] so only this finger drives
|
||||
/// the drag — other fingers are ignored.
|
||||
fn touch_start_drag(
|
||||
mut touch_events: EventReader<TouchInput>,
|
||||
mut touch_events: MessageReader<TouchInput>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
@@ -760,7 +758,7 @@ fn touch_start_drag(
|
||||
continue;
|
||||
};
|
||||
|
||||
let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index);
|
||||
let bottom_pos = card_position(&game.0, &layout.0, &pile, stack_index);
|
||||
|
||||
drag.cards = card_ids;
|
||||
drag.origin_pile = Some(pile);
|
||||
@@ -841,7 +839,7 @@ fn touch_follow_drag(
|
||||
/// buttons. Uncommitted drags (tap gestures) are cancelled cleanly.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn touch_end_drag(
|
||||
mut touch_events: EventReader<TouchInput>,
|
||||
mut touch_events: MessageReader<TouchInput>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
@@ -894,8 +892,8 @@ fn touch_end_drag(
|
||||
world.and_then(|w| find_drop_target(w, &game.0, &layout.0, &origin));
|
||||
|
||||
let mut fired = false;
|
||||
if let Some(target) = target {
|
||||
if target != origin {
|
||||
if let Some(target) = target
|
||||
&& target != origin {
|
||||
let bottom_card_id = drag.cards[0];
|
||||
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
|
||||
let ok = match &target {
|
||||
@@ -926,7 +924,6 @@ fn touch_end_drag(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drag.clear();
|
||||
changed.write(StateChangedEvent);
|
||||
@@ -971,8 +968,8 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
||||
}
|
||||
|
||||
/// Where a card at `stack_index` in pile `pile` would be rendered.
|
||||
fn card_position(game: &GameState, layout: &Layout, pile: PileType, stack_index: usize) -> Vec2 {
|
||||
let base = layout.pile_positions[&pile];
|
||||
fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
|
||||
let base = layout.pile_positions[pile];
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
||||
Vec2::new(base.x, base.y + fan * (stack_index as f32))
|
||||
@@ -980,7 +977,7 @@ fn card_position(game: &GameState, layout: &Layout, pile: PileType, stack_index:
|
||||
// In Draw-Three mode the top 3 waste cards are fanned in X to match
|
||||
// card_plugin::card_positions(). Hit-testing must use the same offsets
|
||||
// so clicking the visually rightmost (top) card actually registers.
|
||||
let pile_len = game.piles.get(&pile).map_or(0, |p| p.cards.len());
|
||||
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
let visible_start = pile_len.saturating_sub(3);
|
||||
let slot = stack_index.saturating_sub(visible_start) as f32;
|
||||
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
|
||||
@@ -1039,7 +1036,7 @@ fn find_draggable_at(
|
||||
if !card.face_up {
|
||||
continue;
|
||||
}
|
||||
let pos = card_position(game, layout, pile.clone(), i);
|
||||
let pos = card_position(game, layout, &pile, i);
|
||||
if !point_in_rect(cursor, pos, layout.card_size) {
|
||||
continue;
|
||||
}
|
||||
@@ -1134,21 +1131,19 @@ pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> {
|
||||
// Try all four foundations first.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let dest = PileType::Foundation(suit);
|
||||
if let Some(pile) = game.piles.get(&dest) {
|
||||
if can_place_on_foundation(card, pile, suit) {
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, pile, suit) {
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Then try all seven tableau piles.
|
||||
for i in 0..7_usize {
|
||||
let dest = PileType::Tableau(i);
|
||||
if let Some(pile) = game.piles.get(&dest) {
|
||||
if can_place_on_tableau(card, pile) {
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_tableau(card, pile) {
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1169,12 +1164,11 @@ pub fn best_tableau_destination_for_stack(
|
||||
if dest == *from {
|
||||
continue;
|
||||
}
|
||||
if let Some(pile) = game.piles.get(&dest) {
|
||||
if can_place_on_tableau(bottom_card, pile) {
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_tableau(bottom_card, pile) {
|
||||
return Some((dest, stack_count));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1311,8 +1305,8 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
||||
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
|
||||
for &suit in &suits {
|
||||
let dest = PileType::Foundation(suit);
|
||||
if let Some(dest_pile) = game.piles.get(&dest) {
|
||||
if can_place_on_foundation(card, dest_pile, suit) {
|
||||
if let Some(dest_pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, dest_pile, suit) {
|
||||
hints.push((from.clone(), dest, 1));
|
||||
// Each source card can go to at most one foundation suit;
|
||||
// no need to check the remaining three for this card.
|
||||
@@ -1320,7 +1314,6 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 — tableau moves (deduplicated by source pile so we don't
|
||||
// repeat the same source card multiple times for different destinations).
|
||||
@@ -1340,8 +1333,8 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
||||
if dest == *from {
|
||||
continue;
|
||||
}
|
||||
if let Some(dest_pile) = game.piles.get(&dest) {
|
||||
if can_place_on_tableau(card, dest_pile) {
|
||||
if let Some(dest_pile) = game.piles.get(&dest)
|
||||
&& can_place_on_tableau(card, dest_pile) {
|
||||
hints.push((from.clone(), dest, 1));
|
||||
// One tableau destination per source card is enough for the
|
||||
// hint list — the player can see where else a card can go
|
||||
@@ -1350,7 +1343,6 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3 — suggest drawing from the stock when no other hint was found.
|
||||
if hints.is_empty() {
|
||||
@@ -1423,7 +1415,7 @@ mod tests {
|
||||
|
||||
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||
// Its position: base.y + fan * 6.
|
||||
let top_pos = card_position(&game, &layout, PileType::Tableau(6), 6);
|
||||
let top_pos = card_position(&game, &layout, &PileType::Tableau(6), 6);
|
||||
let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
|
||||
assert_eq!(result.0, PileType::Tableau(6));
|
||||
assert_eq!(result.1, 6);
|
||||
@@ -1439,7 +1431,7 @@ mod tests {
|
||||
// position of the bottom face-down card (index 0) should miss —
|
||||
// that card is face-down and the topmost face-up card overlaps at
|
||||
// a different fanned position.
|
||||
let bottom_pos = card_position(&game, &layout, PileType::Tableau(6), 0);
|
||||
let bottom_pos = card_position(&game, &layout, &PileType::Tableau(6), 0);
|
||||
// Shift to avoid accidental overlap with the face-up card above it.
|
||||
let below_bottom = bottom_pos - Vec2::new(0.0, layout.card_size.y * 0.4);
|
||||
let result = find_draggable_at(below_bottom, &game, &layout);
|
||||
@@ -1477,7 +1469,7 @@ mod tests {
|
||||
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||
let queen_center = card_position(&game, &layout, PileType::Tableau(0), 1);
|
||||
let queen_center = card_position(&game, &layout, &PileType::Tableau(0), 1);
|
||||
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, PileType::Tableau(0));
|
||||
@@ -1507,7 +1499,7 @@ mod tests {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
// Both cards in waste sit at the same (x, y). Clicking should pick
|
||||
// the visually top card (id 201), with count = 1.
|
||||
let pos = card_position(&game, &layout, PileType::Waste, 0);
|
||||
let pos = card_position(&game, &layout, &PileType::Waste, 0);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, PileType::Waste);
|
||||
assert_eq!(start, 1);
|
||||
|
||||
@@ -123,8 +123,8 @@ fn toggle_leaderboard_screen(
|
||||
spawn_leaderboard_screen(&mut commands, data.0.as_deref());
|
||||
|
||||
// Start a background fetch if not already in flight.
|
||||
if task_res.0.is_none() {
|
||||
if let Some(p) = provider {
|
||||
if task_res.0.is_none()
|
||||
&& let Some(p) = provider {
|
||||
let provider = p.0.clone();
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||
provider.fetch_leaderboard().await.map_err(|e| e.to_string())
|
||||
@@ -132,7 +132,6 @@ fn toggle_leaderboard_screen(
|
||||
task_res.0 = Some(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the background fetch task; store results when complete.
|
||||
fn poll_leaderboard_fetch(
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod animation_plugin;
|
||||
pub mod auto_complete_plugin;
|
||||
pub mod audio_plugin;
|
||||
pub mod card_plugin;
|
||||
pub mod font_plugin;
|
||||
pub mod feedback_anim_plugin;
|
||||
pub mod challenge_plugin;
|
||||
pub mod cursor_plugin;
|
||||
@@ -59,9 +60,10 @@ pub use feedback_anim_plugin::{
|
||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||
pub use card_plugin::{
|
||||
CardEntity, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer, RightClickHighlight,
|
||||
RightClickHighlightTimer,
|
||||
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
||||
RightClickHighlight, RightClickHighlightTimer,
|
||||
};
|
||||
pub use font_plugin::{FontPlugin, FontResource};
|
||||
pub use cursor_plugin::CursorPlugin;
|
||||
pub use events::{
|
||||
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||
|
||||
@@ -103,13 +103,12 @@ fn toggle_pause(
|
||||
// If a drag is in progress, cancel it instead of opening the pause overlay.
|
||||
// Clearing DragState and emitting StateChangedEvent snaps the dragged cards
|
||||
// back to their resting positions exactly as a rejected drop does.
|
||||
if let Some(ref mut d) = drag {
|
||||
if !d.is_idle() {
|
||||
if let Some(ref mut d) = drag
|
||||
&& !d.is_idle() {
|
||||
d.clear();
|
||||
changed.write(StateChangedEvent);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
paused.0 = false;
|
||||
@@ -122,15 +121,13 @@ fn toggle_pause(
|
||||
paused.0 = true;
|
||||
// Persist the current game state whenever the player opens the pause
|
||||
// overlay so an OS-level kill still leaves a resumable save.
|
||||
if let (Some(g), Some(p)) = (game, path) {
|
||||
if let Some(disk_path) = p.0.as_deref() {
|
||||
if let Err(e) = save_game_state_to(disk_path, &g.0) {
|
||||
if let (Some(g), Some(p)) = (game, path)
|
||||
&& let Some(disk_path) = p.0.as_deref()
|
||||
&& let Err(e) = save_game_state_to(disk_path, &g.0) {
|
||||
warn!("game_state: failed to save on pause: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the draw-mode toggle button on the pause overlay.
|
||||
///
|
||||
@@ -155,13 +152,11 @@ fn handle_pause_draw_toggle(
|
||||
DrawMode::DrawOne => DrawMode::DrawThree,
|
||||
DrawMode::DrawThree => DrawMode::DrawOne,
|
||||
};
|
||||
if let Some(p) = &path {
|
||||
if let Some(target) = &p.0 {
|
||||
if let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
|
||||
if let Some(p) = &path
|
||||
&& let Some(target) = &p.0
|
||||
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
|
||||
warn!("failed to save settings after draw-mode toggle: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +260,6 @@ fn sync_info(backend: &SyncBackend) -> (&'static str, String) {
|
||||
SyncBackend::SolitaireServer { username, .. } => {
|
||||
("Solitaire Server", username.clone())
|
||||
}
|
||||
SyncBackend::GooglePlayGames => ("Google Play Games", "—".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,13 +101,12 @@ fn award_xp_on_win(
|
||||
total_xp: progress.0.total_xp,
|
||||
});
|
||||
}
|
||||
if let Some(target) = &path.0 {
|
||||
if let Err(e) = save_progress_to(target, &progress.0) {
|
||||
if let Some(target) = &path.0
|
||||
&& let Err(e) = save_progress_to(target, &progress.0) {
|
||||
warn!("failed to save progress: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||