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:
funman300
2026-04-28 23:51:58 +00:00
parent 4997356cb5
commit 41d75b50de
10 changed files with 691 additions and 41 deletions
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.
+60 -1
View File
@@ -16,12 +16,13 @@ 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, gen) in &effects {
@@ -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)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+18 -2
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}")))
} }
} }
+2 -3
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 {
+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));
+5 -5
View File
@@ -265,18 +265,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,
) )
} }
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. /// 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 +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 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
+10 -10
View File
@@ -531,7 +531,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.
@@ -760,7 +760,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);
@@ -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. /// 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 +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 // 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 +1039,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;
} }
@@ -1423,7 +1423,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 +1439,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 +1477,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 +1507,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);
+13 -8
View File
@@ -198,13 +198,17 @@ fn poll_pull_result(
progress.0 = merged.progress; progress.0 = merged.progress;
status.0 = SyncStatus::LastSynced(Utc::now()); 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) => { Err(e) => {
warn!("sync pull failed: {e}"); warn!("sync pull failed: {e}");
let msg = match &e { let msg = match &e {
SyncError::Network(_) => "Can't reach server — check your connection".to_string(), 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::Auth(_) => "Login expired — tap Sync Now after re-logging in".to_string(),
SyncError::Serialization(_) => format!("Unexpected server response: {e}"), SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
SyncError::UnsupportedPlatform => "Sync not configured".to_string(), SyncError::UnsupportedPlatform => unreachable!("handled above"),
}; };
status.0 = SyncStatus::Error(msg); 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 // Prefer an existing tokio runtime; fall back to futures_lite block_on
// for environments (e.g. tests) that don't have one. // for environments (e.g. tests) that don't have one.
match tokio::runtime::Handle::try_current() { let result = match tokio::runtime::Handle::try_current() {
Ok(handle) => { Ok(handle) => handle.block_on(provider.push(&payload)),
let _ = handle.block_on(provider.push(&payload)); Err(_) => future::block_on(provider.push(&payload)),
} };
Err(_) => { if let Err(e) = result {
let _ = future::block_on(provider.push(&payload)); // 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}");
} }
} }