diff --git a/assets/audio/ambient_loop.wav b/assets/audio/ambient_loop.wav new file mode 100644 index 0000000..466304a Binary files /dev/null and b/assets/audio/ambient_loop.wav differ diff --git a/docs/android_investigation.md b/docs/android_investigation.md new file mode 100644 index 0000000..8739414 --- /dev/null +++ b/docs/android_investigation.md @@ -0,0 +1,247 @@ +# Android Port Investigation + +> **Date:** 2026-04-28 +> **Author:** Claude Code +> **Scope:** Feasibility analysis for porting Solitaire Quest to Android using cargo-mobile2 + +--- + +## Summary + +A working Android port is feasible but not trivial. The core game logic (`solitaire_core`, `solitaire_sync`) compiles to Android without changes. Every other crate requires at least minor surgery. The biggest blockers are the `keyring` crate (no Android backend), the `kira`/`AudioManager` audio stack (`DefaultBackend` uses CPAL which targets desktop), and the `dirs` crate returning `None` on Android in its current usage. Touch input already has a solid foundation in `input_plugin.rs`. Estimated effort from a clean Android toolchain is **12–18 developer-days** to reach a playable-but-rough state. + +--- + +## 1. Bevy on Android — Current Status + +Bevy's Android support is community-maintained via the `winit` backend and is usable but carries known rough edges as of the 0.15/0.16 generation. + +**What works:** +- Basic rendering via Vulkan (through `wgpu`). OpenGL ES fallback is available for older devices. +- Touch input events: Bevy's `TouchInput` events and the `Touches` resource are populated from Android `MotionEvent`s via `winit`. The existing `touch_start_drag`, `touch_follow_drag`, `touch_end_drag`, and `handle_touch_stock_tap` systems in `input_plugin.rs` will function correctly — this was already written with multi-touch in mind and uses `TouchPhase::Started/Moved/Ended/Canceled` cleanly. +- Bevy UI (the `bevy::ui` module used for all overlays). +- `WindowResized` events fire correctly, so the layout system will recompute for any screen size. + +**What does not work / needs attention:** +- **`bevy/dynamic_linking`**: The dynamic linking feature must be stripped from any Android build profile. Dynamic linking is a desktop-only development shortcut; Android requires static linking. +- **Fixed window size**: `main.rs` sets `resolution: (1280u32, 800u32)`. On Android the window is always the full display. This value is harmlessly overridden by the OS, but `min_width`/`min_height` constraints should be removed or set to 0 for Android to avoid Winit warnings. +- **`F11` fullscreen toggle** (`handle_fullscreen` in `input_plugin.rs`): `WindowMode::BorderlessFullscreen` is desktop-only. On Android it should be a no-op. +- **Keyboard shortcuts**: The entire `handle_keyboard_core`, `handle_keyboard_hint`, `handle_keyboard_forfeit` systems are desktop-only workflows. They will not crash, but they are dead code on Android. No touchscreen replacement for Undo (U), New Game (N), Draw (D/Space), Hint (H), Forfeit (G) exists yet — these need an on-screen UI. +- **`CursorPlugin`**: The custom cursor sprite plugin is irrelevant on Android (no cursor). Harmless to leave registered, but it uses `PrimaryWindow` cursor APIs that may panic or warn on Android. + +**cargo-mobile2 integration for Bevy:** +The standard path is: +1. Install `cargo-mobile2`: `cargo install --locked cargo-mobile2` +2. Run `cargo mobile init` in the workspace root. This generates an `android/` directory with the Gradle project, `AndroidManifest.xml`, and JNI glue. +3. cargo-mobile2 targets the `solitaire_app` binary crate (the thin entry point). The generated `lib.rs` shim calls `android_main` via `bevy::winit`'s Android entry point. +4. The `solitaire_app` crate needs a `[lib]` target added alongside the existing `[[bin]]`, with `crate-type = ["cdylib"]`, used only when building for Android. + +**Required `Cargo.toml` changes (workspace level):** +```toml +[target.'cfg(target_os = "android")'.dependencies] +# android_logger and ndk-glue wiring are handled by cargo-mobile2's generated shim. +# No direct ndk-glue dependency is needed in app code when using Bevy + cargo-mobile2. +``` + +**NDK version:** Android NDK r25c or r26 LTS is the tested range for `wgpu`/Vulkan on Android. NDK r27+ may work but has had compatibility reports with CPAL. Set `ANDROID_NDK_ROOT` to the NDK root; the minimum API level should be 26 (Android 8.0) for Vulkan stability. + +--- + +## 2. Audio — `kira` + `DefaultBackend` + +**The problem:** +`solitaire_engine/src/audio_plugin.rs` creates an `AudioManager`. `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::::new(AudioManagerSettings::default()).ok(); +if manager.is_none() { + warn!("audio device unavailable; SFX disabled"); +} +``` +This means if the audio manager fails to initialise on Android, the game continues silently. This is acceptable as a first-pass fallback. + +**What is needed for working audio on Android:** +- Add `kira` dependency with `cpal` backend enabled for Android: The `kira` workspace dependency currently specifies `version = "0.12"`. Verify that `kira/Cargo.toml` exposes a `cpal` feature (or that `DefaultBackend` compiles on Android targets with NDK). If not, a `CpalBackend` with `cpal = { features = ["oboe"] }` may be needed. +- The `NonSend` resource `AudioState` should compile fine — `NonSend` is legal in Bevy Android builds. +- `include_bytes!` for the WAV assets is compile-time and unaffected by platform. + +**Recommendation:** Defer full audio verification to a device test. The graceful fallback means a silent-but-working first build is achievable without resolving this. + +--- + +## 3. `keyring` Crate — No Android Backend + +**The problem:** +`keyring = "2"` is used in `solitaire_data/src/auth_tokens.rs` to store JWT access and refresh tokens in the OS keychain. The `keyring` crate's Android backend does not exist — as of v2.x, supported backends are: macOS Keychain, Windows Credential Manager, Linux Secret Service (D-Bus), and iOS Keychain. There is no Android KeyStore backend. + +On Android, `Entry::new(...)` will return `keyring::Error::NoStorageAccess`, which the existing code already maps to `TokenError::KeychainUnavailable`. So the code will not crash — it will simply fail every token store/load operation. + +**Current failure mode:** +Every call to `store_tokens`, `load_access_token`, `load_refresh_token`, or `delete_tokens` will return `Err(TokenError::KeychainUnavailable(...))`. The sync client in `sync_client.rs` needs to be verified to handle this gracefully rather than propagating an error that disables sync entirely. + +**Options for Android credential storage:** + +| Option | Security | Effort | Notes | +|---|---|---|---| +| **In-memory only (prompt re-login each session)** | N/A | 1 day | Simplest. On `TokenError::KeychainUnavailable`, the `SyncProvider` returns `SyncError::Auth`, user is prompted to log in. Already architecturally supported. | +| **Encrypted `SharedPreferences` equivalent via JNI** | Good | 4–6 days | Call Android's `EncryptedSharedPreferences` (Jetpack Security) via JNI. Significant JNI boilerplate. | +| **AES-256 file encryption using Android Keystore via JNI** | Excellent | 5–8 days | Proper Android keychain equivalent. Complex JNI. | +| **Store in app-private file, unencrypted** | Poor | 0.5 days | Only acceptable during development. Never ship. | + +**Recommended approach (first pass):** Use the in-memory / re-login-each-session path. The existing `TokenError::KeychainUnavailable` variant already exists for exactly this reason (Linux without a running secret service). The `SyncPlugin` should detect this on startup and present a "Sync unavailable — please log in" message rather than a hard error. This requires: +1. Conditional compilation: when `cfg(target_os = "android")`, replace the `keyring` calls with a no-op in-memory store (a simple `Mutex>`). +2. A `#[cfg(not(target_os = "android"))]` guard on the `keyring` import/dependency in `solitaire_data/Cargo.toml`. + +**Required `solitaire_data/Cargo.toml` change:** +```toml +[target.'cfg(not(target_os = "android"))'.dependencies] +keyring = { workspace = true } + +[target.'cfg(target_os = "android")'.dependencies] +# keyring is replaced by in-memory storage; no dependency needed +``` + +--- + +## 4. `dirs` Crate — Data Directory on Android + +**The problem:** +`storage.rs` and other persistence modules use `dirs::data_dir()` to locate `~/.local/share/solitaire_quest/` (or platform equivalent). On Android, `dirs::data_dir()` returns `None` because there is no `XDG_DATA_HOME` and the `dirs` crate does not implement an Android-specific path. + +**Current code behavior:** +All persistence functions already handle `None` gracefully (returning default values or `Err`), consistent with the CLAUDE.md lesson about `dirs::data_dir()`. Stats and progress will silently not persist across sessions if `data_dir()` returns `None`. + +**Fix required:** +Android apps should store private data in the app's internal storage directory, obtained via JNI: `context.getFilesDir()`. This requires either: +- A thin JNI helper (via `jni` crate) called once on startup to obtain the path and store it as a global. +- Or passing the path in via the `android_main` entry point using `cargo-mobile2`'s `AndroidApp` handle, which exposes `internal_data_path()`. + +The `cargo-mobile2` + Bevy path exposes an `AndroidApp` via `bevy::winit`'s Android entry point. Bevy 0.13+ passes `AndroidApp` through `WinitPlugin`, and it is accessible via a Bevy resource. A startup system can extract `app.internal_data_path()` and insert a `PlatformDataDirResource` that the storage functions read instead of calling `dirs::data_dir()`. + +**Effort:** 1–2 days to implement the override and thread it through all `storage.rs` / `progress.rs` / `settings.rs` / `achievements.rs` call sites. + +--- + +## 5. Touch Input — Current State and Gaps + +**What already exists (strong foundation):** + +The `InputPlugin` in `input_plugin.rs` has a complete parallel touch pipeline: + +| System | Purpose | Status | +|---|---|---| +| `handle_touch_stock_tap` | Tap the stock pile to draw | Complete | +| `touch_start_drag` | Begin a touch drag on a face-up card | Complete | +| `touch_follow_drag` | Move card(s) with the active finger | Complete | +| `touch_end_drag` | Resolve the drag (move or reject) | Complete | + +The touch systems use `TouchInput` events and the `Touches` resource, map touch IDs to `DragState.active_touch_id` to prevent multi-finger conflicts, and share the same `DragState`, `MoveRequestEvent`, `MoveRejectedEvent`, and `StateChangedEvent` infrastructure as the mouse pipeline. The drag threshold (`tuning.drag_threshold_px`) applies identically. + +**Gaps for a production Android experience:** + +1. **No double-tap equivalent for auto-move**: `handle_double_click` is mouse-only. Android users need a double-tap to trigger the same "move to best destination" logic. The `handle_double_click` system checks `buttons.just_pressed(MouseButton::Left)` and will be inert on Android. Estimated: 1 day. + +2. **No touch equivalent for keyboard actions**: Undo, New Game, Draw (when stock is visible but tapping it is awkward), Hint, and Forfeit have no on-screen buttons. These need an Android-specific UI bar or gesture (e.g. two-finger tap for undo). Estimated: 2–3 days for a minimal floating action button strip. + +3. **Drag threshold tuning**: The threshold is in `AnimationTuning` (`tuning.drag_threshold_px`). Touch screens typically need a larger threshold than mouse (physical screens have more accidental movement during a tap). The current value should be evaluated on a real device and likely increased for touch. + +4. **No long-press for right-click equivalent**: The right-click highlight/hint glow (`HintHighlightTimer`) is triggered via right mouse button. Long-press detection is not yet implemented. This is a missing feature but not a blocker for basic play. + +5. **`handle_double_click` uses `LocalDateTime`-based timing via `Time`**: This will work on Android, but `DOUBLE_CLICK_WINDOW = 0.35s` may feel too tight on touch. Should be configurable. + +--- + +## 6. Additional Issues Not in Scope of the Four Research Areas + +**`CursorPlugin`:** Uses Bevy's cursor APIs which are desktop-only. Should be conditionally compiled out on Android with `#[cfg(not(target_os = "android"))]`. + +**`reqwest` with `rustls-native-certs`:** The `reqwest` dependency uses `rustls` with native root certificates. On Android, `rustls-native-certs` reads system certificates differently (via the `android_system_properties` crate internally). This generally works but should be tested; Android's certificate store is in a non-standard location vs Linux. + +**App lifecycle (suspend/resume):** Android can suspend the process at any time. Bevy handles `WindowEvent::Suspended` and `WindowEvent::Resumed` via `winit`, pausing the render loop. The `SyncPlugin`'s "push on exit" path (`AppExit` event) should also trigger on `WindowEvent::Suspended` to avoid data loss when the user backgrounds the app. This is a separate feature (1 day). + +**No `sqlx` on Android:** `solitaire_server` is a server binary and is never built for Android. The `sqlx` dependency only exists in `solitaire_server/Cargo.toml` and will not affect Android builds of the client crates. + +**`solitaire_assetgen`:** The asset generation tool is desktop-only and not part of the client build. Unaffected. + +--- + +## 7. Required Changes Per Crate + +### `solitaire_core` and `solitaire_sync` +No changes required. Both are pure Rust with no platform dependencies. + +### `solitaire_data` +| Change | Effort | +|---|---| +| Gate `keyring` dependency on `#[cfg(not(target_os = "android"))]` | 0.5 days | +| Implement `auth_tokens.rs` in-memory fallback for Android | 1 day | +| Add `internal_data_path()` override for `dirs::data_dir()` on Android | 1.5 days | +| Audit all `dirs::data_dir()` / `settings_file_path()` call sites to accept injected path | 0.5 days | + +### `solitaire_engine` +| Change | Effort | +|---|---| +| Conditionally disable `CursorPlugin` on Android | 0.5 days | +| Disable `handle_fullscreen` on Android (or make it a no-op) | 0.25 days | +| Implement double-tap for auto-move (touch equivalent of `handle_double_click`) | 1 day | +| On-screen action bar for Undo, New Game, Hint (minimal floating buttons) | 2.5 days | +| Tune drag threshold for touch; expose as a platform-specific tuning constant | 0.5 days | +| Trigger sync push on `WindowEvent::Suspended` in `SyncPlugin` | 1 day | +| Verify `kira` audio on Android (test `DefaultBackend` / CPAL; implement fallback if needed) | 1–2 days | + +### `solitaire_app` +| Change | Effort | +|---|---| +| Add `[lib]` target with `crate-type = ["cdylib"]` for Android builds | 0.25 days | +| Create `src/lib.rs` (or `src/android.rs`) Android entry point calling `android_main` | 0.5 days | +| Remove or guard fixed `resolution` / `resize_constraints` for Android | 0.25 days | +| Pass `AndroidApp::internal_data_path()` to a startup resource | 0.5 days | + +### Build / Toolchain +| Change | Effort | +|---|---| +| Install cargo-mobile2, Android NDK r25c/r26, `aarch64-linux-android` target | 1 day | +| Run `cargo mobile init`, configure `android/` Gradle project | 0.5 days | +| Get a first build compiling (resolve linker / NDK issues) | 1–2 days | + +--- + +## 8. Estimated Effort + +| Phase | Description | Days | +|---|---|---| +| Toolchain setup | NDK, cargo-mobile2, first compile | 2–3 | +| `solitaire_data` Android adaptations | keyring fallback, data dir | 3 | +| `solitaire_app` Android entry point | cdylib, AndroidApp wiring | 1 | +| `solitaire_engine` guards and fixes | cursor, fullscreen, audio verify | 2–3 | +| Touch UX improvements | double-tap, action bar, threshold tuning | 4–5 | +| Testing on real device / emulator | iteration, lifecycle edge cases | 2–3 | +| **Total** | | **14–17 days** | + +This produces a playable, functionally complete Android build. It does not include Play Store preparation (signing keys, metadata, icon set, permissions manifest tuning) which would add 1–2 more days. + +--- + +## 9. Recommended First Step + +**Get the workspace to compile for `aarch64-linux-android` without running.** + +This surfaces all the real linker and dependency errors before writing any gameplay code: + +```bash +# Install toolchain +rustup target add aarch64-linux-android +cargo install --locked cargo-mobile2 + +# In the workspace root: +cargo mobile init # generates android/ directory + +# Attempt a library build targeting Android +cargo build -p solitaire_app --target aarch64-linux-android 2>&1 | head -60 +``` + +The first build will fail on `keyring` (no Android backend) and likely on `dirs`. Fixing those two in `solitaire_data` — gate `keyring` behind `cfg(not(target_os = "android"))` and stub the data directory — will probably get the workspace to a clean compile. From there, the path to a running APK is incremental. + +Do not attempt to resolve audio or touch UX until the build compiles cleanly. Compile errors are the only true blockers; the rest are feature gaps. diff --git a/docs/sync_test_runbook.md b/docs/sync_test_runbook.md new file mode 100644 index 0000000..1d3cbf3 --- /dev/null +++ b/docs/sync_test_runbook.md @@ -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":""}' | 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":""}' | 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":""}' | 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":""}' | 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. diff --git a/solitaire_assetgen/src/bin/gen_sfx.rs b/solitaire_assetgen/src/bin/gen_sfx.rs index 3b70d99..a64395f 100644 --- a/solitaire_assetgen/src/bin/gen_sfx.rs +++ b/solitaire_assetgen/src/bin/gen_sfx.rs @@ -16,12 +16,13 @@ fn main() -> io::Result<()> { let out_dir = workspace_root().join("assets").join("audio"); fs::create_dir_all(&out_dir)?; - let effects: [(&str, Generator); 5] = [ + let effects: [(&str, Generator); 6] = [ ("card_flip.wav", card_flip), ("card_place.wav", card_place), ("card_deal.wav", card_deal), ("card_invalid.wav", card_invalid), ("win_fanfare.wav", win_fanfare), + ("ambient_loop.wav", ambient_loop), ]; for (name, gen) in &effects { @@ -169,6 +170,64 @@ fn win_fanfare() -> Vec { 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 { + use std::f32::consts::PI; + + // LFO period = 10 s; fundamental period ≈ 18.18 ms. + // We want a loop that is an exact integer multiple of both, so both + // complete a whole number of cycles with no phase discontinuity. + // + // LCM approach: fundamental @ 55 Hz repeats every 1/55 s. The LFO @ 0.1 Hz + // repeats every 10 s. 10 s is already a multiple of 1/55 s (10 * 55 = 550 + // cycles), so a 10-second buffer loops perfectly. We halve it to 5 s for + // a smaller file — 5 * 55 = 275 (integer), 5 * 0.1 = 0.5 (half-cycle of + // LFO). To keep a full LFO cycle we use 10 s but write only the first 5 s + // of the waveform, which is within the 4–8 s budget and still a seamless + // loop because the LFO amplitude is symmetric about its midpoint at t=5 s. + // + // Simpler explanation: at exactly 5 s, both the 55 Hz tone and a slow + // 0.2 Hz (period=5 s) breath LFO complete an integer number of cycles. + // We use 0.2 Hz for the LFO instead of 0.1 Hz so the full envelope fits + // in one loop period. + let lfo_freq = 0.2_f32; // 1 full LFO cycle per 5-second loop + let loop_seconds = 1.0 / lfo_freq; // = 5.0 s + let n = (loop_seconds * SAMPLE_RATE as f32) as usize; + + let f0 = 55.0_f32; // fundamental (Hz) + let f1 = 110.0_f32; // 2nd harmonic + let f2 = 165.0_f32; // 3rd harmonic + + let mut out = Vec::with_capacity(n); + for i in 0..n { + let t = i as f32 / SAMPLE_RATE as f32; + + // LFO: smoothly oscillates between 0.4 and 1.0 amplitude. + // Using (1 - cos) / 2 instead of sin so the loop starts and ends at + // the same LFO phase (0.0 → both sin and cos are fully periodic). + let lfo = 0.7 + 0.3 * (2.0 * PI * lfo_freq * t).cos(); + + // Layered harmonics + let tone = (2.0 * PI * f0 * t).sin() + + 0.4 * (2.0 * PI * f1 * t).sin() + + 0.2 * (2.0 * PI * f2 * t).sin(); + + // Normalise the layered sum: max raw peak ≈ 1.6; keep final peak ≤ 0.18 + let sample = tone / 1.6 * lfo * 0.18; + out.push(quantize(sample)); + } + out +} + // --------------------------------------------------------------------------- // Minimal WAV writer (mono 16-bit PCM) // --------------------------------------------------------------------------- diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index 15d3fd2..a632bd8 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -364,6 +364,10 @@ impl SyncProvider for SolitaireServerClient { /// Deserialize a pull response body as [`SyncResponse`] and return its /// `merged` field, or map non-200 statuses to the appropriate [`SyncError`]. +/// +/// Only HTTP 401 (Unauthorized) and 403 (Forbidden) are treated as +/// authentication errors. All other non-2xx statuses (5xx, 429, etc.) are +/// classified as network/transport errors so the UI shows the right message. async fn extract_pull_body(resp: reqwest::Response) -> Result { let status = resp.status(); if status.is_success() { @@ -372,8 +376,12 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result Result Result { let status = resp.status(); if status.is_success() { resp.json() .await .map_err(|e| SyncError::Serialization(e.to_string())) - } else { + } else if status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN + { Err(SyncError::Auth(format!("server returned {status}"))) + } else { + Err(SyncError::Network(format!("server returned {status}"))) } } diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index 562e32f..caabf9f 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -274,9 +274,8 @@ fn handle_win_cascade( let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score); spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS); - let speed = settings.as_ref().map(|s| s.0.animation_speed.clone()); - let step = speed.clone().map(cascade_step_secs).unwrap_or(CASCADE_STAGGER_NORMAL); - let duration = speed.map(cascade_duration_secs).unwrap_or(CASCADE_DURATION_NORMAL); + let step = settings.as_ref().map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed.clone())); + let duration = settings.as_ref().map_or(CASCADE_DURATION_NORMAL, |s| cascade_duration_secs(s.0.animation_speed.clone())); for (i, (entity, transform)) in cards.iter().enumerate() { commands.entity(entity).insert(CardAnim { diff --git a/solitaire_engine/src/audio_plugin.rs b/solitaire_engine/src/audio_plugin.rs index 278cc90..6fed715 100644 --- a/solitaire_engine/src/audio_plugin.rs +++ b/solitaire_engine/src/audio_plugin.rs @@ -11,9 +11,8 @@ //! | `NewGameRequestEvent` | `card_deal.wav` | //! | `GameWonEvent` | `win_fanfare.wav` | //! -//! An ambient loop is started at plugin startup using `card_flip.wav` at very -//! low volume (0.05 amplitude) routed through `music_track` as a placeholder -//! until a dedicated ambient track is available. +//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very +//! low volume (0.05 amplitude) routed through `music_track`. //! //! If the audio device cannot be opened (e.g. a headless CI machine or a //! Linux box without a running PulseAudio/Pipewire session), the plugin @@ -121,8 +120,8 @@ impl Plugin for AudioPlugin { None => (None, None), }; - // Start the ambient loop placeholder (card_flip.wav looped at very low - // volume through music_track). + // Start the ambient loop (ambient_loop.wav looped at very low volume + // through music_track). let ambient_handle = start_ambient_loop(manager.as_mut(), library.as_ref(), &mut music_track); @@ -190,20 +189,27 @@ fn decode(bytes: &'static [u8]) -> Option { } } -/// Starts the ambient music loop placeholder (`card_flip.wav` looped at very -/// low volume) routed through `music_track`. Returns the handle so it can be -/// stored in `AudioState` for future pause/stop control. +/// Decodes the embedded `ambient_loop.wav` and starts it as a seamlessly +/// looping ambient track routed through `music_track`. Returns the handle so +/// it can be stored in `AudioState` for future pause/stop control. /// -/// Returns `None` when audio is unavailable or the library failed to load. +/// Returns `None` when audio is unavailable or the WAV fails to decode. fn start_ambient_loop( manager: Option<&mut AudioManager>, - library: Option<&SoundLibrary>, + _library: Option<&SoundLibrary>, music_track: &mut Option, ) -> Option { let manager = manager?; - let lib = library?; - let mut data = lib.flip.clone(); + let ambient_bytes: &'static [u8] = + include_bytes!("../../assets/audio/ambient_loop.wav"); + let mut data = match StaticSoundData::from_cursor(Cursor::new(ambient_bytes.to_vec())) { + Ok(d) => d, + Err(e) => { + warn!("failed to decode ambient_loop.wav: {e}"); + return None; + } + }; data.settings.loop_region = Some(Region::default()); data.settings.volume = Value::Fixed(amplitude_to_decibels(AMBIENT_VOLUME as f32)); diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index ba33ff3..1e02376 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -265,18 +265,18 @@ fn sync_cards( match existing.get(&card.id) { Some(&(entity, cur)) => { update_card_entity( - &mut commands, entity, &card, position, z, layout, + &mut commands, entity, card, position, z, layout, slide_secs, back_colour, color_blind, cur, ) } - 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), } } } /// Returns an ordered vec of (card, position, z) for every card in the game. -fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> { - let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52); +fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Vec2, f32)> { + let mut out: Vec<(&'a Card, Vec2, f32)> = Vec::with_capacity(52); let piles = [ PileType::Stock, PileType::Waste, @@ -331,7 +331,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> { }; let pos = Vec2::new(base.x + x_offset, base.y + y_offset); let z = 1.0 + (slot as f32) * STACK_FAN_FRAC; - out.push((card.clone(), pos, z)); + out.push((card, pos, z)); if is_tableau { let step = if card.face_up { TABLEAU_FAN_FRAC diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 871cf5c..390fe26 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -531,7 +531,7 @@ fn start_drag( return; }; - let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index); + let bottom_pos = card_position(&game.0, &layout.0, &pile, stack_index); // Store as a pending drag. We do NOT elevate the cards yet — the visual // lift happens in follow_drag once the threshold is crossed. @@ -760,7 +760,7 @@ fn touch_start_drag( continue; }; - let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index); + let bottom_pos = card_position(&game.0, &layout.0, &pile, stack_index); drag.cards = card_ids; drag.origin_pile = Some(pile); @@ -971,8 +971,8 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool { } /// Where a card at `stack_index` in pile `pile` would be rendered. -fn card_position(game: &GameState, layout: &Layout, pile: PileType, stack_index: usize) -> Vec2 { - let base = layout.pile_positions[&pile]; +fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 { + let base = layout.pile_positions[pile]; if matches!(pile, PileType::Tableau(_)) { let fan = -layout.card_size.y * TABLEAU_FAN_FRAC; Vec2::new(base.x, base.y + fan * (stack_index as f32)) @@ -980,7 +980,7 @@ fn card_position(game: &GameState, layout: &Layout, pile: PileType, stack_index: // In Draw-Three mode the top 3 waste cards are fanned in X to match // card_plugin::card_positions(). Hit-testing must use the same offsets // so clicking the visually rightmost (top) card actually registers. - let pile_len = game.piles.get(&pile).map_or(0, |p| p.cards.len()); + let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len()); let visible_start = pile_len.saturating_sub(3); let slot = stack_index.saturating_sub(visible_start) as f32; Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y) @@ -1039,7 +1039,7 @@ fn find_draggable_at( if !card.face_up { continue; } - let pos = card_position(game, layout, pile.clone(), i); + let pos = card_position(game, layout, &pile, i); if !point_in_rect(cursor, pos, layout.card_size) { continue; } @@ -1423,7 +1423,7 @@ mod tests { // In tableau 6, the visually topmost card is the last (face-up) one. // Its position: base.y + fan * 6. - let top_pos = card_position(&game, &layout, PileType::Tableau(6), 6); + let top_pos = card_position(&game, &layout, &PileType::Tableau(6), 6); let result = find_draggable_at(top_pos, &game, &layout).expect("hit"); assert_eq!(result.0, PileType::Tableau(6)); assert_eq!(result.1, 6); @@ -1439,7 +1439,7 @@ mod tests { // position of the bottom face-down card (index 0) should miss — // that card is face-down and the topmost face-up card overlaps at // a different fanned position. - let bottom_pos = card_position(&game, &layout, PileType::Tableau(6), 0); + let bottom_pos = card_position(&game, &layout, &PileType::Tableau(6), 0); // Shift to avoid accidental overlap with the face-up card above it. let below_bottom = bottom_pos - Vec2::new(0.0, layout.card_size.y * 0.4); let result = find_draggable_at(below_bottom, &game, &layout); @@ -1477,7 +1477,7 @@ mod tests { // (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the // Queen we click in her visible strip: the 0.25h band above the Jack's top // edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h. - let queen_center = card_position(&game, &layout, PileType::Tableau(0), 1); + let queen_center = card_position(&game, &layout, &PileType::Tableau(0), 1); let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375); let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); assert_eq!(pile, PileType::Tableau(0)); @@ -1507,7 +1507,7 @@ mod tests { let layout = compute_layout(Vec2::new(1280.0, 800.0)); // Both cards in waste sit at the same (x, y). Clicking should pick // the visually top card (id 201), with count = 1. - let pos = card_position(&game, &layout, PileType::Waste, 0); + let pos = card_position(&game, &layout, &PileType::Waste, 0); let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); assert_eq!(pile, PileType::Waste); assert_eq!(start, 1); diff --git a/solitaire_engine/src/sync_plugin.rs b/solitaire_engine/src/sync_plugin.rs index 1065ae6..dcdf889 100644 --- a/solitaire_engine/src/sync_plugin.rs +++ b/solitaire_engine/src/sync_plugin.rs @@ -198,13 +198,17 @@ fn poll_pull_result( progress.0 = merged.progress; status.0 = SyncStatus::LastSynced(Utc::now()); } + Err(SyncError::UnsupportedPlatform) => { + // No backend configured — not an error, just leave status as Idle. + status.0 = SyncStatus::Idle; + } Err(e) => { warn!("sync pull failed: {e}"); let msg = match &e { SyncError::Network(_) => "Can't reach server — check your connection".to_string(), SyncError::Auth(_) => "Login expired — tap Sync Now after re-logging in".to_string(), SyncError::Serialization(_) => format!("Unexpected server response: {e}"), - SyncError::UnsupportedPlatform => "Sync not configured".to_string(), + SyncError::UnsupportedPlatform => unreachable!("handled above"), }; status.0 = SyncStatus::Error(msg); } @@ -233,13 +237,14 @@ fn push_on_exit( // Prefer an existing tokio runtime; fall back to futures_lite block_on // for environments (e.g. tests) that don't have one. - match tokio::runtime::Handle::try_current() { - Ok(handle) => { - let _ = handle.block_on(provider.push(&payload)); - } - Err(_) => { - let _ = future::block_on(provider.push(&payload)); - } + let result = match tokio::runtime::Handle::try_current() { + Ok(handle) => handle.block_on(provider.push(&payload)), + Err(_) => future::block_on(provider.push(&payload)), + }; + if let Err(e) = result { + // Log push failures on exit so they appear in crash/log reports. + // We cannot surface them to the UI at this point (game loop is done). + warn!("sync push on exit failed: {e}"); } }