Commit Graph

15 Commits

Author SHA1 Message Date
funman300 d761a150d7 chore: rename app from Solitaire Quest to Ferrous Solitaire
Build and Deploy / build-and-push (push) Successful in 4m40s
Updates all in-tree references:
- Android package: com.solitairequest.app → com.ferrousapp.solitaire
- APK name: solitaire-quest → ferrous-solitaire
- Data dir: solitaire_quest → ferrous_solitaire (across all 6 data modules + engine)
- Keyring service: solitaire_quest_server → ferrous_solitaire_server
- Android Keystore key: solitaire_quest_token_key → ferrous_solitaire_token_key
- Gitea repo: Rusty_Solitare → Ferrous-Solitaire (also fixes "Solitare" typo)
- Renamed pkg/solitaire-quest* → pkg/ferrous-solitaire*
- Updated ArgoCD, docker-compose, CI workflow, build script, all docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:23:49 -07:00
funman300 677999a51e feat(engine): wire avatar download and display into profile modal
Build and Deploy / build-and-push (push) Successful in 4m15s
- Add avatar_plugin: AvatarPlugin, AvatarResource, AvatarFetchEvent
  - After AvatarFetchEvent fires, spawns an async reqwest download task
  - On completion, decodes image bytes via image::load_from_memory →
    Image::from_dynamic and inserts into Assets<Image>
- Expand auth task to also call fetch_me_with_token immediately after
  login/register so avatar_url is available without a second round-trip
- poll_auth_task fires AvatarFetchEvent when avatar_url is Some, building
  the full URL from base_url + relative avatar path
- Profile modal shows 48px circular avatar ImageNode when AvatarResource
  is populated, or an initials disc (first letter of username) as fallback
- Add image = "0.25" and reqwest to solitaire_engine deps
- Add fetch_me_with_token helper to SolitaireServerClient for use when
  the access token hasn't been persisted to keychain yet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:27:27 -07:00
funman300 0dcb783e94 feat(analytics): opt-in usage analytics with server ingest and settings toggle
- Server: POST /api/analytics endpoint with per-IP rate limit (5/min),
  batch validation (≤50 events, event_type regex, UUID dedup, clock check),
  INSERT OR IGNORE for idempotency, and migration 004_analytics.sql
- Client (solitaire_data): AnalyticsClient with in-memory Mutex buffer,
  UUID session_id per launch, async flush via background task
- Engine: AnalyticsPlugin records game_won, game_forfeit, game_start,
  achievement_unlocked; flushes immediately on game-end, every 60 s otherwise
- Settings UI: Privacy section with ON/OFF toggle, hidden in local-only mode
- Default: analytics_enabled = false (explicit opt-in required)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:06:34 -07:00
funman300 8325bf6cf7 chore: rename app from Solitaire Quest to Ferrous Solitaire
Replace all display-name occurrences across web pages, Rust source,
docs, and Cargo metadata. Update localStorage token key from sq_token
to fs_token. Tagline "Klondike Solitaire" retained as genre descriptor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:04:45 -07:00
funman300 432061c3ec feat(sync): Phase 8 sync setup UI — login/register modal + Connect/Disconnect
Adds SyncSetupPlugin: a three-field (URL / Username / Password) modal
that handles both login and register flows via an async task on
AsyncComputeTaskPool wrapped in a Tokio single-thread runtime (same
pattern as the existing sync push/pull). On success, tokens are stored
to the OS keychain / Android Keystore and SyncProviderResource is
hot-swapped so subsequent pull/push use the new credentials immediately.

Settings sync section now shows Connect (when Local) or Sync Now +
Disconnect + username label (when SolitaireServer). SyncAuthResultEvent
stub registered for future re-auth prompt wiring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:40:29 -07:00
funman300 fcc7337c97 fix(android): gate AssetPlugin file_path override to desktop only
`AssetPlugin::file_path = "../assets"` was set unconditionally to
make `cargo run -p solitaire_app` find the workspace-root assets
directory from inside `solitaire_app/`. On Android cargo-apk packages
the same directory into the APK at `assets/`, and Bevy's
AndroidAssetReader is already rooted there — prepending `../` walked
the reader out of the APK assets root and every load failed silently.

