Compare commits

...

15 Commits

Author SHA1 Message Date
funman300 7cda2a9f1a fix(engine): resolve all clippy warnings introduced by PNG asset pipeline
CI / Test & Lint (push) Failing after 1m34s
CI / Release Build (push) Has been skipped
- Collapse nested-if patterns into let-chains across 13 plugins (42 instances)
- Add #[allow(clippy::too_many_arguments)] to 5 Bevy systems in card_plugin
  and input_plugin where ECS parameter count exceeds the lint threshold
- Gate Theme import in table_plugin under #[cfg(test)] — only used by
  test-only colour helpers; removing the unconditional import silences the
  unused-import lint without breaking the test suite
- Wrap ButtonInput<MouseButton> in Option<> in update_input_platform so that
  tests using MinimalPlugins (no InputPlugin) no longer panic on startup

All 789 tests pass; cargo clippy --workspace -- -D warnings is clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 03:35:41 +00:00
funman300 2b04718f33 feat(assetgen): upgrade card backs and backgrounds to 120×168 with richer patterns
Replace 16×16 placeholder PNGs with 120×168 canvas-drawn art matching card
face dimensions. Each card back has a distinctive coloured pattern (blue diamond
grid, red crosshatch, green circle array, purple concentric diamonds, teal
horizontal stripes). Each background has textured detail (green felt weave, wood
plank grain, navy star field, burgundy diagonal tile, charcoal checkerboard).
Removes the now-unused save_small_png/make_small helpers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:27:31 +00:00
funman300 505f0ebda3 fix(docker): remove unneeded openssl deps, verify sqlx offline cache path
All crypto uses pure-Rust backends: jsonwebtoken with rust_crypto feature,
sqlx with runtime-tokio-rustls, reqwest with rustls. Neither libssl-dev
(builder) nor libssl3 (runtime) are required. pkg-config is also removed
as no build.rs in the dep tree invokes it. EXPOSE updated to reflect the
SERVER_PORT env var with an 8080 fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:25:45 +00:00
funman300 0f40e717e1 docs(arch): update CardImageSet and asset pipeline for 52-face PNG system
Replace the stale single-face placeholder description with the live 52-PNG
system: CardImageSet.faces is now [[Handle<Image>; 13]; 4] indexed by
[suit][rank], face images are generated by solitaire_assetgen using ab_glyph
with rank/suit baked in, and Text2d overlays are fallback-only. Remove the
now-completed "Future art pass / texture atlas upgrade" note from Section 14.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:25:30 +00:00
funman300 08202f9351 docs(engine): update card_plugin module comment for PNG-based rendering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:24:45 +00:00
funman300 e22fcadb22 feat(engine,assetgen): generate 52 individual card face PNGs
Replace the single shared face.png placeholder with 52 individual card
face images (120×168 px each), generated by the updated gen_art tool:

- solitaire_assetgen: add ab_glyph dep; rewrite gen_art to render each
  card with FiraMono rank characters, programmatic suit symbols (heart,
  spade, diamond, club drawn via circles/triangles), and standard pip
  layout for numbered cards (A–10) plus large face letter for J/Q/K.
- CardImageSet: replace single `face` handle with `faces: [[Handle; 13]; 4]`
  indexed by [suit][rank].
- card_sprite(): select the per-card face image by suit/rank indices.
- spawn/update_card_entity: suppress Text2d overlay when PNG faces are
  loaded (rank/suit baked into image); keep overlay in solid-colour
  fallback for tests.
- gen_sfx.rs: rename `gen` variable to `make` (reserved keyword in 2024).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:20:31 +00:00
funman300 11d53245cf ci: add libwayland-dev to both CI jobs
wayland-sys (pulled in by Bevy via winit) requires libwayland-dev to
satisfy pkg-config at compile time. Missing it causes a build failure
for both the clippy step (which compiles all crates) and the release
build job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:00:31 +00:00
funman300 f27a002c91 fix(server,core): use SmartIpKeyExtractor for rate limiter, collapse nested if
- tower_governor: switch from PeerIpKeyExtractor (socket address) to
  SmartIpKeyExtractor so x-forwarded-for headers are honoured in tests
  and behind reverse proxies. Fixes auth_rate_limit_returns_429 test
  returning 500 instead of 429.
- solitaire_core: collapse nested if/if-let per clippy::collapsible_if.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:54:53 +00:00
funman300 ce8ba6a8c4 chore(workspace): pin rust-toolchain to stable, set MSRV 1.95
Add rust-toolchain.toml so all developers and CI use the same Rust
channel (latest stable = 1.95.0 as of 2026-04-14). Set rust-version
= "1.95" in workspace Cargo.toml to declare the minimum supported
Rust version explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:47:17 +00:00
funman300 66695683eb chore(workspace): upgrade rand 0.9, edition 2024, expand server tests
- rand "0.8" → "0.9": StdRng/SliceRandom API unchanged; 142 core tests pass
- edition "2021" → "2024" workspace-wide: no gen keyword conflicts found;
  204 tests (core + sync) pass clean with zero warnings
- ARCHITECTURE.md: Edition 2021 → Edition 2024 in header
- solitaire_server tests: add 5 new integration tests covering
  refresh-with-garbage-token, expired-refresh-token, push-without-token,
  delete-account-without-token, and leaderboard-authenticated-but-empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:36:12 +00:00
funman300 18ac5adef5 feat(engine): art pass — PNG assets, custom font, and keyring v4 upgrade
Art pass (Phase 4):
- Generate placeholder PNG assets: face.png, back_0–4.png, bg_0–4.png via
  solitaire_assetgen gen_art binary (16×16 RGBA, embedded via include_bytes!)
- Add FiraMono-Medium font (assets/fonts/main.ttf) embedded at compile time
- Add FontPlugin: loads font at startup, exposes FontResource; gracefully
  falls back to default handle when Assets<Font> absent (MinimalPlugins tests)
- Wire CardImageSet into card_plugin: face/back PNGs replace solid-colour
  sprites when available; tests continue using colour fallback via MinimalPlugins
- Wire BackgroundImageSet into table_plugin: bg PNGs replace solid-colour
  background; empty set inserted when Assets<Image> absent in tests
- Fix hint highlight system (input_plugin): tint sprite.color directly instead
  of replacing the whole Sprite (which would discard the image handle)
- Export FontPlugin, FontResource, CardImageSet from solitaire_engine::lib
- Register FontPlugin in solitaire_app before other plugins

Dependency upgrades (latest releases):
- keyring "2" → keyring "4" + keyring-core "1" (v4 split architecture into
  separate core library crate)
- auth_tokens.rs: Entry::new now returns Result; delete_password →
  delete_credential; NoDefaultStore error variant handled
- solitaire_app: add keyring::use_native_store(true) at startup for Linux
  Secret Service / macOS Keychain / Windows Credential Store selection

ARCHITECTURE.md: fix Edition 2025→2021, update asset pipeline section,
add FontPlugin/CardImageSet/BackgroundImageSet to plugin and resource tables,
update Section 14 to reflect actual include_bytes!() rendering approach,
add Decision Log entries for embedded PNG and font decisions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:30:55 +00:00
funman300 41d75b50de feat/fix/perf(engine,data,assetgen): ambient audio, sync bug fixes, hot-path cleanup
**ambient_loop.wav (task 5)**
- solitaire_assetgen: add ambient_loop() synthesizer — 5 s seamless loop,
  55 Hz drone with 2nd/3rd harmonics, 0.2 Hz LFO breath, 16-bit mono 44100 Hz
- audio_plugin: load ambient_loop.wav via include_bytes!() replacing the
  card_flip.wav placeholder; decouple start_ambient_loop() from SoundLibrary

**sync bug fixes (task 11)**
- sync_plugin: LocalOnlyProvider returning UnsupportedPlatform now sets
  SyncStatus::Idle instead of displaying a misleading "Sync not configured" error
- sync_client: extract_pull_body / extract_push_body now return SyncError::Auth
  only for HTTP 401/403; all other non-2xx statuses return SyncError::Network
- sync_plugin: push_on_exit now logs a warn! on failure instead of silently
  discarding the result

**hot-path performance (task 12)**
- card_plugin: card_positions() now returns &Card references (lifetime-bound to
  GameState) instead of owned Card clones — eliminates 52 Card clones per
  sync_cards() call (runs every animation frame)
- input_plugin: card_position() takes &PileType instead of PileType, eliminating
  PileType copies at every drag hit-test call site
- animation_plugin: eliminate intermediate AnimSpeed clone in handle_win_cascade()

**docs (tasks 11, 13)**
- docs/sync_test_runbook.md: manual test runbook for cross-machine sync
- docs/android_investigation.md: cargo-mobile2 port investigation and effort estimate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:51:58 +00:00
funman300 4997356cb5 docs(project): add README, CI workflow, migration guide, and fix asset docs
- README.md: player-facing install, controls, features, and test instructions
- .github/workflows/ci.yml: clippy + headless tests + release build on push/PR
- solitaire_server/migrations/README.md: naming convention and workflow for
  adding future schema migrations
