# Ferrous Solitaire — Session Handoff **Last updated:** 2026-06-25 — v0.40.0 released (Android APK published); physical-device gate remains. --- ## Current state - **Branch state:** `master` pushed to origin; latest commits are the Draw-Three waste fan fix, its regression tests, and the NDK doc update (PRs #105–#108). - **Latest tag:** `v0.40.0` (released — signed arm64-v8a APK published to the Gitea release for Obtainium/sideload). `v0.39.1` was the prior published release. - **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed. - **Latest verification this session:** `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo build -p solitaire_app`; Android cross-compile + clippy for `aarch64-linux-android` (clean); full local signed arm64-v8a APK via `scripts/build_android_apk.sh`; CI `android-release` for `v0.40.0` completed/success with APK download HTTP 200. - **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing. --- ## v0.40.0 release (2026-06-25) Released via tag push → `.gitea/workflows/android-release.yml` built and signed the arm64-v8a release APK (release keystore, `versionCode 4000` / `versionName 0.40.0`, 29.2 MB) and published it to the Gitea release. Obtainium clients tracking the repo pick it up automatically. - Release: https://git.aleshym.co/funman300/Ferrous-Solitaire/releases/tag/v0.40.0 | PR | Summary | |----|---------| | #106 | **fix(engine):** Draw-Three waste fan hit-test now shares the renderer's fan step (`card_plugin::waste_fan_step` / `tableau_col_step`). The two had diverged under Android's tighter column spacing (`H_GAP_DIVISOR=32`), shifting the top fanned waste card's click target onto the card beneath it — so dragging the visible top card played the wrong one. Desktop/web were unaffected (the formulas already coincided there). | | #105 | **test(engine):** waste-card draggability regression tests (`find_draggable_at` picks the waste top with multiple cards and as a lone card). | | #108 | **docs(android):** NDK reference updated `26.3.11579264` → `30.0.14904198`; noted versions are not load-bearing and `build_android_apk.sh` auto-discovers the newest NDK/build-tools. | Pre-release validation performed locally this session: workspace clippy/test/build gates; `aarch64-linux-android` cross-compile + clippy clean (covers the `#[cfg(target_os = "android")]` paths that host CI never lints); release manifest sanity (`solitaire_app/android/AndroidManifest.xml` has no version fields so CI injection works; `lib_name` matches `[lib].name`); and a full signed local APK proving the `build_android_apk.sh` packaging pipeline end-to-end. --- ## What shipped since v0.39.0 - Browser Bevy canvas route and `window.__FERROUS_DEBUG__` automation bridge landed, with Playwright coverage for `/play`. - In-place `card_game` / `klondike` rewrite phases are complete through the latest follow-up: - `5e87358` integrates upstream deps cleanly. - `ae1ecc8` unifies `Suit` / `Rank` with upstream `card_game` types. - `d864d98` routes klondike/card imports through `solitaire_core`. - `9bcf13d`, `56e3b62`, `26f1b00` finish schema-v3 migration coverage, undo/recycle score correctness, and rewrite-plan docs. - Android keystore wiring, Android build-script hardening, server auth/runtime hardening, and modal safe-area centering have landed. - `CHANGELOG.md` has been caught up from `v0.34.0` through current unreleased work and committed in `7fe6ac6`. - Matomo analytics was re-reviewed: `MatomoClient` and `AnalyticsPlugin` are wired through `CoreGamePlugin` on non-wasm targets, and targeted tests now cover opt-in client creation, event encoding, buffer trimming, and analytics mode labels. - Native analytics and Android physical-device validation now have runbooks in `docs/analytics-validation.md` and `docs/ANDROID.md`. --- ## Historical notes before v0.39.0 See git log and `CHANGELOG.md`. The changelog now includes `v0.34.0` through `v0.39.0`, plus current unreleased work. --- ## What shipped since the last handoff (v0.23.0 → v0.35.1) ### v0.34.0 — Android polish + code-quality sweep (2026-05-16/17) | Commit | Summary | |--------|---------| | `9623bde` | Wire FiraMono to Android corner label; CardImageSet load tests | | `980312c` | Fix wrong bottom-right suit symbol on JS/QS/KS card assets | | `04e99a8` | Correct Android waste fan overlap and resume layout desync | | `3bb3ddb` | Eliminate panics, fix dismiss hit-test scope, guard home respawn | | `f8f1f26` | Adaptive drop zones, touch event correctness, modal lifecycle guards | | `1eb4043` | Auth-guard avatar serving; atomic write; user_id assertion in merge | | `69c6e88` | Deterministic pile serialization, undo skip, url-encode bytes, merge_at | | `aa7b0f6` | Gate frame-hot ECS systems on resource changes (perf) | | `6727126` | Consolidate APP_DIR_NAME; add `#[must_use]` on pure fns | | `a4dfb0c` | Differentiate leaderboard opt-in vs opt-out error toasts (M-12) | | `7fc98f8` | WASM: state() and step() return Result, errors throw JS exceptions (CR-6) | | `ffed6b2` | Share Tokio runtime across all network tasks (M-16) | | `fa84152` | Correct Android help hint label `→` to `!` (M-17) | | `18d7937` | Derive Copy for DrawMode; drop redundant .clone() calls (M-18) | | `132fea9` | Use saturating_add for move_count increments (M-19) | | `0ecc1a9` | Add missing derives to AchievementContext (M-20) | | `2301cc6` | Align android_keystore temp extension with cleanup glob (M-21) | | `2e52f54` | Enforce 32-char display_name limit at sync client boundary (M-22) | | `c8878d6` | Fix stale FOCUS_RING colour comment (M-23) | | `4aafc0a` | Name HUD popover Z-layers; replace raw Z arithmetic (M-24) | ### v0.35.0 — Accessibility + sync reliability (2026-05-18) | Commit | Summary | |--------|---------| | `eb6c93f` | Silence B0004 by adding Transform to ModalScrim | | `6f5cebd` | Fire WarningToastEvent on sync pull failure (was InfoToastEvent) | | `87aec5b` | Gate all decorative motion animations under `reduce_motion_mode` | `reduce_motion_mode` now gates: score pulse, score floater, streak flourish (hud_plugin), card-shake on rejected move, foundation completion flourish (feedback_anim_plugin). Pattern: gate at the trigger/start system, never at the tick system — if the component isn't inserted, the tick path never runs. ### v0.35.1 — Leaderboard bug fixes (2026-05-18) | Commit | Summary | |--------|---------| | `8f86d66` | Fix three leaderboard bugs: wrong toast type, stale label, name not synced | Three bugs fixed: 1. **Wrong toast type on error** — `poll_opt_in_task` / `poll_opt_out_task` error branches now fire `WarningToastEvent` instead of `InfoToastEvent`. 2. **Display name not pushed to server on change** — `Settings` gains `leaderboard_opted_in: bool` (serde-defaulted `false`). Set `true`/`false` when opt-in/out tasks succeed and persisted to `settings.json`. `handle_display_name_confirm` now spawns an `opt_in_leaderboard` task when already opted in — the server's upsert endpoint updates only `display_name` without re-opting-in. 3. **"Public name" label stale after name change** — `LeaderboardPublicNameText` marker component added to the label node. `update_leaderboard_public_name_label` system rewrites the text each frame the panel is open; O(0) cost when panel is closed. 5 new regression tests cover all three bugs. --- ## Open punch list ### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM This is the **single outstanding task** for the v0.40.0 Android release. Everything else is done and verified: workspace gates, `aarch64-linux-android` cross-compile + clippy, release manifest sanity, a full local signed APK, the published release, and Obtainium-facing delivery (public releases API, latest non-draft release, APK downloadable anonymously). The only thing that cannot be done without hardware is running the app on a real phone. Install the published APK on a real Android device (not AVD) and run the checklist in `docs/ANDROID.md §4`. This has never been gated in CI — AVD `adb shell input tap` doesn't deliver real touch events, so physical-device smoke testing is the only gate. The signed release APK is published (grab it from the release page, or use the local `target/debug/apk/ferrous-solitaire.apk`). When testing, specifically exercise the Draw-Three waste fan fixed in #106: switch to Draw-Three, draw several cards, and confirm dragging the visible top waste card plays *that* card, not the one beneath it. Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built `target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed it on AVD `Pixel_7`, launched `android.app.NativeActivity`, confirmed Bevy rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0 right=0` after 2 frames, onboarding could be dismissed via AVD input, and filtered logcat showed no Ferrous panic/fatal/ANR. ### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker) Separate, ongoing task unrelated to the Android release. `Settings` has `analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via `AnalyticsPlugin` on non-wasm targets. Remaining work is live validation against the deployed Matomo instance. Use `docs/analytics-validation.md` for the native validation checklist and the current web/WASM decision notes. --- ## Architectural notes for next session - **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system (the trigger), not the `tick_*` system. If the component is never inserted, the tick path never runs. See `hud_plugin.rs::detect_score_change` and `feedback_anim_plugin.rs::start_shake_anim` for the canonical pattern. - **Leaderboard server upsert:** `POST /api/leaderboard/opt-in` is idempotent — calling it when already opted in just updates `display_name`. Safe to call from `handle_display_name_confirm` without tracking a separate "needs update" flag. - **`Messages` API (Bevy 0.18.1):** write with `resource_mut::>().write(value)`; read in tests with `msgs.get_cursor()` + `cursor.read(msgs).next()`. - **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so `ButtonInput::just_pressed` state persists across frames unless explicitly cleared with `input.release(key); input.clear()` between updates. - **`/play` debug bridge design:** `play.html` runs two independent WASM instances in `Promise.all([bootstrap(), init()])`. `bootstrap()` sets `window.__FERROUS_DEBUG__` (logic layer via `solitaire_wasm.js`); `init()` starts the Bevy canvas. The bridge operates its own `SolitaireGame` — moves applied through the bridge do NOT affect the Bevy visual game. This is intentional for automation/invariant checking. - **HiDPI Bevy canvas:** `WindowResolution::default().with_scale_factor_override(1.0)` is set in the canvas app. Without this, physical pixels exceed WebGL2's 2048px limit on HiDPI displays, causing an immediate wgpu panic on the first resize event. - **`/play-classic` vs `/play` in e2e:** `smoke.spec.js` + `gameplay_review.spec.js` target `/play-classic` (DOM-heavy game.html); `play_canvas.spec.js` targets `/play` using only the `__FERROUS_DEBUG__` bridge (no DOM selectors). `cycle_metrics.js` supports both via `--route play-classic|play`.