The cascade: CardImageSet handles were inserted but pointed at
non-existent paths, so `card_sprite` saw `Some(set)` but the textures
never resolved. The face-down branch then rendered with `Color::WHITE`
over a missing texture — which on hardware showed as the
`card_back_colour(0)` solid-red brick fallback that's *supposed* to
only fire under MinimalPlugins in tests.

Gates the `file_path` override behind
`#[cfg(not(target_os = "android"))]` so Android picks up the default
empty path. Closes P0 #3 of docs/android/PLAYABILITY_TODO.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:41:06 -07:00
funman300 b9aa2620b8 feat(android): safe-area insets for HUD positioning
Adds SafeAreaInsets resource + SafeAreaInsetsPlugin that report the
OS-reserved regions (status bar, gesture/nav bar, display cutout)
around the playable surface. Desktop reports all zeros; Android
queries WindowInsets.getInsets(systemBars()) via JNI on the decor
view, polling for up to 120 frames since getRootWindowInsets()
returns null until the view is laid out.

UI that should respect the top inset carries a SafeAreaAnchoredTop
{ base_top } marker. A change-detection system re-applies
`base_top + insets.top` whenever the resource changes, so late
inset arrival (frame 1-3 on Android) and future orientation
changes flow through without re-spawning entities.

Wires the three top-anchored HUD spawn sites — hud_band, hud
column, action button row — to the new pattern. Spawn systems
take Option<Res<SafeAreaInsets>> so HudPlugin still works
standalone in unit tests (mirrors the existing FontResource
pattern).

Closes P0 #1 of docs/android/PLAYABILITY_TODO.md. Resolves the
status-bar/HUD collision visible in the v0.22.3 hardware
screenshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:37:06 -07:00
funman300 681a54d9bb fix(android): gate Monitor/PrimaryMonitor/PrimaryWindow imports to non-Android
These three bevy::window types are only referenced by
apply_smart_default_window_size, which is already cfg(not(android)).
The unconditional import triggered -D unused-imports on the Android
cross-compile. Split into a separate cfg-gated use statement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:15:36 -07:00
funman300 7894559ca7 fix(android): gate had_saved_geometry and apply_smart_default_window_size
Both symbols are desktop-only: the variable feeds apply_smart_default_
window_size which is only registered inside a cfg(not(android)) block.
Without the matching cfg gate on the declaration / definition, the
Android cross-compile emits unused-variable and dead-code errors
(-D warnings turns them into hard failures).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:04:54 -07:00
funman300 4303ef3f5b feat(difficulty): add difficulty-tier game mode with seed catalogs and home UI
Adds DifficultyLevel (Easy/Medium/Hard/Expert/Grandmaster/Random) to
solitaire_core::game_state alongside GameMode::Difficulty(DifficultyLevel).
Five seed catalogs (40 seeds each) are pre-verified by the new
gen_difficulty_seeds binary using tiered solver budgets (1K–200K moves).
DifficultyPlugin resolves StartDifficultyRequestEvent → catalog seed →
NewGameRequestEvent; Random uses a system-time seed and bypasses the
winnable-only filter. The home overlay gets an expandable Difficulty section
between Draw Mode and the mode grid; last-played tier persists in Settings.
Difficulty wins pool into Classic stats. 5 unit tests in difficulty_plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:07:49 -07:00
funman300 0cb15872b1 feat(engine): add Play-by-Seed dialog with solver preview
Adds a numeric-input modal (PlayBySeedPlugin) that lets the player type
a decimal seed and receive an instant solver-verified verdict before the
hand is dealt.  A new HomeMode::PlayBySeed card surfaces it in the home
overlay, matched by the StartPlayBySeedRequestEvent carrier.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:19:02 -07:00
funman300 202a64db45 fix(android): export android_main and gate desktop-only window config
Three changes to get the APK past the NativeActivity launch crash:

