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>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
# Android Port Investigation
|
||||
|
||||
> **Date:** 2026-04-28
|
||||
> **Author:** Claude Code
|
||||
> **Scope:** Feasibility analysis for porting Solitaire Quest to Android using cargo-mobile2
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
A working Android port is feasible but not trivial. The core game logic (`solitaire_core`, `solitaire_sync`) compiles to Android without changes. Every other crate requires at least minor surgery. The biggest blockers are the `keyring` crate (no Android backend), the `kira`/`AudioManager` audio stack (`DefaultBackend` uses CPAL which targets desktop), and the `dirs` crate returning `None` on Android in its current usage. Touch input already has a solid foundation in `input_plugin.rs`. Estimated effort from a clean Android toolchain is **12–18 developer-days** to reach a playable-but-rough state.
|
||||
|
||||
---
|
||||
|
||||
## 1. Bevy on Android — Current Status
|
||||
|
||||
Bevy's Android support is community-maintained via the `winit` backend and is usable but carries known rough edges as of the 0.15/0.16 generation.
|
||||
|
||||
**What works:**
|
||||
- Basic rendering via Vulkan (through `wgpu`). OpenGL ES fallback is available for older devices.
|
||||
- Touch input events: Bevy's `TouchInput` events and the `Touches` resource are populated from Android `MotionEvent`s via `winit`. The existing `touch_start_drag`, `touch_follow_drag`, `touch_end_drag`, and `handle_touch_stock_tap` systems in `input_plugin.rs` will function correctly — this was already written with multi-touch in mind and uses `TouchPhase::Started/Moved/Ended/Canceled` cleanly.
|
||||
- Bevy UI (the `bevy::ui` module used for all overlays).
|
||||
- `WindowResized` events fire correctly, so the layout system will recompute for any screen size.
|
||||
|
||||
**What does not work / needs attention:**
|
||||
- **`bevy/dynamic_linking`**: The dynamic linking feature must be stripped from any Android build profile. Dynamic linking is a desktop-only development shortcut; Android requires static linking.
|
||||
- **Fixed window size**: `main.rs` sets `resolution: (1280u32, 800u32)`. On Android the window is always the full display. This value is harmlessly overridden by the OS, but `min_width`/`min_height` constraints should be removed or set to 0 for Android to avoid Winit warnings.
|
||||
- **`F11` fullscreen toggle** (`handle_fullscreen` in `input_plugin.rs`): `WindowMode::BorderlessFullscreen` is desktop-only. On Android it should be a no-op.
|
||||
- **Keyboard shortcuts**: The entire `handle_keyboard_core`, `handle_keyboard_hint`, `handle_keyboard_forfeit` systems are desktop-only workflows. They will not crash, but they are dead code on Android. No touchscreen replacement for Undo (U), New Game (N), Draw (D/Space), Hint (H), Forfeit (G) exists yet — these need an on-screen UI.
|
||||
- **`CursorPlugin`**: The custom cursor sprite plugin is irrelevant on Android (no cursor). Harmless to leave registered, but it uses `PrimaryWindow` cursor APIs that may panic or warn on Android.
|
||||
|
||||
**cargo-mobile2 integration for Bevy:**
|
||||
The standard path is:
|
||||
1. Install `cargo-mobile2`: `cargo install --locked cargo-mobile2`
|
||||
2. Run `cargo mobile init` in the workspace root. This generates an `android/` directory with the Gradle project, `AndroidManifest.xml`, and JNI glue.
|
||||
3. cargo-mobile2 targets the `solitaire_app` binary crate (the thin entry point). The generated `lib.rs` shim calls `android_main` via `bevy::winit`'s Android entry point.
|
||||
4. The `solitaire_app` crate needs a `[lib]` target added alongside the existing `[[bin]]`, with `crate-type = ["cdylib"]`, used only when building for Android.
|
||||
|
||||
**Required `Cargo.toml` changes (workspace level):**
|
||||
```toml
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
# android_logger and ndk-glue wiring are handled by cargo-mobile2's generated shim.
|
||||
# No direct ndk-glue dependency is needed in app code when using Bevy + cargo-mobile2.
|
||||
```
|
||||
|
||||
**NDK version:** Android NDK r25c or r26 LTS is the tested range for `wgpu`/Vulkan on Android. NDK r27+ may work but has had compatibility reports with CPAL. Set `ANDROID_NDK_ROOT` to the NDK root; the minimum API level should be 26 (Android 8.0) for Vulkan stability.
|
||||
|
||||
---
|
||||
|
||||
## 2. Audio — `kira` + `DefaultBackend`
|
||||
|
||||
**The problem:**
|
||||
`solitaire_engine/src/audio_plugin.rs` creates an `AudioManager<DefaultBackend>`. `kira`'s `DefaultBackend` is an alias for `CpalBackend`, which wraps CPAL. CPAL's Android backend uses OpenSL ES and is functional but historically fragile. As of kira 0.9+, `kira` no longer bundles its own CPAL backend by default in the same way — the `DefaultBackend` feature must be enabled explicitly and requires `cpal` with the Android feature.
|
||||
|
||||
**Current code behavior:**
|
||||
The `AudioPlugin::build` already handles the "no audio device" case gracefully:
|
||||
```rust
|
||||
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
|
||||
if manager.is_none() {
|
||||
warn!("audio device unavailable; SFX disabled");
|
||||
}
|
||||
```
|
||||
This means if the audio manager fails to initialise on Android, the game continues silently. This is acceptable as a first-pass fallback.
|
||||
|
||||
**What is needed for working audio on Android:**
|
||||
- Add `kira` dependency with `cpal` backend enabled for Android: The `kira` workspace dependency currently specifies `version = "0.12"`. Verify that `kira/Cargo.toml` exposes a `cpal` feature (or that `DefaultBackend` compiles on Android targets with NDK). If not, a `CpalBackend` with `cpal = { features = ["oboe"] }` may be needed.
|
||||
- The `NonSend` resource `AudioState` should compile fine — `NonSend` is legal in Bevy Android builds.
|
||||
- `include_bytes!` for the WAV assets is compile-time and unaffected by platform.
|
||||
|
||||
**Recommendation:** Defer full audio verification to a device test. The graceful fallback means a silent-but-working first build is achievable without resolving this.
|
||||
|
||||
---
|
||||
|
||||
## 3. `keyring` Crate — No Android Backend
|
||||
|
||||
**The problem:**
|
||||
`keyring = "2"` is used in `solitaire_data/src/auth_tokens.rs` to store JWT access and refresh tokens in the OS keychain. The `keyring` crate's Android backend does not exist — as of v2.x, supported backends are: macOS Keychain, Windows Credential Manager, Linux Secret Service (D-Bus), and iOS Keychain. There is no Android KeyStore backend.
|
||||
|
||||
On Android, `Entry::new(...)` will return `keyring::Error::NoStorageAccess`, which the existing code already maps to `TokenError::KeychainUnavailable`. So the code will not crash — it will simply fail every token store/load operation.
|
||||
|
||||
**Current failure mode:**
|
||||
Every call to `store_tokens`, `load_access_token`, `load_refresh_token`, or `delete_tokens` will return `Err(TokenError::KeychainUnavailable(...))`. The sync client in `sync_client.rs` needs to be verified to handle this gracefully rather than propagating an error that disables sync entirely.
|
||||
|
||||
**Options for Android credential storage:**
|
||||
|
||||
| Option | Security | Effort | Notes |
|
||||
|---|---|---|---|
|
||||
| **In-memory only (prompt re-login each session)** | N/A | 1 day | Simplest. On `TokenError::KeychainUnavailable`, the `SyncProvider` returns `SyncError::Auth`, user is prompted to log in. Already architecturally supported. |
|
||||
| **Encrypted `SharedPreferences` equivalent via JNI** | Good | 4–6 days | Call Android's `EncryptedSharedPreferences` (Jetpack Security) via JNI. Significant JNI boilerplate. |
|
||||
| **AES-256 file encryption using Android Keystore via JNI** | Excellent | 5–8 days | Proper Android keychain equivalent. Complex JNI. |
|
||||
| **Store in app-private file, unencrypted** | Poor | 0.5 days | Only acceptable during development. Never ship. |
|
||||
|
||||
**Recommended approach (first pass):** Use the in-memory / re-login-each-session path. The existing `TokenError::KeychainUnavailable` variant already exists for exactly this reason (Linux without a running secret service). The `SyncPlugin` should detect this on startup and present a "Sync unavailable — please log in" message rather than a hard error. This requires:
|
||||
1. Conditional compilation: when `cfg(target_os = "android")`, replace the `keyring` calls with a no-op in-memory store (a simple `Mutex<HashMap<String, String>>`).
|
||||
2. A `#[cfg(not(target_os = "android"))]` guard on the `keyring` import/dependency in `solitaire_data/Cargo.toml`.
|
||||
|
||||
**Required `solitaire_data/Cargo.toml` change:**
|
||||
```toml
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
keyring = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
# keyring is replaced by in-memory storage; no dependency needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `dirs` Crate — Data Directory on Android
|
||||
|
||||
**The problem:**
|
||||
`storage.rs` and other persistence modules use `dirs::data_dir()` to locate `~/.local/share/solitaire_quest/` (or platform equivalent). On Android, `dirs::data_dir()` returns `None` because there is no `XDG_DATA_HOME` and the `dirs` crate does not implement an Android-specific path.
|
||||
|
||||
**Current code behavior:**
|
||||
All persistence functions already handle `None` gracefully (returning default values or `Err`), consistent with the CLAUDE.md lesson about `dirs::data_dir()`. Stats and progress will silently not persist across sessions if `data_dir()` returns `None`.
|
||||
|
||||
**Fix required:**
|
||||
Android apps should store private data in the app's internal storage directory, obtained via JNI: `context.getFilesDir()`. This requires either:
|
||||
- A thin JNI helper (via `jni` crate) called once on startup to obtain the path and store it as a global.
|
||||
- Or passing the path in via the `android_main` entry point using `cargo-mobile2`'s `AndroidApp` handle, which exposes `internal_data_path()`.
|
||||
|
||||
The `cargo-mobile2` + Bevy path exposes an `AndroidApp` via `bevy::winit`'s Android entry point. Bevy 0.13+ passes `AndroidApp` through `WinitPlugin`, and it is accessible via a Bevy resource. A startup system can extract `app.internal_data_path()` and insert a `PlatformDataDirResource` that the storage functions read instead of calling `dirs::data_dir()`.
|
||||
|
||||
**Effort:** 1–2 days to implement the override and thread it through all `storage.rs` / `progress.rs` / `settings.rs` / `achievements.rs` call sites.
|
||||
|
||||
---
|
||||
|
||||
## 5. Touch Input — Current State and Gaps
|
||||
|
||||
**What already exists (strong foundation):**
|
||||
|
||||
The `InputPlugin` in `input_plugin.rs` has a complete parallel touch pipeline:
|
||||
|
||||
| System | Purpose | Status |
|
||||
|---|---|---|
|
||||
| `handle_touch_stock_tap` | Tap the stock pile to draw | Complete |
|
||||
| `touch_start_drag` | Begin a touch drag on a face-up card | Complete |
|
||||
| `touch_follow_drag` | Move card(s) with the active finger | Complete |
|
||||
| `touch_end_drag` | Resolve the drag (move or reject) | Complete |
|
||||
|
||||
The touch systems use `TouchInput` events and the `Touches` resource, map touch IDs to `DragState.active_touch_id` to prevent multi-finger conflicts, and share the same `DragState`, `MoveRequestEvent`, `MoveRejectedEvent`, and `StateChangedEvent` infrastructure as the mouse pipeline. The drag threshold (`tuning.drag_threshold_px`) applies identically.
|
||||
|
||||
**Gaps for a production Android experience:**
|
||||
|
||||
1. **No double-tap equivalent for auto-move**: `handle_double_click` is mouse-only. Android users need a double-tap to trigger the same "move to best destination" logic. The `handle_double_click` system checks `buttons.just_pressed(MouseButton::Left)` and will be inert on Android. Estimated: 1 day.
|
||||
|
||||
2. **No touch equivalent for keyboard actions**: Undo, New Game, Draw (when stock is visible but tapping it is awkward), Hint, and Forfeit have no on-screen buttons. These need an Android-specific UI bar or gesture (e.g. two-finger tap for undo). Estimated: 2–3 days for a minimal floating action button strip.
|
||||
|
||||
3. **Drag threshold tuning**: The threshold is in `AnimationTuning` (`tuning.drag_threshold_px`). Touch screens typically need a larger threshold than mouse (physical screens have more accidental movement during a tap). The current value should be evaluated on a real device and likely increased for touch.
|
||||
|
||||
4. **No long-press for right-click equivalent**: The right-click highlight/hint glow (`HintHighlightTimer`) is triggered via right mouse button. Long-press detection is not yet implemented. This is a missing feature but not a blocker for basic play.
|
||||
|
||||
5. **`handle_double_click` uses `LocalDateTime`-based timing via `Time`**: This will work on Android, but `DOUBLE_CLICK_WINDOW = 0.35s` may feel too tight on touch. Should be configurable.
|
||||
|
||||
---
|
||||
|
||||
## 6. Additional Issues Not in Scope of the Four Research Areas
|
||||
|
||||
**`CursorPlugin`:** Uses Bevy's cursor APIs which are desktop-only. Should be conditionally compiled out on Android with `#[cfg(not(target_os = "android"))]`.
|
||||
|
||||
**`reqwest` with `rustls-native-certs`:** The `reqwest` dependency uses `rustls` with native root certificates. On Android, `rustls-native-certs` reads system certificates differently (via the `android_system_properties` crate internally). This generally works but should be tested; Android's certificate store is in a non-standard location vs Linux.
|
||||
|
||||
**App lifecycle (suspend/resume):** Android can suspend the process at any time. Bevy handles `WindowEvent::Suspended` and `WindowEvent::Resumed` via `winit`, pausing the render loop. The `SyncPlugin`'s "push on exit" path (`AppExit` event) should also trigger on `WindowEvent::Suspended` to avoid data loss when the user backgrounds the app. This is a separate feature (1 day).
|
||||
|
||||
**No `sqlx` on Android:** `solitaire_server` is a server binary and is never built for Android. The `sqlx` dependency only exists in `solitaire_server/Cargo.toml` and will not affect Android builds of the client crates.
|
||||
|
||||
**`solitaire_assetgen`:** The asset generation tool is desktop-only and not part of the client build. Unaffected.
|
||||
|
||||
---
|
||||
|
||||
## 7. Required Changes Per Crate
|
||||
|
||||
### `solitaire_core` and `solitaire_sync`
|
||||
No changes required. Both are pure Rust with no platform dependencies.
|
||||
|
||||
### `solitaire_data`
|
||||
| Change | Effort |
|
||||
|---|---|
|
||||
| Gate `keyring` dependency on `#[cfg(not(target_os = "android"))]` | 0.5 days |
|
||||
| Implement `auth_tokens.rs` in-memory fallback for Android | 1 day |
|
||||
| Add `internal_data_path()` override for `dirs::data_dir()` on Android | 1.5 days |
|
||||
| Audit all `dirs::data_dir()` / `settings_file_path()` call sites to accept injected path | 0.5 days |
|
||||
|
||||
### `solitaire_engine`
|
||||
| Change | Effort |
|
||||
|---|---|
|
||||
| Conditionally disable `CursorPlugin` on Android | 0.5 days |
|
||||
| Disable `handle_fullscreen` on Android (or make it a no-op) | 0.25 days |
|
||||
| Implement double-tap for auto-move (touch equivalent of `handle_double_click`) | 1 day |
|
||||
| On-screen action bar for Undo, New Game, Hint (minimal floating buttons) | 2.5 days |
|
||||
| Tune drag threshold for touch; expose as a platform-specific tuning constant | 0.5 days |
|
||||
| Trigger sync push on `WindowEvent::Suspended` in `SyncPlugin` | 1 day |
|
||||
| Verify `kira` audio on Android (test `DefaultBackend` / CPAL; implement fallback if needed) | 1–2 days |
|
||||
|
||||
### `solitaire_app`
|
||||
| Change | Effort |
|
||||
|---|---|
|
||||
| Add `[lib]` target with `crate-type = ["cdylib"]` for Android builds | 0.25 days |
|
||||
| Create `src/lib.rs` (or `src/android.rs`) Android entry point calling `android_main` | 0.5 days |
|
||||
| Remove or guard fixed `resolution` / `resize_constraints` for Android | 0.25 days |
|
||||
| Pass `AndroidApp::internal_data_path()` to a startup resource | 0.5 days |
|
||||
|
||||
### Build / Toolchain
|
||||
| Change | Effort |
|
||||
|---|---|
|
||||
| Install cargo-mobile2, Android NDK r25c/r26, `aarch64-linux-android` target | 1 day |
|
||||
| Run `cargo mobile init`, configure `android/` Gradle project | 0.5 days |
|
||||
| Get a first build compiling (resolve linker / NDK issues) | 1–2 days |
|
||||
|
||||
---
|
||||
|
||||
## 8. Estimated Effort
|
||||
|
||||
| Phase | Description | Days |
|
||||
|---|---|---|
|
||||
| Toolchain setup | NDK, cargo-mobile2, first compile | 2–3 |
|
||||
| `solitaire_data` Android adaptations | keyring fallback, data dir | 3 |
|
||||
| `solitaire_app` Android entry point | cdylib, AndroidApp wiring | 1 |
|
||||
| `solitaire_engine` guards and fixes | cursor, fullscreen, audio verify | 2–3 |
|
||||
| Touch UX improvements | double-tap, action bar, threshold tuning | 4–5 |
|
||||
| Testing on real device / emulator | iteration, lifecycle edge cases | 2–3 |
|
||||
| **Total** | | **14–17 days** |
|
||||
|
||||
This produces a playable, functionally complete Android build. It does not include Play Store preparation (signing keys, metadata, icon set, permissions manifest tuning) which would add 1–2 more days.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommended First Step
|
||||
|
||||
**Get the workspace to compile for `aarch64-linux-android` without running.**
|
||||
|
||||
This surfaces all the real linker and dependency errors before writing any gameplay code:
|
||||
|
||||
```bash
|
||||
# Install toolchain
|
||||
rustup target add aarch64-linux-android
|
||||
cargo install --locked cargo-mobile2
|
||||
|
||||
# In the workspace root:
|
||||
cargo mobile init # generates android/ directory
|
||||
|
||||
# Attempt a library build targeting Android
|
||||
cargo build -p solitaire_app --target aarch64-linux-android 2>&1 | head -60
|
||||
```
|
||||
|
||||
The first build will fail on `keyring` (no Android backend) and likely on `dirs`. Fixing those two in `solitaire_data` — gate `keyring` behind `cfg(not(target_os = "android"))` and stub the data directory — will probably get the workspace to a clean compile. From there, the path to a running APK is incremental.
|
||||
|
||||
Do not attempt to resolve audio or touch UX until the build compiles cleanly. Compile errors are the only true blockers; the rest are feature gaps.
|
||||
@@ -0,0 +1,318 @@
|
||||
# Sync Subsystem Manual Test Runbook
|
||||
|
||||
**Version:** 1.0
|
||||
**Last Updated:** 2026-04-28
|
||||
**Scope:** Cross-machine sync, JWT refresh, conflict resolution, account deletion
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- Two machines (or VMs) referred to as **Machine A** and **Machine B** throughout this runbook. Both must be able to reach the sync server over the network.
|
||||
- A running Solitaire Quest sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup.
|
||||
- Verify the server is live before starting:
|
||||
|
||||
```bash
|
||||
curl -s https://solitaire.example.com/health
|
||||
# Expected: {"status":"ok","version":"..."}
|
||||
```
|
||||
|
||||
### Accounts
|
||||
|
||||
- You will register two separate accounts (`alice` and `bob`) during the tests. You do not need to create them in advance.
|
||||
|
||||
### Tooling
|
||||
|
||||
- `curl` or a REST client (Insomnia/Postman) for manual API calls.
|
||||
- `sqlite3` CLI if you need to inspect the server database directly.
|
||||
- The game binary built in release mode on both machines:
|
||||
|
||||
```bash
|
||||
cargo build -p solitaire_app --release
|
||||
```
|
||||
|
||||
### Baseline: Clear local data on both machines
|
||||
|
||||
Before starting, delete any existing local save files to ensure a clean state:
|
||||
|
||||
```
|
||||
# Linux
|
||||
rm -rf ~/.local/share/solitaire_quest/
|
||||
|
||||
# macOS
|
||||
rm -rf ~/Library/Application\ Support/solitaire_quest/
|
||||
|
||||
# Windows
|
||||
rmdir /s %APPDATA%\solitaire_quest\
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test 1 — Full Sync Round-Trip (register, play, push, verify on second machine)
|
||||
|
||||
**Goal:** Confirm that stats played on Machine A appear on Machine B after sync.
|
||||
|
||||
### Step 1 — Register on Machine A
|
||||
|
||||
1. Launch the game on Machine A.
|
||||
2. Open **Settings** (key: `O`) and locate the **Sync** section.
|
||||
3. Enter the server URL and choose a username: `alice`.
|
||||
4. Choose a password (at least 12 characters).
|
||||
5. Tap **Register** (or **Login** if the account already exists).
|
||||
6. The Settings screen should show **Status: syncing…** briefly, then **Status: last synced at HH:MM**.
|
||||
7. Close the game.
|
||||
|
||||
Verify the registration succeeded directly:
|
||||
|
||||
```bash
|
||||
curl -s -X POST https://solitaire.example.com/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"<your-password>"}' | jq .
|
||||
# Expected: {"access_token":"...","refresh_token":"..."}
|
||||
```
|
||||
|
||||
### Step 2 — Play games on Machine A
|
||||
|
||||
1. Launch the game on Machine A.
|
||||
2. Win at least **three games** (Draw One or Draw Three — note which mode).
|
||||
3. Check the Stats overlay (key: `S`) and note:
|
||||
- `games_played`
|
||||
- `games_won`
|
||||
- `win_streak_current`
|
||||
- `fastest_win_seconds`
|
||||
4. Close the game normally (this triggers the push-on-exit path).
|
||||
|
||||
### Step 3 — Verify the push reached the server
|
||||
|
||||
```bash
|
||||
# Log in to get a fresh token
|
||||
TOKEN=$(curl -s -X POST https://solitaire.example.com/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"<your-password>"}' | jq -r .access_token)
|
||||
|
||||
# Pull the server's stored state
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull | jq .merged.stats
|
||||
```
|
||||
|
||||
Confirm `games_won` matches what you recorded in Step 2.
|
||||
|
||||
### Step 4 — Pull on Machine B
|
||||
|
||||
1. Launch the game on **Machine B** (clean local data).
|
||||
2. Open **Settings**, enter the same server URL, and log in as `alice` with the same password.
|
||||
3. The plugin will pull on startup. Wait for **Status: last synced at HH:MM**.
|
||||
4. Open the Stats overlay (key: `S`) and confirm the numbers from Step 2 are present.
|
||||
|
||||
**Pass criterion:** `games_won`, `games_played`, and `fastest_win_seconds` on Machine B match Machine A.
|
||||
|
||||
---
|
||||
|
||||
## Test 2 — JWT Refresh on 401
|
||||
|
||||
**Goal:** Confirm that an expired access token is refreshed transparently without user interaction.
|
||||
|
||||
### Step 1 — Shorten the access token TTL on the server (test environment only)
|
||||
|
||||
Edit the server `.env` and set a short expiry, then restart:
|
||||
|
||||
```
|
||||
JWT_ACCESS_EXPIRY_SECS=5
|
||||
```
|
||||
|
||||
> If you cannot modify the server config, skip to the manual token corruption method in Step 1b.
|
||||
|
||||
### Step 1b (alternative) — Corrupt the stored access token directly
|
||||
|
||||
On the machine where you want to test (Linux example):
|
||||
|
||||
```bash
|
||||
# List keychain entries (uses secret-tool on GNOME)
|
||||
secret-tool search service solitaire_quest_server
|
||||
|
||||
# Overwrite alice's access token with a deliberately invalid value
|
||||
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "invalid.token.value"
|
||||
```
|
||||
|
||||
### Step 2 — Trigger a sync with the expired/invalid token
|
||||
|
||||
1. Launch the game.
|
||||
2. Either wait for the startup pull (for the short-TTL method), or open **Settings** and tap **Sync Now**.
|
||||
3. Observe the **Status** field.
|
||||
|
||||
**Pass criterion (transparent refresh):** Status briefly shows "syncing…" and then shows "last synced at HH:MM" — no auth error is displayed. The access token in the keychain has been silently replaced.
|
||||
|
||||
**Verify the new token is valid:**
|
||||
|
||||
```bash
|
||||
# Extract the new token from the keychain
|
||||
secret-tool lookup service solitaire_quest_server account alice_access | head -c 50
|
||||
# Should look like a valid JWT (three base64 segments separated by dots)
|
||||
```
|
||||
|
||||
### Step 3 — Test failed refresh (both tokens expired)
|
||||
|
||||
1. Corrupt both the access token and the refresh token in the keychain:
|
||||
|
||||
```bash
|
||||
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "bad"
|
||||
secret-tool store --label="alice_refresh" service solitaire_quest_server account alice_refresh <<< "bad"
|
||||
```
|
||||
|
||||
2. Launch the game and trigger a sync.
|
||||
|
||||
**Pass criterion:** The Settings screen shows an error message matching: "Login expired — tap Sync Now after re-logging in". The game must not crash. No data must be lost (local files are untouched).
|
||||
|
||||
3. Restore: log in again via Settings to get fresh tokens.
|
||||
|
||||
---
|
||||
|
||||
## Test 3 — Conflict Scenario (offline play on both machines, then sync)
|
||||
|
||||
**Goal:** Confirm that progress made on both devices offline is merged correctly, with no data silently discarded.
|
||||
|
||||
### Step 1 — Take both machines offline
|
||||
|
||||
Disable network on both Machine A and Machine B (e.g. airplane mode, or block the server URL in `/etc/hosts`).
|
||||
|
||||
### Step 2 — Play on Machine A (offline)
|
||||
|
||||
1. Win 5 games. Note the resulting streak and `games_won`.
|
||||
2. Close the game.
|
||||
|
||||
### Step 3 — Play on Machine B (offline)
|
||||
|
||||
1. Win 3 different games. Note the resulting streak and `games_won`.
|
||||
2. Close the game.
|
||||
|
||||
At this point Machine A and Machine B have divergent state.
|
||||
|
||||
### Step 4 — Re-enable network, sync Machine A first
|
||||
|
||||
1. Restore network.
|
||||
2. Launch the game on Machine A. The push-on-exit from Step 2 did not reach the server, so:
|
||||
- Open Settings, tap **Sync Now** to force a pull.
|
||||
- Close the game (triggers push-on-exit).
|
||||
3. Verify the server has Machine A's state:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull | jq '.merged.stats.games_won'
|
||||
```
|
||||
|
||||
### Step 5 — Sync Machine B
|
||||
|
||||
1. Launch the game on Machine B.
|
||||
2. The startup pull fetches the server's merged state (which now contains Machine A's wins).
|
||||
3. Open Settings — wait for **Status: last synced at HH:MM**.
|
||||
4. Open the Stats overlay.
|
||||
|
||||
**Pass criteria:**
|
||||
- `games_won` = max(Machine A wins, Machine B wins) — at minimum the higher of the two counts.
|
||||
- No games are lost — both machines' win counts contribute.
|
||||
- If the two machines had different `win_streak_current` values, a conflict should be recorded (visible if you inspect the server response directly):
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull | jq '.conflicts'
|
||||
```
|
||||
|
||||
- The `win_streak_current` conflict entry will show `local_value` and `remote_value`. The higher value is used as the best-effort resolution.
|
||||
|
||||
---
|
||||
|
||||
## Test 4 — Account Deletion
|
||||
|
||||
**Goal:** Confirm that `DELETE /api/account` removes all server-side data and that a subsequent authenticated request is rejected.
|
||||
|
||||
### Step 1 — Confirm data exists before deletion
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull | jq '.merged.stats.games_played'
|
||||
# Expected: a non-zero number
|
||||
```
|
||||
|
||||
### Step 2 — Delete the account via the API
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/account | jq .
|
||||
# Expected: {"ok":true}
|
||||
```
|
||||
|
||||
### Step 3 — Verify all data is gone from the server
|
||||
|
||||
```bash
|
||||
# Try to pull with the (now-invalid) token
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull
|
||||
# Expected: HTTP 401 Unauthorized
|
||||
|
||||
# Try to log in again with the same credentials
|
||||
curl -s -X POST https://solitaire.example.com/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"<your-password>"}' | jq .
|
||||
# Expected: HTTP 401 or error body indicating invalid credentials
|
||||
```
|
||||
|
||||
### Step 4 — Verify local data is NOT deleted
|
||||
|
||||
1. Open the game. The local files (`stats.json`, `progress.json`, etc.) must still be present and intact — account deletion only affects the server.
|
||||
2. Check the Stats overlay and confirm local game history is visible.
|
||||
3. The Settings screen may show an auth error on next sync attempt, which is expected.
|
||||
|
||||
### Step 5 — Re-register with the same username (optional)
|
||||
|
||||
```bash
|
||||
curl -s -X POST https://solitaire.example.com/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"<new-password>"}' | jq .
|
||||
# Expected: {"access_token":"...","refresh_token":"..."} — fresh empty account
|
||||
```
|
||||
|
||||
**Pass criterion:** Re-registration succeeds, and a subsequent pull returns a payload with all-zero stats (completely fresh account, no residual data from the deleted account).
|
||||
|
||||
---
|
||||
|
||||
## Test 5 — Server Errors Do Not Show "Login Expired"
|
||||
|
||||
**Goal:** Verify that a 500 Internal Server Error or 429 Too Many Requests shows a network error, not an auth error, to the user.
|
||||
|
||||
### Step 1 — Simulate a 500 with a reverse proxy rule
|
||||
|
||||
Add a temporary nginx/Caddy rule to return 500 for `/api/sync/*`:
|
||||
|
||||
```nginx
|
||||
location /api/sync/ {
|
||||
return 500;
|
||||
}
|
||||
```
|
||||
|
||||
Or use a local proxy like `mitmproxy` to intercept and rewrite responses.
|
||||
|
||||
### Step 2 — Trigger a sync
|
||||
|
||||
Open Settings and tap **Sync Now**.
|
||||
|
||||
**Pass criterion:** The Status field shows "Can't reach server — check your connection" (network error message), NOT "Login expired — tap Sync Now after re-logging in" (auth error message).
|
||||
|
||||
Remove the nginx rule after this test.
|
||||
|
||||
---
|
||||
|
||||
## Regression Checklist
|
||||
|
||||
After running all tests above, confirm:
|
||||
|
||||
- [ ] No crash occurred during any test on either machine.
|
||||
- [ ] Local save files (`stats.json`, `progress.json`, `achievements.json`) are present and valid JSON after all tests.
|
||||
- [ ] The game launches and plays normally after all sync operations (sync is additive — never blocks gameplay).
|
||||
- [ ] The Stats overlay shows correct numbers on both machines after a successful sync round-trip.
|
||||
- [ ] An expired token is refreshed transparently without the user having to log in again.
|
||||
- [ ] A doubly-expired token surfaces a clear error message to the user.
|
||||
- [ ] Account deletion removes all server data; local data is preserved.
|
||||
- [ ] HTTP 5xx and 429 responses show a network error, not an auth error.
|
||||
Reference in New Issue
Block a user