- ARCHITECTURE.md §14: rewrite Asset Pipeline to reflect procedural rendering
  (no image files used; audio only, embedded via include_bytes!)
- ARCHITECTURE.md §2 / §13: fix workspace structure and audio file listing
- CLAUDE.md: clarify asset embedding rule (audio only; visuals are procedural)
- server_tests.rs: add auth_rate_limit_returns_429_on_11th_request test using
  build_router() (rate limiting ON) to verify the GovernorLayer is wired correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:41:16 +00:00
funman300 4bd562671e chore(data,engine,docs): remove Google Play Games Services sync backend
Deletes the solitaire_gpgs crate and all GPGS references from settings,
sync client, profile plugin, CLAUDE.md, and ARCHITECTURE.md. The
self-hosted server covers all sync needs without the Android-only backend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:22:25 +00:00
funman300 8221ebc803 fix(engine): replace EventReader with MessageReader for TouchInput events
EventReader was removed in Bevy 0.18 in favour of MessageReader.
Three touch drag/tap handlers used the old type, causing compile errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:00:53 +00:00
112 changed files with 4264 additions and 999 deletions
+88
View File
@@ -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
+100 -180
View File
@@ -1,9 +1,9 @@
# Solitaire Quest — Architecture Document # Solitaire Quest — Architecture Document
> **Version:** 1.1 > **Version:** 1.1
> **Language:** Rust (Edition 2021) > **Language:** Rust (Edition 2024)
> **Engine:** Bevy (latest stable) > **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) 5. [Game Engine Architecture](#5-game-engine-architecture)
6. [Persistence & Sync Architecture](#6-persistence--sync-architecture) 6. [Persistence & Sync Architecture](#6-persistence--sync-architecture)
7. [Sync Server Architecture](#7-sync-server-architecture) 7. [Sync Server Architecture](#7-sync-server-architecture)
8. [Google Play Games Services (Android Future)](#8-google-play-games-services-android-future) 8. [Data Models](#8-data-models)
9. [Data Models](#9-data-models) 9. [API Reference](#9-api-reference)
10. [API Reference](#10-api-reference) 10. [Merge Strategy](#10-merge-strategy)
11. [Merge Strategy](#11-merge-strategy) 11. [Achievement System](#11-achievement-system)
12. [Achievement System](#12-achievement-system) 12. [Progression System](#12-progression-system)
13. [Progression System](#13-progression-system) 13. [Audio System](#13-audio-system)
14. [Audio System](#14-audio-system) 14. [Asset Pipeline](#14-asset-pipeline)
15. [Asset Pipeline](#15-asset-pipeline) 15. [Platform Targets](#15-platform-targets)
16. [Platform Targets](#16-platform-targets) 16. [Build & Development Guide](#16-build--development-guide)
17. [Build & Development Guide](#17-build--development-guide) 17. [Deployment Guide](#17-deployment-guide)
18. [Deployment Guide](#18-deployment-guide) 18. [Security Model](#18-security-model)
19. [Security Model](#19-security-model) 19. [Testing Strategy](#19-testing-strategy)
20. [Testing Strategy](#20-testing-strategy) 20. [Decision Log](#20-decision-log)
21. [Decision Log](#21-decision-log)
--- ---
## 1. Project Overview ## 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. 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.
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.
### Sync Backend by Platform ### 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 | | macOS | Self-hosted server | Full feature set |
| Windows | Self-hosted server | Full feature set | | Windows | Self-hosted server | Full feature set |
| Linux | 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 ### Design Principles
@@ -72,26 +67,25 @@ solitaire_quest/
├── Dockerfile # Multi-stage server build ├── Dockerfile # Multi-stage server build
├── docker-compose.yml # Server + Caddy reverse proxy ├── docker-compose.yml # Server + Caddy reverse proxy
├── assets/ # All runtime assets (loaded via Bevy AssetServer) ├── assets/ # Assets embedded at compile time via include_bytes!()
│ ├── cards/ │ ├── cards/
│ │ ├── faces/ # Card face sprites (suit + rank) │ │ ├── faces/{rank}_{suit}.png # 52 individual card faces (120×168, generated by solitaire_assetgen)
│ │ └── backs/ # Card back designs (back_0.png back_4.png) │ │ └── backs/back_0.png back_4.png # placeholder patterns
│ ├── backgrounds/ # Table backgrounds (bg_0.png bg_4.png) │ ├── backgrounds/bg_0.png bg_4.png # placeholder textures
│ ├── fonts/ # .ttf font files │ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
│ └── audio/ │ └── audio/
│ ├── card_deal.ogg │ ├── card_deal.wav
│ ├── card_flip.ogg │ ├── card_flip.wav
│ ├── card_place.ogg │ ├── card_place.wav
│ ├── card_invalid.ogg │ ├── card_invalid.wav
│ ├── win_fanfare.ogg │ ├── win_fanfare.wav
│ └── ambient_loop.ogg │ └── ambient_loop.wav
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde ├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
├── solitaire_sync/ # Shared API types — used by client and server ├── solitaire_sync/ # Shared API types — used by client and server
├── solitaire_data/ # Persistence, sync client, settings ├── solitaire_data/ # Persistence, sync client, settings
├── solitaire_engine/ # Bevy ECS systems, components, plugins ├── solitaire_engine/ # Bevy ECS systems, components, plugins
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite) ├── 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 └── solitaire_app/ # Main binary entry point
``` ```
@@ -135,22 +129,7 @@ Owns:
- `SyncBackend` enum and backend selection - `SyncBackend` enum and backend selection
- Solitaire Server sync client (JWT auth, auto-refresh) - Solitaire Server sync client (JWT auth, auto-refresh)
- OS keychain integration (`keyring`) - OS keychain integration (`keyring`)
- `SyncProvider` trait — implemented by both `SolitaireServerClient` and `GpgsClient` (Android) - `SyncProvider` trait — implemented by `SolitaireServerClient`
### `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.
### `solitaire_engine` ### `solitaire_engine`
**Dependencies:** `bevy`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`. **Dependencies:** `bevy`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`.
@@ -165,6 +144,7 @@ Owns:
- All Bevy UI screens (Home, Stats, Achievements, Settings, Profile) - All Bevy UI screens (Home, Stats, Achievements, Settings, Profile)
- Audio playback systems - Audio playback systems
- Sync status display - Sync status display
- Card, background, and font asset loading (embedded via `include_bytes!()` — no `AssetServer` dependency)
### `solitaire_server` ### `solitaire_server`
**Dependencies:** `solitaire_sync`, `axum`, `sqlx`, `jsonwebtoken`, `bcrypt`, `tower-governor`, `tracing`, `tokio`, `dotenvy`. **Dependencies:** `solitaire_sync`, `axum`, `sqlx`, `jsonwebtoken`, `bcrypt`, `tower-governor`, `tracing`, `tokio`, `dotenvy`.
@@ -223,8 +203,7 @@ SyncPlugin::on_startup()
│ spawns AsyncComputeTask │ spawns AsyncComputeTask
solitaire_data::sync_pull() ← dispatches to active SyncProvider solitaire_data::sync_pull() ← dispatches to active SyncProvider
│ SolitaireServerClient (desktop / iOS) │ SolitaireServerClient
│ GpgsClient (Android, future)
solitaire_sync::merge(local, remote) solitaire_sync::merge(local, remote)
@@ -245,7 +224,7 @@ SyncPlugin::on_exit()
│ blocking push (acceptable on exit, not on main loop) │ blocking push (acceptable on exit, not on main loop)
active SyncProvider::push(local) active SyncProvider::push(local)
│ POST to server — or — GPGS Saved Games PUT (Android) │ POST to server
Done Done
``` ```
@@ -260,6 +239,7 @@ Done
|---|---|---| |---|---|---|
| `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop | | `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop |
| `TablePlugin` | — | Pile markers, background, layout calculation | | `TablePlugin` | — | Pile markers, background, layout calculation |
| `FontPlugin` | — | Embeds FiraMono-Medium font at compile time; exposes `FontResource` handle |
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations | | `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations | | `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit | | `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
@@ -309,6 +289,20 @@ struct StatsResource(StatsSnapshot);
struct ProgressResource(PlayerProgress); struct ProgressResource(PlayerProgress);
struct AchievementsResource(Vec<AchievementRecord>); struct AchievementsResource(Vec<AchievementRecord>);
struct SettingsResource(Settings); 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 04 match selected_background setting
}
``` ```
### Key Bevy Events ### Key Bevy Events
@@ -382,7 +376,6 @@ Implementations:
|---|---|---| |---|---|---|
| `LocalOnlyProvider` | No-op (default) | All | | `LocalOnlyProvider` | No-op (default) | All |
| `SolitaireServerClient` | Self-hosted server | 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. 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 // JWT access + refresh tokens stored in OS keychain
// key: "solitaire_quest_server_{username}" // 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. 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. 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 ## 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) ## 8. Data Models
> **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
### Core Game Models (`solitaire_core`) ### Core Game Models (`solitaire_core`)
@@ -677,14 +581,14 @@ pub struct Settings {
pub music_volume: f32, pub music_volume: f32,
pub animation_speed: AnimSpeed, pub animation_speed: AnimSpeed,
pub theme: Theme, pub theme: Theme,
pub sync_backend: SyncBackend, // Local | SolitaireServer | GooglePlayGames pub sync_backend: SyncBackend, // Local | SolitaireServer
pub first_run_complete: bool, 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`). 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 ```rust
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload { 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 ### 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. 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 ### 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`. 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_place.wav` | Valid card placement |
| `card_invalid.wav` | Invalid move attempt | | `card_invalid.wav` | Invalid move attempt |
| `win_fanfare.wav` | Game won | | `win_fanfare.wav` | Game won |
| `ambient_loop.wav` | Looping background music (restarts seamlessly) | | `ambient_loop.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. 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: Backgrounds are Bevy `Sprite` entities with `Handle<Image>` from `BackgroundImageSet`. `BackgroundImageSet` is populated at startup from `include_bytes!()`.
- A texture atlas (`assets/cards/atlas.png` + `atlas.ron` layout) — faster to load, preferred
- Individual files (`assets/cards/faces/2_of_clubs.png`, etc.) — easier to author
Card backs: `assets/cards/backs/back_0.png` through `back_4.png`. Additional backs unlocked via achievements are in the same folder, gated by `PlayerProgress::unlocked_card_backs`. 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 | | Platform | Status | Primary Sync | Notes |
|---|---|---|---| |---|---|---|---|
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) | | 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+ | | 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 | | Android | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input; GPGS unavailable on iOS | | 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`. 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 ### Prerequisites
@@ -965,7 +886,7 @@ Add `--features bevy/dynamic_linking` during development to dramatically reduce
--- ---
## 18. Deployment Guide ## 17. Deployment Guide
### Docker Compose (Recommended) ### Docker Compose (Recommended)
@@ -1010,7 +931,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
--- ---
## 19. Security Model ## 18. Security Model
| Concern | Mitigation | | Concern | Mitigation |
|---|---| |---|---|
@@ -1026,7 +947,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
--- ---
## 20. Testing Strategy ## 19. Testing Strategy
### Unit Tests (`solitaire_core`) ### Unit Tests (`solitaire_core`)
@@ -1065,12 +986,10 @@ Using `axum::test` and an in-memory SQLite database:
- [ ] Achievement toast appears and dismisses - [ ] Achievement toast appears and dismisses
- [ ] Server sync: register, login, push, pull on second machine - [ ] Server sync: register, login, push, pull on second machine
- [ ] Server sync: JWT refresh on 401 works transparently - [ ] 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 | | 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 | | 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 | | 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 | | 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 | | `SyncProvider` trait, not `SyncBackend` match arms | `SyncPlugin` stays backend-agnostic and testable; new backends can be added without touching the plugin | 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 |
| Dropped WebDAV backend | Redundant once the self-hosted server exists; removing it reduces surface area and simplifies settings UI | 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 |
+1 -3
View File
@@ -12,7 +12,6 @@ solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
solitaire_data/ # Persistence + SyncProvider trait + server client solitaire_data/ # Persistence + SyncProvider trait + server client
solitaire_engine/ # Bevy ECS systems, components, plugins solitaire_engine/ # Bevy ECS systems, components, plugins
solitaire_server/ # Axum sync server binary solitaire_server/ # Axum sync server binary
solitaire_gpgs/ # Google Play Games bridge — STUB ONLY until Android phase
solitaire_app/ # Thin binary entry point solitaire_app/ # Thin binary entry point
assets/ # Source assets — embedded at compile time via include_bytes!() 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. - `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`. - No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
- Assets are embedded at compile time using `include_bytes!()`. No runtime asset loading via `AssetServer`. - Audio assets are embedded at compile time using `include_bytes!()` in `audio_plugin.rs`. Cards and backgrounds are rendered procedurally (colored `Sprite` entities + text) — no image files are used and no `AssetServer` is needed.
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`. - Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs. - Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread. - Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
- All sync backends implement the `SyncProvider` trait. The `SyncPlugin` is backend-agnostic — never `match` on `SyncBackend` inside a Bevy system. - 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 clippy --workspace -- -D warnings` must pass clean after every change.
- `cargo test --workspace` must pass after every change. - `cargo test --workspace` must pass after every change.
Generated
+1795 -400
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -5,16 +5,16 @@ members = [
"solitaire_data", "solitaire_data",
"solitaire_engine", "solitaire_engine",
"solitaire_server", "solitaire_server",
"solitaire_gpgs",
"solitaire_app", "solitaire_app",
"solitaire_assetgen", "solitaire_assetgen",
] ]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
edition = "2021" edition = "2024"
version = "0.1.0" version = "0.1.0"
license = "MIT" license = "MIT"
rust-version = "1.95"
[workspace.dependencies] [workspace.dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@@ -22,11 +22,12 @@ serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
thiserror = "2" thiserror = "2"
rand = "0.8" rand = "0.9"
async-trait = "0.1" async-trait = "0.1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
dirs = "6" dirs = "6"
keyring = "2" keyring = "4"
keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false } reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
solitaire_core = { path = "solitaire_core" } solitaire_core = { path = "solitaire_core" }
+2 -6
View File
@@ -6,10 +6,6 @@ FROM rust:slim AS builder
WORKDIR /app WORKDIR /app
RUN apt-get update \
&& apt-get install -y pkg-config libssl-dev \
&& rm -rf /var/lib/apt/lists/*
COPY . . COPY . .
# Tell sqlx to use the cached query metadata instead of a live database. # 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 FROM debian:bookworm-slim
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y libssl3 ca-certificates \ && apt-get install -y ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/solitaire_server /usr/local/bin/solitaire_server 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"] ENTRYPOINT ["/usr/local/bin/solitaire_server"]
+73
View File
@@ -0,0 +1,73 @@
# Solitaire Quest
A cross-platform Klondike Solitaire game written in Rust, featuring a full progression system with XP, levels, achievements, daily challenges, and optional self-hosted sync so your stats follow you across machines.
## Features
- **Klondike Solitaire** — Draw One and Draw Three modes
- **Progression** — XP, levels, unlockable card backs and backgrounds
- **18 Achievements** — including secret ones
- **Daily Challenge** — server-seeded so every player worldwide gets the same deal
- **Leaderboard** — opt-in, powered by your own self-hosted server
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
- **Sync** — pull/push stats across devices via a self-hosted server
- **Color-blind mode** — blue tint on red-suit cards
## Building
**Prerequisites**
- Rust stable toolchain (`rustup install stable`)
- Linux: `libasound2-dev libudev-dev libxkbcommon-dev`
- macOS: Xcode Command Line Tools
```bash
# Fast development build
cargo run -p solitaire_app --features bevy/dynamic_linking
# Release build
cargo build -p solitaire_app --release
./target/release/solitaire_app
```
## Controls
| Key | Action |
|---|---|
| Left click / drag | Move cards |
| Right click | Highlight legal moves for a card |
| Space / D | Draw from stock |
| Z / Ctrl+Z | Undo |
| N | New game |
| S | Stats overlay |
| A | Achievements overlay |
| P | Profile overlay |
| O | Settings |
| L | Leaderboard |
| H | Help / controls |
| Enter | Auto-complete (when badge is lit) |
| Escape | Pause / clear selection |
| Arrow keys | Navigate card selection |
## Sync Server (optional)
To sync stats across machines, run the self-hosted server. See [README_SERVER.md](README_SERVER.md) for setup instructions.
Once the server is running, open **Settings → Sync Backend**, enter the server URL and your username, and register an account from within the game.
## Running Tests
```bash
# All tests
cargo test --workspace
# Just game logic (no display required)
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
# Lint
cargo clippy --workspace -- -D warnings
```
## License
MIT — see [LICENSE](LICENSE).
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.
+247
View File
@@ -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 **1218 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 | 46 days | Call Android's `EncryptedSharedPreferences` (Jetpack Security) via JNI. Significant JNI boilerplate. |
| **AES-256 file encryption using Android Keystore via JNI** | Excellent | 58 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:** 12 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: 23 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) | 12 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) | 12 days |
---
## 8. Estimated Effort
| Phase | Description | Days |
|---|---|---|
| Toolchain setup | NDK, cargo-mobile2, first compile | 23 |
| `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 | 23 |
| Touch UX improvements | double-tap, action bar, threshold tuning | 45 |
| Testing on real device / emulator | iteration, lifecycle edge cases | 23 |
| **Total** | | **1417 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 12 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.
+318
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
[toolchain]
channel = "stable"
+1
View File
@@ -12,3 +12,4 @@ path = "src/main.rs"
bevy = { workspace = true } bevy = { workspace = true }
solitaire_engine = { workspace = true } solitaire_engine = { workspace = true }
solitaire_data = { workspace = true } solitaire_data = { workspace = true }
keyring = { workspace = true }
+14 -1
View File
@@ -3,12 +3,24 @@ use solitaire_data::{load_settings_from, provider_for_backend, settings_file_pat
use solitaire_engine::{ use solitaire_engine::{
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
}; };
fn main() { 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 // Load settings before building the app so we can construct the right
// sync provider. Falls back to defaults if no settings file exists yet. // sync provider. Falls back to defaults if no settings file exists yet.
let settings: Settings = settings_file_path() let settings: Settings = settings_file_path()
@@ -32,6 +44,7 @@ fn main() {
..default() ..default()
}), }),
) )
.add_plugins(FontPlugin)
.add_plugins(GamePlugin) .add_plugins(GamePlugin)
.add_plugins(TablePlugin) .add_plugins(TablePlugin)
.add_plugins(CardPlugin) .add_plugins(CardPlugin)
+10 -1
View File
@@ -5,9 +5,18 @@ license.workspace = true
edition.workspace = true edition.workspace = true
publish = false 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. # Not depended on by any other workspace crate.
[dependencies]
png = "0.17"
ab_glyph = "0.2"
[[bin]] [[bin]]
name = "gen_sfx" name = "gen_sfx"
path = "src/bin/gen_sfx.rs" path = "src/bin/gen_sfx.rs"
[[bin]]
name = "gen_art"
path = "src/bin/gen_art.rs"
+713
View File
@@ -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.");
}
+62 -3
View File
@@ -16,16 +16,17 @@ fn main() -> io::Result<()> {
let out_dir = workspace_root().join("assets").join("audio"); let out_dir = workspace_root().join("assets").join("audio");
fs::create_dir_all(&out_dir)?; fs::create_dir_all(&out_dir)?;
let effects: [(&str, Generator); 5] = [ let effects: [(&str, Generator); 6] = [
("card_flip.wav", card_flip), ("card_flip.wav", card_flip),
("card_place.wav", card_place), ("card_place.wav", card_place),
("card_deal.wav", card_deal), ("card_deal.wav", card_deal),
("card_invalid.wav", card_invalid), ("card_invalid.wav", card_invalid),
("win_fanfare.wav", win_fanfare), ("win_fanfare.wav", win_fanfare),
("ambient_loop.wav", ambient_loop),
]; ];
for (name, gen) in &effects { for (name, make) in &effects {
let samples = gen(); let samples = make();
let path = out_dir.join(name); let path = out_dir.join(name);
write_wav_mono_pcm16(&path, SAMPLE_RATE, &samples)?; write_wav_mono_pcm16(&path, SAMPLE_RATE, &samples)?;
println!("wrote {} ({} samples)", path.display(), samples.len()); println!("wrote {} ({} samples)", path.display(), samples.len());
@@ -169,6 +170,64 @@ fn win_fanfare() -> Vec<i16> {
out 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 48 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) // Minimal WAV writer (mono 16-bit PCM)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+2 -3
View File
@@ -289,10 +289,9 @@ impl GameState {
.ok_or(MoveError::InvalidSource)? .ok_or(MoveError::InvalidSource)?
.cards .cards
.last_mut() .last_mut()
&& !top.face_up
{ {
if !top.face_up { top.face_up = true;
top.face_up = true;
}
} }
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved); self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
+1 -1
View File
@@ -13,6 +13,6 @@ chrono = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
keyring = { workspace = true } keyring-core = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
+16 -9
View File
@@ -8,9 +8,15 @@
//! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting //! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting
//! the user to log in again. //! 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. //! # Note: no unit tests — requires live OS keychain.
use keyring::Entry; use keyring_core::Entry;
use thiserror::Error; use thiserror::Error;
/// Errors that can occur when reading or writing tokens in the OS keychain. /// 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. /// Service name used to namespace all keychain entries for this application.
const SERVICE: &str = "solitaire_quest_server"; const SERVICE: &str = "solitaire_quest_server";
/// Map a `keyring::Error` to the appropriate `TokenError`. /// Map a `keyring_core::Error` to the appropriate `TokenError`.
fn map_keyring_err(err: keyring::Error, username: &str) -> TokenError { fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
let msg = err.to_string(); let msg = err.to_string();
match err { match err {
keyring::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg), keyring_core::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
keyring::Error::NoEntry => TokenError::NotFound(username.to_string()), keyring_core::Error::NoDefaultStore => TokenError::KeychainUnavailable(msg),
keyring_core::Error::NoEntry => TokenError::NotFound(username.to_string()),
_ => TokenError::Keyring(msg), _ => 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> { pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
match Entry::new(SERVICE, &format!("{username}_access")) match Entry::new(SERVICE, &format!("{username}_access"))
.map_err(|e| map_keyring_err(e, username))? .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)), Err(e) => return Err(map_keyring_err(e, username)),
} }
match Entry::new(SERVICE, &format!("{username}_refresh")) match Entry::new(SERVICE, &format!("{username}_refresh"))
.map_err(|e| map_keyring_err(e, username))? .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)), Err(e) => return Err(map_keyring_err(e, username)),
} }
+3 -5
View File
@@ -40,7 +40,8 @@ pub enum Theme {
/// Which sync backend the player has configured. /// 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. /// `solitaire_data::auth_tokens` — **never** in this struct.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub enum SyncBackend { pub enum SyncBackend {
@@ -57,10 +58,7 @@ pub enum SyncBackend {
username: String, username: String,
// JWT tokens are stored in the OS keychain — not here. // 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. /// Persistent user settings.
+18 -15
View File
@@ -364,6 +364,10 @@ impl SyncProvider for SolitaireServerClient {
/// Deserialize a pull response body as [`SyncResponse`] and return its /// Deserialize a pull response body as [`SyncResponse`] and return its
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`]. /// `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> { async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncError> {
let status = resp.status(); let status = resp.status();
if status.is_success() { if status.is_success() {
@@ -372,8 +376,12 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
.await .await
.map_err(|e| SyncError::Serialization(e.to_string()))?; .map_err(|e| SyncError::Serialization(e.to_string()))?;
Ok(sync_resp.merged) Ok(sync_resp.merged)
} else { } else if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
Err(SyncError::Auth(format!("server returned {status}"))) 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 /// Deserialize a push response body as [`SyncResponse`], or map non-200
/// statuses to the appropriate [`SyncError`]. /// 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> { async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, SyncError> {
let status = resp.status(); let status = resp.status();
if status.is_success() { if status.is_success() {
resp.json() resp.json()
.await .await
.map_err(|e| SyncError::Serialization(e.to_string())) .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}"))) 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`] /// This is the **one** place in the codebase that matches on [`SyncBackend`]
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>` /// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
/// and remains backend-agnostic. /// 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> { pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
match backend { match backend {
SyncBackend::Local => Box::new(LocalOnlyProvider), SyncBackend::Local => Box::new(LocalOnlyProvider),
SyncBackend::SolitaireServer { url, username } => { SyncBackend::SolitaireServer { url, username } => {
Box::new(SolitaireServerClient::new(url.clone(), username.clone())) 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"); 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] #[test]
fn factory_server_returns_server_client() { fn factory_server_returns_server_client() {
let provider = provider_for_backend(&SyncBackend::SolitaireServer { let provider = provider_for_backend(&SyncBackend::SolitaireServer {
+6 -10
View File
@@ -176,21 +176,17 @@ fn evaluate_on_win(
unlocks.write(AchievementUnlockedEvent(record.clone())); unlocks.write(AchievementUnlockedEvent(record.clone()));
} }
if achievements_changed { if achievements_changed
if let Some(target) = &path.0 { && let Some(target) = &path.0
if let Err(e) = save_achievements_to(target, &achievements.0) { && let Err(e) = save_achievements_to(target, &achievements.0) {
warn!("failed to save achievements: {e}"); warn!("failed to save achievements: {e}");
} }
}
}
if progress_changed { if progress_changed
if let Some(target) = &progress_path.0 { && let Some(target) = &progress_path.0
if let Err(e) = save_progress_to(target, &progress.0) { && let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after reward: {e}"); warn!("failed to save progress after reward: {e}");
} }
}
}
} }
/// Convenience: resolve an achievement ID to its human-readable name. /// Convenience: resolve an achievement ID to its human-readable name.
+4 -6
View File
@@ -274,9 +274,8 @@ fn handle_win_cascade(
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score); let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS); spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS);
let speed = settings.as_ref().map(|s| s.0.animation_speed.clone()); let step = settings.as_ref().map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed.clone()));
let step = speed.clone().map(cascade_step_secs).unwrap_or(CASCADE_STAGGER_NORMAL); let duration = settings.as_ref().map_or(CASCADE_DURATION_NORMAL, |s| cascade_duration_secs(s.0.animation_speed.clone()));
let duration = speed.map(cascade_duration_secs).unwrap_or(CASCADE_DURATION_NORMAL);
for (i, (entity, transform)) in cards.iter().enumerate() { for (i, (entity, transform)) in cards.iter().enumerate() {
commands.entity(entity).insert(CardAnim { commands.entity(entity).insert(CardAnim {
@@ -473,13 +472,12 @@ fn drive_toast_display(
} }
// If no active toast and the queue has messages, show the next one. // If no active toast and the queue has messages, show the next one.
if active.entity.is_none() { if active.entity.is_none()
if let Some(message) = queue.0.pop_front() { && let Some(message) = queue.0.pop_front() {
let entity = spawn_queued_toast(&mut commands, message); let entity = spawn_queued_toast(&mut commands, message);
active.entity = Some(entity); active.entity = Some(entity);
active.timer = QUEUED_TOAST_SECS; active.timer = QUEUED_TOAST_SECS;
} }
}
} }
/// Spawns a centered top-of-screen `ToastEntity` for the queued toast system. /// Spawns a centered top-of-screen `ToastEntity` for the queued toast system.
+18 -12
View File
@@ -11,9 +11,8 @@
//! | `NewGameRequestEvent` | `card_deal.wav` | //! | `NewGameRequestEvent` | `card_deal.wav` |
//! | `GameWonEvent` | `win_fanfare.wav` | //! | `GameWonEvent` | `win_fanfare.wav` |
//! //!
//! An ambient loop is started at plugin startup using `card_flip.wav` at very //! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
//! low volume (0.05 amplitude) routed through `music_track` as a placeholder //! low volume (0.05 amplitude) routed through `music_track`.
//! until a dedicated ambient track is available.
//! //!
//! If the audio device cannot be opened (e.g. a headless CI machine or a //! 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 //! Linux box without a running PulseAudio/Pipewire session), the plugin
@@ -121,8 +120,8 @@ impl Plugin for AudioPlugin {
None => (None, None), None => (None, None),
}; };
// Start the ambient loop placeholder (card_flip.wav looped at very low // Start the ambient loop (ambient_loop.wav looped at very low volume
// volume through music_track). // through music_track).
let ambient_handle = let ambient_handle =
start_ambient_loop(manager.as_mut(), library.as_ref(), &mut music_track); 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 /// Decodes the embedded `ambient_loop.wav` and starts it as a seamlessly
/// low volume) routed through `music_track`. Returns the handle so it can be /// looping ambient track routed through `music_track`. Returns the handle so
/// stored in `AudioState` for future pause/stop control. /// 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( fn start_ambient_loop(
manager: Option<&mut AudioManager<DefaultBackend>>, manager: Option<&mut AudioManager<DefaultBackend>>,
library: Option<&SoundLibrary>, _library: Option<&SoundLibrary>,
music_track: &mut Option<TrackHandle>, music_track: &mut Option<TrackHandle>,
) -> Option<StaticSoundHandle> { ) -> Option<StaticSoundHandle> {
let manager = manager?; 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.loop_region = Some(Region::default());
data.settings.volume = Value::Fixed(amplitude_to_decibels(AMBIENT_VOLUME as f32)); 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). /// running under `MinimalPlugins` (which does not register the touch subsystem).
pub(crate) fn update_input_platform( pub(crate) fn update_input_platform(
touches: Option<Res<Touches>>, touches: Option<Res<Touches>>,
mouse_buttons: Res<ButtonInput<MouseButton>>, mouse_buttons: Option<Res<ButtonInput<MouseButton>>>,
mut tuning: ResMut<AnimationTuning>, mut tuning: ResMut<AnimationTuning>,
) { ) {
let touch_active = touches.as_ref().is_some_and(|t| { 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() || t.iter_just_released().next().is_some()
}); });
let mouse_active = mouse_buttons.get_just_pressed().next().is_some() let mouse_active = mouse_buttons.as_ref().is_some_and(|mb| {
|| mouse_buttons.get_pressed().next().is_some(); mb.get_just_pressed().next().is_some() || mb.get_pressed().next().is_some()
});
if touch_active && tuning.platform != InputPlatform::Touch { if touch_active && tuning.platform != InputPlatform::Touch {
*tuning = AnimationTuning::mobile(); *tuning = AnimationTuning::mobile();
+267 -78
View File
@@ -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 //! Card entities are synced with [`GameStateResource`] on every
//! `Text2d` showing rank+suit. Entities are synced with `GameStateResource` //! [`StateChangedEvent`]: missing cards are spawned, present cards are
//! on every `StateChangedEvent`: missing cards are spawned, present cards //! repositioned/updated in place, and stale cards are despawned.
//! are repositioned/updated in place, and stale cards are despawned.
//! //!
//! Phase 3 uses ASCII rank letters ("A", "2"…"10", "J", "Q", "K") and ASCII //! When [`CardImageSet`] is available, each face-up card renders its own
//! suit letters ("C", "D", "H", "S") so rendering does not depend on the //! 120×168 px `Handle<Image>` chosen from the 52 per-card PNGs loaded from
//! bundled font carrying Unicode suit glyphs. When real card art lands in a //! `assets/cards/faces/{rank}_{suit}.png`. A solid-colour `Sprite` with a
//! later phase, this plugin is replaced — the `CardEntity` marker and the //! `Text2d` rank+suit overlay is used as a fallback when `CardImageSet` is
//! "sync on StateChangedEvent" contract stay the same. //! absent (e.g. in tests running under `MinimalPlugins`).
use std::collections::{HashMap, HashSet}; 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 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); 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 04).
pub backs: [Handle<Image>; 5],
}
/// Alternative face tint for red-suit cards in color-blind mode — a subtle /// 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. /// 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); 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::<SettingsChangedEvent>()
.add_message::<CardFlippedEvent>() .add_message::<CardFlippedEvent>()
.add_message::<CardFaceRevealedEvent>() .add_message::<CardFaceRevealedEvent>()
.add_systems(Startup, load_card_images)
.add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup)) .add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup))
.add_systems( .add_systems(
Update, 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 /// 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). /// new back colour is applied immediately (without waiting for a state change).
fn resync_cards_on_settings_change( fn resync_cards_on_settings_change(
@@ -201,17 +383,18 @@ fn sync_cards_startup(
slide_dur: Option<Res<EffectiveSlideDuration>>, slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform)>, entities: Query<(Entity, &CardEntity, &Transform)>,
card_images: Option<Res<CardImageSet>>,
) { ) {
if let Some(layout) = layout { if let Some(layout) = layout {
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs); let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
let back_colour = settings let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
.as_ref() let back_colour = card_back_colour(selected_back);
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode); 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( fn sync_cards_on_change(
mut events: MessageReader<StateChangedEvent>, mut events: MessageReader<StateChangedEvent>,
commands: Commands, commands: Commands,
@@ -220,20 +403,21 @@ fn sync_cards_on_change(
slide_dur: Option<Res<EffectiveSlideDuration>>, slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform)>, entities: Query<(Entity, &CardEntity, &Transform)>,
card_images: Option<Res<CardImageSet>>,
) { ) {
if events.read().next().is_none() { if events.read().next().is_none() {
return; return;
} }
if let Some(layout) = layout { if let Some(layout) = layout {
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs); let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
let back_colour = settings let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
.as_ref() let back_colour = card_back_colour(selected_back);
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode); 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( fn sync_cards(
mut commands: Commands, mut commands: Commands,
game: &GameState, game: &GameState,
@@ -242,6 +426,8 @@ fn sync_cards(
back_colour: Color, back_colour: Color,
color_blind: bool, color_blind: bool,
entities: &Query<(Entity, &CardEntity, &Transform)>, entities: &Query<(Entity, &CardEntity, &Transform)>,
card_images: Option<&CardImageSet>,
selected_back: usize,
) { ) {
let positions = card_positions(game, layout); let positions = card_positions(game, layout);
@@ -265,18 +451,18 @@ fn sync_cards(
match existing.get(&card.id) { match existing.get(&card.id) {
Some(&(entity, cur)) => { Some(&(entity, cur)) => {
update_card_entity( update_card_entity(
&mut commands, entity, &card, position, z, layout, &mut commands, entity, card, position, z, layout,
slide_secs, back_colour, color_blind, cur, 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. /// 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)> { fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Vec2, f32)> {
let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52); let mut out: Vec<(&'a Card, Vec2, f32)> = Vec::with_capacity(52);
let piles = [ let piles = [
PileType::Stock, PileType::Stock,
PileType::Waste, 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 pos = Vec2::new(base.x + x_offset, base.y + y_offset);
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC; let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
out.push((card.clone(), pos, z)); out.push((card, pos, z));
if is_tableau { if is_tableau {
let step = if card.face_up { let step = if card.face_up {
TABLEAU_FAN_FRAC 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) { #[allow(clippy::too_many_arguments)]
let body_colour = if card.face_up { fn spawn_card_entity(
face_colour(card, color_blind) commands: &mut Commands,
} else { card: &Card,
back_colour 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 let mut entity = commands.spawn((
.spawn(( CardEntity { card_id: card.id },
CardEntity { card_id: card.id }, sprite,
Sprite { Transform::from_xyz(pos.x, pos.y, z),
color: body_colour, Visibility::default(),
custom_size: Some(layout.card_size), ));
..default() // When PNG faces are loaded the rank/suit are baked into the image.
}, // Only spawn the Text2d overlay in the solid-colour fallback (tests).
Transform::from_xyz(pos.x, pos.y, z), if card_images.is_none() {
Visibility::default(), entity.with_children(|b| {
))
.with_children(|b| {
b.spawn(( b.spawn((
CardLabel, CardLabel,
Text2d::new(label_for(card)), Text2d::new(label_for(card)),
@@ -385,12 +576,11 @@ fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, la
..default() ..default()
}, },
TextColor(text_colour(card)), 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), Transform::from_xyz(0.0, 0.0, 0.01),
label_visibility(card), label_visibility(card),
)); ));
}); });
}
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -405,21 +595,13 @@ fn update_card_entity(
back_colour: Color, back_colour: Color,
color_blind: bool, color_blind: bool,
cur: Vec3, 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); let target = Vec3::new(pos.x, pos.y, z);
// Always refresh the visual appearance. // Always refresh the visual appearance.
commands.entity(entity).insert(Sprite { commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
color: body_colour,
custom_size: Some(layout.card_size),
..default()
});
// Slide to the new position when it differs meaningfully; snap otherwise. // Slide to the new position when it differs meaningfully; snap otherwise.
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 { if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
@@ -441,22 +623,25 @@ fn update_card_entity(
.insert(Transform::from_xyz(pos.x, pos.y, z)); .insert(Transform::from_xyz(pos.x, pos.y, z));
} }
// Despawn the old label child and respawn a fresh one, so rank/suit/ // Despawn any stale children and re-add the label overlay only when
// colour/visibility all stay in sync with the card's current state. // 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>(); commands.entity(entity).despawn_related::<Children>();
commands.entity(entity).with_children(|b| { if card_images.is_none() {
b.spawn(( commands.entity(entity).with_children(|b| {
CardLabel, b.spawn((
Text2d::new(label_for(card)), CardLabel,
TextFont { Text2d::new(label_for(card)),
font_size: layout.card_size.x * FONT_SIZE_FRAC, TextFont {
..default() font_size: layout.card_size.x * FONT_SIZE_FRAC,
}, ..default()
TextColor(text_colour(card)), },
Transform::from_xyz(0.0, 0.0, 0.01), TextColor(text_colour(card)),
label_visibility(card), Transform::from_xyz(0.0, 0.0, 0.01),
)); label_visibility(card),
}); ));
});
}
} }
fn label_for(card: &Card) -> String { fn label_for(card: &Card) -> String {
@@ -653,20 +838,24 @@ fn tick_hint_highlight(
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>, mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>, 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 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() { for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
hint.remaining -= time.delta_secs(); hint.remaining -= time.delta_secs();
if hint.remaining <= 0.0 { if hint.remaining <= 0.0 {
// Restore normal face-up colour. // Restore the normal sprite colour.
let is_face_up = game.0.piles.values() // When image-based rendering is active, WHITE is the neutral tint;
.flat_map(|p| p.cards.iter()) // otherwise restore the solid colour appropriate to the card state.
.find(|c| c.id == card_entity.card_id) sprite.color = if use_images {
.is_some_and(|c| c.face_up); Color::WHITE
sprite.color = if is_face_up {
CARD_FACE_COLOUR
} else { } else {
card_back_colour(back_idx) 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);
if is_face_up { CARD_FACE_COLOUR } else { card_back_colour(back_idx) }
}; };
commands commands
.entity(entity) .entity(entity)
+2 -3
View File
@@ -54,11 +54,10 @@ fn advance_on_challenge_win(
} }
let prev = progress.0.challenge_index; let prev = progress.0.challenge_index;
progress.0.challenge_index = prev.saturating_add(1); progress.0.challenge_index = prev.saturating_add(1);
if let Some(target) = &path.0 { if let Some(target) = &path.0
if let Err(e) = save_progress_to(target, &progress.0) { && let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after challenge advance: {e}"); warn!("failed to save progress after challenge advance: {e}");
} }
}
// Human-readable level is 1-based (index 0 → "Challenge 1"). // Human-readable level is 1-based (index 0 → "Challenge 1").
let level_number = prev.saturating_add(1); let level_number = prev.saturating_add(1);
toast.write(InfoToastEvent(format!("Challenge {level_number} complete!"))); toast.write(InfoToastEvent(format!("Challenge {level_number} complete!")));
@@ -161,27 +161,24 @@ fn handle_daily_completion(
continue; continue;
} }
// Enforce server-supplied goal constraints when present. // Enforce server-supplied goal constraints when present.
if let Some(target) = daily.target_score { if let Some(target) = daily.target_score
if ev.score < target { && ev.score < target {
continue; // score goal not met continue; // score goal not met
} }
} if let Some(max_secs) = daily.max_time_secs
if let Some(max_secs) = daily.max_time_secs { && ev.time_seconds > max_secs {
if ev.time_seconds > max_secs {
continue; // time limit exceeded continue; // time limit exceeded
} }
}
if !progress.0.record_daily_completion(daily.date) { if !progress.0.record_daily_completion(daily.date) {
// Already counted today — no-op. // Already counted today — no-op.
continue; continue;
} }
progress.0.add_xp(DAILY_BONUS_XP); progress.0.add_xp(DAILY_BONUS_XP);
xp_awarded.write(XpAwardedEvent { amount: DAILY_BONUS_XP }); xp_awarded.write(XpAwardedEvent { amount: DAILY_BONUS_XP });
if let Some(target) = &path.0 { if let Some(target) = &path.0
if let Err(e) = save_progress_to(target, &progress.0) { && let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after daily completion: {e}"); warn!("failed to save progress after daily completion: {e}");
} }
}
completed.write(DailyChallengeCompletedEvent { completed.write(DailyChallengeCompletedEvent {
date: daily.date, date: daily.date,
streak: progress.0.daily_challenge_streak, streak: progress.0.daily_challenge_streak,
+36
View File
@@ -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()));
}
}
}
+10 -15
View File
@@ -194,11 +194,10 @@ fn handle_new_game(
let mode = ev.mode.unwrap_or(game.0.mode); let mode = ev.mode.unwrap_or(game.0.mode);
game.0 = GameState::new_with_mode(seed, draw_mode, mode); game.0 = GameState::new_with_mode(seed, draw_mode, mode);
// Delete any previously saved in-progress state — this is a fresh game. // 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 Some(p) = path.as_ref().and_then(|r| r.0.as_deref())
if let Err(e) = delete_game_state_at(p) { && let Err(e) = delete_game_state_at(p) {
warn!("game_state: failed to delete saved game: {e}"); warn!("game_state: failed to delete saved game: {e}");
} }
}
changed.write(StateChangedEvent); changed.write(StateChangedEvent);
} }
} }
@@ -380,14 +379,13 @@ fn handle_move(
match game.0.move_cards(ev.from.clone(), ev.to.clone(), ev.count) { match game.0.move_cards(ev.from.clone(), ev.to.clone(), ev.count) {
Ok(()) => { Ok(()) => {
// Fire flip event if the candidate card is now face-up. // Fire flip event if the candidate card is now face-up.
if let Some(fid) = flip_candidate_id { if let Some(fid) = flip_candidate_id
if game.0.piles.get(&ev.from) && game.0.piles.get(&ev.from)
.and_then(|p| p.cards.last()) .and_then(|p| p.cards.last())
.is_some_and(|c| c.id == fid && c.face_up) .is_some_and(|c| c.id == fid && c.face_up)
{ {
flipped.write(crate::events::CardFlippedEvent(fid)); flipped.write(crate::events::CardFlippedEvent(fid));
} }
}
changed.write(StateChangedEvent); changed.write(StateChangedEvent);
if !was_won && game.0.is_won { if !was_won && game.0.is_won {
won.write(GameWonEvent { won.write(GameWonEvent {
@@ -395,11 +393,10 @@ fn handle_move(
time_seconds: game.0.elapsed_seconds, time_seconds: game.0.elapsed_seconds,
}); });
// Delete the saved state — a won game should not be resumed. // 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 Some(p) = path.as_ref().and_then(|r| r.0.as_deref())
if let Err(e) = delete_game_state_at(p) { && let Err(e) = delete_game_state_at(p) {
warn!("game_state: failed to delete on win: {e}"); warn!("game_state: failed to delete on win: {e}");
} }
}
} }
} }
Err(e) => warn!("move rejected {:?} -> {:?} x{}: {e}", ev.from, ev.to, ev.count), Err(e) => warn!("move rejected {:?} -> {:?} x{}: {e}", ev.from, ev.to, ev.count),
@@ -468,11 +465,10 @@ pub fn has_legal_moves(game: &GameState) -> bool {
// Check foundations. // Check foundations.
for &suit in &suits { for &suit in &suits {
let dest = PileType::Foundation(suit); let dest = PileType::Foundation(suit);
if let Some(dest_pile) = game.piles.get(&dest) { if let Some(dest_pile) = game.piles.get(&dest)
if can_place_on_foundation(card, dest_pile, suit) { && can_place_on_foundation(card, dest_pile, suit) {
return true; return true;
} }
}
} }
// Check tableau piles. // Check tableau piles.
@@ -481,11 +477,10 @@ pub fn has_legal_moves(game: &GameState) -> bool {
if dest == *from { if dest == *from {
continue; continue;
} }
if let Some(dest_pile) = game.piles.get(&dest) { if let Some(dest_pile) = game.piles.get(&dest)
if can_place_on_tableau(card, dest_pile) { && can_place_on_tableau(card, dest_pile) {
return true; return true;
} }
}
} }
} }
+9 -5
View File
@@ -14,6 +14,7 @@ use solitaire_core::pile::PileType;
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
use crate::daily_challenge_plugin::DailyChallengeResource; use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::InfoToastEvent; use crate::events::InfoToastEvent;
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::selection_plugin::SelectionState; 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 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 commands
.spawn(( .spawn((
Node { Node {
@@ -432,15 +437,14 @@ fn update_hud(
// Reflects the AutoCompleteState resource; update whenever it changes or game changes. // 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_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()); let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
if ac_changed || game.is_changed() { if (ac_changed || game.is_changed())
if let Ok(mut t) = auto_q.single_mut() { && let Ok(mut t) = auto_q.single_mut() {
**t = if ac_active { **t = if ac_active {
"AUTO".to_string() "AUTO".to_string()
} else { } else {
String::new() String::new()
}; };
} }
}
} }
/// Updates the `HudSelection` text node to show which pile is Tab-selected. /// Updates the `HudSelection` text node to show which pile is Tab-selected.
+38 -46
View File
@@ -136,6 +136,7 @@ struct CoreKeyboardMessages<'w> {
/// ///
/// Also resets `forfeit_countdown` whenever U, D, Z, or N are pressed so that /// 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. /// an in-flight forfeit confirmation is cancelled by any other action.
#[allow(clippy::too_many_arguments)]
fn handle_keyboard_core( fn handle_keyboard_core(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
@@ -174,8 +175,8 @@ fn handle_keyboard_core(
confirm.forfeit_countdown = 0.0; confirm.forfeit_countdown = 0.0;
// If a Time Attack session is running, cancel it and start a Classic game. // If a Time Attack session is running, cancel it and start a Classic game.
if let Some(ref mut session) = time_attack { if let Some(ref mut session) = time_attack
if session.active { && session.active {
session.active = false; session.active = false;
session.remaining_secs = 0.0; session.remaining_secs = 0.0;
ev.info_toast.write(InfoToastEvent("Time Attack ended".to_string())); ev.info_toast.write(InfoToastEvent("Time Attack ended".to_string()));
@@ -186,7 +187,6 @@ fn handle_keyboard_core(
confirm.new_game_countdown = 0.0; confirm.new_game_countdown = 0.0;
return; return;
} }
}
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won); 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); 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 /// 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. /// moves are available a "No hints available" toast is shown instead.
#[allow(clippy::too_many_arguments)]
fn handle_keyboard_hint( fn handle_keyboard_hint(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
@@ -252,7 +253,7 @@ fn handle_keyboard_hint(
mut confirm: ResMut<KeyboardConfirmState>, mut confirm: ResMut<KeyboardConfirmState>,
mut hint_cycle: ResMut<HintCycleIndex>, mut hint_cycle: ResMut<HintCycleIndex>,
mut commands: Commands, mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Sprite)>, mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
mut info_toast: MessageWriter<InfoToastEvent>, mut info_toast: MessageWriter<InfoToastEvent>,
mut hint_visual: MessageWriter<HintVisualEvent>, mut hint_visual: MessageWriter<HintVisualEvent>,
) { ) {
@@ -273,7 +274,7 @@ fn handle_keyboard_hint(
return; return;
} }
let Some(ref layout_res) = layout else { return }; let Some(_layout_res) = layout else { return };
let hints = all_hints(&g.0); let hints = all_hints(&g.0);
if hints.is_empty() { if hints.is_empty() {
@@ -308,16 +309,14 @@ fn handle_keyboard_hint(
.and_then(|p| p.cards.last().filter(|c| c.face_up)) .and_then(|p| p.cards.last().filter(|c| c.face_up))
.map(|c| c.id); .map(|c| c.id);
if let Some(card_id) = top_card_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 { 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) commands.entity(entity)
.insert(HintHighlight { remaining: 2.0 }) .insert(HintHighlight { remaining: 2.0 })
.insert(HintHighlightTimer(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()
});
break; break;
} }
} }
@@ -472,7 +471,7 @@ fn handle_stock_click(
/// — since the stock cannot be dragged, there is no ambiguity between a tap and /// — 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. /// the start of a drag on this pile. Does nothing while a drag is in progress.
fn handle_touch_stock_tap( fn handle_touch_stock_tap(
mut touch_events: EventReader<TouchInput>, mut touch_events: MessageReader<TouchInput>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
cameras: Query<(&Camera, &GlobalTransform)>, cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
@@ -531,7 +530,7 @@ fn start_drag(
return; 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 // Store as a pending drag. We do NOT elevate the cards yet — the visual
// lift happens in follow_drag once the threshold is crossed. // 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 // the placement is illegal, fire MoveRejectedEvent so AudioPlugin can
// play card_invalid.wav. // play card_invalid.wav.
let mut fired = false; let mut fired = false;
if let Some(target) = target { if let Some(target) = target
if target != origin { && target != origin {
let bottom_card_id = drag.cards[0]; let bottom_card_id = drag.cards[0];
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) { if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
let ok = match &target { let ok = match &target {
@@ -710,7 +709,6 @@ fn end_drag(
} }
} }
} }
}
drag.clear(); drag.clear();
@@ -731,7 +729,7 @@ fn end_drag(
/// buttons. Records the touch ID in [`DragState`] so only this finger drives /// buttons. Records the touch ID in [`DragState`] so only this finger drives
/// the drag — other fingers are ignored. /// the drag — other fingers are ignored.
fn touch_start_drag( fn touch_start_drag(
mut touch_events: EventReader<TouchInput>, mut touch_events: MessageReader<TouchInput>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
cameras: Query<(&Camera, &GlobalTransform)>, cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
@@ -760,7 +758,7 @@ fn touch_start_drag(
continue; 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.cards = card_ids;
drag.origin_pile = Some(pile); drag.origin_pile = Some(pile);
@@ -841,7 +839,7 @@ fn touch_follow_drag(
/// buttons. Uncommitted drags (tap gestures) are cancelled cleanly. /// buttons. Uncommitted drags (tap gestures) are cancelled cleanly.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn touch_end_drag( fn touch_end_drag(
mut touch_events: EventReader<TouchInput>, mut touch_events: MessageReader<TouchInput>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
cameras: Query<(&Camera, &GlobalTransform)>, cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>, 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)); world.and_then(|w| find_drop_target(w, &game.0, &layout.0, &origin));
let mut fired = false; let mut fired = false;
if let Some(target) = target { if let Some(target) = target
if target != origin { && target != origin {
let bottom_card_id = drag.cards[0]; let bottom_card_id = drag.cards[0];
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) { if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
let ok = match &target { let ok = match &target {
@@ -926,7 +924,6 @@ fn touch_end_drag(
} }
} }
} }
}
drag.clear(); drag.clear();
changed.write(StateChangedEvent); 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. /// 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 { fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
let base = layout.pile_positions[&pile]; let base = layout.pile_positions[pile];
if matches!(pile, PileType::Tableau(_)) { if matches!(pile, PileType::Tableau(_)) {
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC; let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
Vec2::new(base.x, base.y + fan * (stack_index as f32)) 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 // 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 // card_plugin::card_positions(). Hit-testing must use the same offsets
// so clicking the visually rightmost (top) card actually registers. // 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 visible_start = pile_len.saturating_sub(3);
let slot = stack_index.saturating_sub(visible_start) as f32; let slot = stack_index.saturating_sub(visible_start) as f32;
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y) 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 { if !card.face_up {
continue; 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) { if !point_in_rect(cursor, pos, layout.card_size) {
continue; continue;
} }
@@ -1134,20 +1131,18 @@ pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> {
// Try all four foundations first. // Try all four foundations first.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
let dest = PileType::Foundation(suit); let dest = PileType::Foundation(suit);
if let Some(pile) = game.piles.get(&dest) { if let Some(pile) = game.piles.get(&dest)
if can_place_on_foundation(card, pile, suit) { && can_place_on_foundation(card, pile, suit) {
return Some(dest); return Some(dest);
} }
}
} }
// Then try all seven tableau piles. // Then try all seven tableau piles.
for i in 0..7_usize { for i in 0..7_usize {
let dest = PileType::Tableau(i); let dest = PileType::Tableau(i);
if let Some(pile) = game.piles.get(&dest) { if let Some(pile) = game.piles.get(&dest)
if can_place_on_tableau(card, pile) { && can_place_on_tableau(card, pile) {
return Some(dest); return Some(dest);
} }
}
} }
None None
} }
@@ -1169,11 +1164,10 @@ pub fn best_tableau_destination_for_stack(
if dest == *from { if dest == *from {
continue; continue;
} }
if let Some(pile) = game.piles.get(&dest) { if let Some(pile) = game.piles.get(&dest)
if can_place_on_tableau(bottom_card, pile) { && can_place_on_tableau(bottom_card, pile) {
return Some((dest, stack_count)); return Some((dest, stack_count));
} }
}
} }
None None
} }
@@ -1311,14 +1305,13 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue }; let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
for &suit in &suits { for &suit in &suits {
let dest = PileType::Foundation(suit); let dest = PileType::Foundation(suit);
if let Some(dest_pile) = game.piles.get(&dest) { if let Some(dest_pile) = game.piles.get(&dest)
if can_place_on_foundation(card, dest_pile, suit) { && can_place_on_foundation(card, dest_pile, suit) {
hints.push((from.clone(), dest, 1)); hints.push((from.clone(), dest, 1));
// Each source card can go to at most one foundation suit; // Each source card can go to at most one foundation suit;
// no need to check the remaining three for this card. // no need to check the remaining three for this card.
break; break;
} }
}
} }
} }
@@ -1340,15 +1333,14 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
if dest == *from { if dest == *from {
continue; continue;
} }
if let Some(dest_pile) = game.piles.get(&dest) { if let Some(dest_pile) = game.piles.get(&dest)
if can_place_on_tableau(card, dest_pile) { && can_place_on_tableau(card, dest_pile) {
hints.push((from.clone(), dest, 1)); hints.push((from.clone(), dest, 1));
// One tableau destination per source card is enough for the // One tableau destination per source card is enough for the
// hint list — the player can see where else a card can go // hint list — the player can see where else a card can go
// via the right-click destination highlights. // via the right-click destination highlights.
break; break;
} }
}
} }
} }
@@ -1423,7 +1415,7 @@ mod tests {
// In tableau 6, the visually topmost card is the last (face-up) one. // In tableau 6, the visually topmost card is the last (face-up) one.
// Its position: base.y + fan * 6. // 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"); let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
assert_eq!(result.0, PileType::Tableau(6)); assert_eq!(result.0, PileType::Tableau(6));
assert_eq!(result.1, 6); assert_eq!(result.1, 6);
@@ -1439,7 +1431,7 @@ mod tests {
// position of the bottom face-down card (index 0) should miss — // position of the bottom face-down card (index 0) should miss —
// that card is face-down and the topmost face-up card overlaps at // that card is face-down and the topmost face-up card overlaps at
// a different fanned position. // 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. // 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 below_bottom = bottom_pos - Vec2::new(0.0, layout.card_size.y * 0.4);
let result = find_draggable_at(below_bottom, &game, &layout); 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 // (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 // 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. // 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 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"); let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
assert_eq!(pile, PileType::Tableau(0)); assert_eq!(pile, PileType::Tableau(0));
@@ -1507,7 +1499,7 @@ mod tests {
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0));
// Both cards in waste sit at the same (x, y). Clicking should pick // Both cards in waste sit at the same (x, y). Clicking should pick
// the visually top card (id 201), with count = 1. // 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"); let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
assert_eq!(pile, PileType::Waste); assert_eq!(pile, PileType::Waste);
assert_eq!(start, 1); assert_eq!(start, 1);
+2 -3
View File
@@ -123,15 +123,14 @@ fn toggle_leaderboard_screen(
spawn_leaderboard_screen(&mut commands, data.0.as_deref()); spawn_leaderboard_screen(&mut commands, data.0.as_deref());
// Start a background fetch if not already in flight. // Start a background fetch if not already in flight.
if task_res.0.is_none() { if task_res.0.is_none()
if let Some(p) = provider { && let Some(p) = provider {
let provider = p.0.clone(); let provider = p.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move { let task = AsyncComputeTaskPool::get().spawn(async move {
provider.fetch_leaderboard().await.map_err(|e| e.to_string()) provider.fetch_leaderboard().await.map_err(|e| e.to_string())
}); });
task_res.0 = Some(task); task_res.0 = Some(task);
} }
}
} }
/// Poll the background fetch task; store results when complete. /// Poll the background fetch task; store results when complete.
+4 -2
View File
@@ -6,6 +6,7 @@ pub mod animation_plugin;
pub mod auto_complete_plugin; pub mod auto_complete_plugin;
pub mod audio_plugin; pub mod audio_plugin;
pub mod card_plugin; pub mod card_plugin;
pub mod font_plugin;
pub mod feedback_anim_plugin; pub mod feedback_anim_plugin;
pub mod challenge_plugin; pub mod challenge_plugin;
pub mod cursor_plugin; pub mod cursor_plugin;
@@ -59,9 +60,10 @@ pub use feedback_anim_plugin::{
pub use auto_complete_plugin::AutoCompletePlugin; pub use auto_complete_plugin::AutoCompletePlugin;
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary}; pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use card_plugin::{ pub use card_plugin::{
CardEntity, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer, RightClickHighlight, CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
RightClickHighlightTimer, RightClickHighlight, RightClickHighlightTimer,
}; };
pub use font_plugin::{FontPlugin, FontResource};
pub use cursor_plugin::CursorPlugin; pub use cursor_plugin::CursorPlugin;
pub use events::{ pub use events::{
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
+8 -13
View File
@@ -103,13 +103,12 @@ fn toggle_pause(
// If a drag is in progress, cancel it instead of opening the pause overlay. // If a drag is in progress, cancel it instead of opening the pause overlay.
// Clearing DragState and emitting StateChangedEvent snaps the dragged cards // Clearing DragState and emitting StateChangedEvent snaps the dragged cards
// back to their resting positions exactly as a rejected drop does. // back to their resting positions exactly as a rejected drop does.
if let Some(ref mut d) = drag { if let Some(ref mut d) = drag
if !d.is_idle() { && !d.is_idle() {
d.clear(); d.clear();
changed.write(StateChangedEvent); changed.write(StateChangedEvent);
return; return;
} }
}
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
paused.0 = false; paused.0 = false;
@@ -122,13 +121,11 @@ fn toggle_pause(
paused.0 = true; paused.0 = true;
// Persist the current game state whenever the player opens the pause // Persist the current game state whenever the player opens the pause
// overlay so an OS-level kill still leaves a resumable save. // overlay so an OS-level kill still leaves a resumable save.
if let (Some(g), Some(p)) = (game, path) { if let (Some(g), Some(p)) = (game, path)
if let Some(disk_path) = p.0.as_deref() { && let Some(disk_path) = p.0.as_deref()
if let Err(e) = save_game_state_to(disk_path, &g.0) { && let Err(e) = save_game_state_to(disk_path, &g.0) {
warn!("game_state: failed to save on pause: {e}"); warn!("game_state: failed to save on pause: {e}");
} }
}
}
} }
} }
@@ -155,13 +152,11 @@ fn handle_pause_draw_toggle(
DrawMode::DrawOne => DrawMode::DrawThree, DrawMode::DrawOne => DrawMode::DrawThree,
DrawMode::DrawThree => DrawMode::DrawOne, DrawMode::DrawThree => DrawMode::DrawOne,
}; };
if let Some(p) = &path { if let Some(p) = &path
if let Some(target) = &p.0 { && let Some(target) = &p.0
if let Err(e) = solitaire_data::save_settings_to(target, &settings.0) { && let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
warn!("failed to save settings after draw-mode toggle: {e}"); warn!("failed to save settings after draw-mode toggle: {e}");
} }
}
}
changed.write(SettingsChangedEvent(settings.0.clone())); changed.write(SettingsChangedEvent(settings.0.clone()));
} }
} }
-1
View File
@@ -260,7 +260,6 @@ fn sync_info(backend: &SyncBackend) -> (&'static str, String) {
SyncBackend::SolitaireServer { username, .. } => { SyncBackend::SolitaireServer { username, .. } => {
("Solitaire Server", username.clone()) ("Solitaire Server", username.clone())
} }
SyncBackend::GooglePlayGames => ("Google Play Games", "".to_string()),
} }
} }
+2 -3
View File
@@ -101,11 +101,10 @@ fn award_xp_on_win(
total_xp: progress.0.total_xp, total_xp: progress.0.total_xp,
}); });
} }
if let Some(target) = &path.0 { if let Some(target) = &path.0
if let Err(e) = save_progress_to(target, &progress.0) { && let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress: {e}"); warn!("failed to save progress: {e}");
} }
}
} }
} }

Some files were not shown because too many files have changed in this diff Show More