1. Export `android_main` — NativeActivity dlopen-s libsolitaire_app.so
   and calls `android_main` as its entry point. Without the symbol the
   app crashed immediately with UnsatisfiedLinkError. The function sets
   bevy::android::ANDROID_APP (required by WinitPlugin) then delegates
   to the existing `run()`.

2. Gate `resize_constraints` to non-Android — on Android max_width and
   max_height default to 0.0; Bevy's clamp panicked with min=800 > max=0.

3. Gate `apply_smart_default_window_size` to non-Android — the system
   calls `.clamp(800.0, logical_w)` which panics when the window surface
   reports zero dimensions during early Android lifecycle events. Window
   sizing is OS-controlled on Android so the system is irrelevant there.

Verified: app boots on x86_64 Android 14 emulator (Pixel_7 AVD,
SwiftShader Vulkan), runs for 2+ minutes without crashing. Desktop
build: clippy clean, 1282 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 19:21:41 -07:00
funman300 716a025352 fix(app): wrap WinitWindows in Option to satisfy Bevy 0.18 param validation
`NonSend<WinitWindows>` failed system-param validation on the
first few frames before `WinitWindows` was populated, panicking
the Update system before any logic could run. Bevy 0.18's
stricter validation panics rather than skips when a non-send
resource is absent, with an error message spelling out the fix:
*"wrap the parameter in `Option<T>` and handle `None` when it
happens."*

Wraps `winit_windows` as `Option<NonSend<WinitWindows>>` and
early-returns on `None`, mirroring the same lifecycle handling
already applied to `winit_windows.get_window(primary_entity)` —
both fail in the same window of frames before winit's `Resumed`
event fires.

Repro from the user's `cargo run` log:
```
thread 'Compute Task Pool (2)' panicked at .../bevy_ecs-0.18.1/src/error/handler.rs:125:1:
Encountered an error in system ...: Parameter ... failed validation:
Non-send resource does not exist
```

Workspace clippy + cargo test --workspace clean, 1185 passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:09:27 -07:00
funman300 3eb3a26789 feat(app): wire desktop window icon — Terminal ▌RS mark at runtime
Closes Resume-prompt Option A (the post-v0.21.0 first option).
Half-day desktop work, no cert dependency.

Three deliverables:

1. **SVG-authored icon** (`solitaire_engine/src/assets/icon_svg.rs`)
   — square Terminal mark: `#151515` background, brick-red
   `#a54242` 1 px border, brick-red ▌ cursor block centered, "RS"
   monogram in `#d0d0d0` foreground gray beneath. Same shape that
   already lives on the splash boot screen and card-back monogram,
   reused as the project's signature visual mark. Authored in a
   64-unit logical box so it scales cleanly at every rasterisation
   target.

2. **9-size PNG hierarchy** (16, 24, 32, 48, 64, 128, 256, 512, 1024
   px) regenerated by `solitaire_engine/examples/icon_generator.rs`
   into `assets/icon/icon_<size>.png`. Sizes cover Linux hicolor
   (16, 24, 32, 48, 64, 128, 256, 512), Windows .ico targets (16,
   32, 48, 256), and macOS .icns targets (16, 32, 64, 128, 256,
   512, 1024). The runtime path uses just the 256 px slot; the
   smaller sizes are pre-rendered for downstream packaging.

3. **Runtime `Window::icon` wiring** (`solitaire_app/src/lib.rs`).
   Bevy 0.18 has no `Window::icon` field — the icon is set through
   the underlying `winit::window::Window` via the `WinitWindows`
   resource. `set_window_icon` runs each Update tick, retries
   silently until `WinitWindows` is populated (typically frame 1
   or 2), decodes the embedded 256 px PNG via `tiny_skia`, builds
   a `winit::window::Icon`, and self-disables via `Local<bool>`.
   Same one-shot pattern as `apply_smart_default_window_size`.
   Desktop-only — Android draws its launcher icon from the APK
   manifest, so the system is target-gated to
   `cfg(not(target_os = "android"))`.

Dep changes (CLAUDE.md §8 user-confirmed):

- `winit = "0.30"` promoted from a transitive Bevy dep to a direct
  dep on `solitaire_app` so `winit::window::Icon` is in scope —
  bevy_winit 0.18 doesn't re-export it. Version pinned to whatever
  Bevy uses; if Bevy bumps winit, this line bumps in lockstep.
- `tiny-skia` added as a direct dep on `solitaire_app` for PNG →
  RGBA decode. Already in workspace deps for `solitaire_engine`;
  no version drift risk.
- Both new deps target-gated to non-Android only.

Test infrastructure: `solitaire_engine/tests/icon_svg_pin.rs`
hashes the rasterised RGBA bytes at all 9 sizes via FNV-1a (same
shape as `card_face_svg_pin`). Bootstrap pattern (empty EXPECTED
→ panic with hashes formatted as Rust source → paste back in)
handles future intentional builder edits cleanly.

Workspace clippy + cargo test --workspace clean. 1185 passing
(+1 from v0.21.0's 1184 baseline — the icon pin's
`rasterised_icon_bytes_match_pinned_hashes`).

Out of scope for this commit: `.icns` / `.ico` bundling for
macOS / Windows app packaging. Both are packaging-time concerns
(set via bundle manifests, not runtime calls) and would need new
deps (`ico` and `icns` crates) — separate followup if/when the
project ships as a packaged macOS / Windows app rather than just
`cargo run`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:07:31 -07:00
funman300 fb8b2ac684 feat(app): Android build target — first working APK at 54 MB
Wires the workspace through `cargo apk build`. After this commit
`cargo apk build -p solitaire_app --target x86_64-linux-android`
produces a debug-signed APK at `target/debug/apk/solitaire-quest.apk`
containing all assets and `lib/x86_64/libsolitaire_app.so` — runnable
on the AVD or a physical x86_64 device.

The five gating points discovered by iterating compile cycles:

1. solitaire_app split into bin + lib. cargo-apk needs a `cdylib`
   to bundle as `libmain.so`; pure-bin crates panic with
   "Bin is not compatible with Cdylib". `src/lib.rs` carries the
   ECS bootstrap as `pub fn run`; `src/main.rs` is a 3-line shim
   that delegates for the desktop `cargo run` path.

2. `[package.metadata.android]` pins target SDK 34 / min SDK 26
   so cargo-apk doesn't probe for whatever default it ships
   (which on this machine was an uninstalled API 30). `assets =
   "../assets"` lets the same asset directory feed both desktop
   and APK.

3. Workspace `bevy` features add `android-native-activity` (the
   Bevy-side glue that pairs with cargo-apk's NativeActivity
   wrapper). The feature is target-gated inside bevy_internal so
   desktop builds compile it out.

4. `arboard` (clipboard, used by Stats's "Copy share link") has
   no Android backend — `cargo apk build` fails with E0433 on
   `platform::Clipboard` if unconditional. Target-gated to
   `cfg(not(target_os = "android"))`; the system surfaces an
   informational toast on Android until JNI ClipboardManager is
   wired in the Phase-Android round.

5. `keyring` + `keyring-core` cannot compile for android — the
   transitive `rpassword` uses `libc::__errno_location` which
   bionic doesn't expose. Both crates target-gated; `auth_tokens`
   ships a stub on Android that returns `KeychainUnavailable` for
   every call, matching how callers already handle a Linux box
   without Secret Service.

Cosmetic post-pass panic: cargo-apk panics AFTER the APK is signed
when it tries to also wrap the bin target. The APK on disk is
unaffected. Working around this with `cargo apk build --lib` is
the next small step.

What's verified:
- Desktop `cargo build`, `cargo clippy --workspace --all-targets`,
  and `cargo test --workspace` all clean.
- `cargo apk build -p solitaire_app --target x86_64-linux-android`
  produces 54 MB debug APK with libsolitaire_app.so + assets.

What's NOT yet verified:
- Whether the APK actually launches on the AVD / a phone (next
  step: `adb install` + `adb logcat` against the bevy_test AVD).
- Whether `dirs::data_dir()` on Android returns a usable path
  (sync / persistence will surface this if not).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:34:48 +00:00