Commit Graph

16 Commits

Author SHA1 Message Date
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 2c822ba2d7 feat(android): JNI clipboard bridge for Stats share-link button
Replaces the informational "Share link: {url}" toast on Android with a
real clipboard write via ClipboardManager JNI. Falls back to the old
toast on JNI error so the user can still copy the URL manually.

Adds `jni = "0.21"` (default-features = false) as a workspace dep;
`jni 0.21.1` was already in Cargo.lock as a transitive dep so no new
packages are fetched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:05:11 -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
funman300 0b3140ad6d Revert "feat(engine): theme thumbnails accept PNG faces alongside SVG"
This reverts commit de4751115f.
2026-05-06 19:38:13 -07:00
funman300 de4751115f feat(engine): theme thumbnails accept PNG faces alongside SVG
The theme picker chip's thumbnail loader hardcoded `.svg`
filenames (`spades_ace.svg`, `back.svg`) — a holdover from when
every shipped theme was vector-art. Raster-art user themes (e.g.
the v0.19 pixel-art theme generated via Claude Design and dropped
into ~/.local/share/solitaire_quest/themes/rusty-pixel/) had real
PNGs in their directory but the picker rendered placeholders
because it never tried the PNG sibling.

The fix is scoped to the thumbnail-cache pipeline. In-game card
rendering already worked via Bevy's standard PNG asset loader on
manifest-declared face/back paths — only the picker's small
preview chip was affected.

Changes in solitaire_engine/src/theme/plugin.rs:

  - PREVIEW_FACE_FILENAME / PREVIEW_BACK_FILENAME (with embedded
    `.svg` suffix) replaced by PREVIEW_FACE_BASENAME /
    PREVIEW_BACK_BASENAME ("spades_ace" / "back"). The function
    appends the extension itself.
  - read_theme_preview_svg_bytes -> read_theme_preview_bytes
    returns ThemePreviewBytes::{Svg, Png}. For "default" the
    embedded table stays SVG-only. For user themes the function
    tries `<basename>.svg` first (matching the bundled
    convention) and falls back to `<basename>.png` second.
  - rasterize_preview_to_handle gains a Png branch that calls a
    new decode_png_for_thumbnail helper (Bevy's
    Image::from_buffer with ImageType::Format(ImageFormat::Png)).
    PNGs decode at native dimensions; the picker chip's UI
    layout scales them at draw time. SVGs continue to rasterise
    at the fixed 100x140 thumbnail size as before.
  - generate_thumbnail_pair_for is unchanged in shape; just
    threads the new enum through.

Tests:

  - read_default_theme_preview_returns_some_for_canonical_files
    updated to match the new function signature and assert on
    the Svg variant explicitly.
  - New png_only_user_theme_generates_real_thumbnails creates a
    temp theme dir, writes a 2x3 PNG (encoded at runtime via the
    `image` dev-dep so the bytes are guaranteed valid), and
    asserts both ace + back yield non-default Handle<Image>.
    Cleans up the temp dir afterward.

solitaire_engine/Cargo.toml: image = "0.25" added as a
dev-dependency for the test's runtime PNG encoding. Already a
transitive Bevy dep so the build graph is unchanged.

Workspace: 1171 passing tests / 0 failing, was 1170 (+1 new).
cargo clippy --workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:13:52 -07:00
funman300 540869c851 feat(engine): "Copy share link" Stats button — clipboards the replay URL
Quat: replay sharing as the next punch-list item.

End-to-end:

1. Player wins a game on a server-backed sync backend.
2. `sync_plugin::push_replay_on_win` spawns the upload task on
   `AsyncComputeTaskPool` and stores the handle in the new
   `PendingReplayUpload` resource. The previous in-flight task (if
   any) is dropped — the most recent win is the one whose share link
   the player will care about.
3. `poll_replay_upload_result` harvests the task on the main thread
   each frame; on success writes `<server>/replays/<id>` to
   `LastSharedReplayUrl`. `UnsupportedPlatform` (LocalOnlyProvider)
   is silently absorbed; real network/auth errors warn-log.
4. The Stats overlay's action bar gains a "Copy share link" button.
   Click writes `LastSharedReplayUrl` to the OS clipboard via
   `arboard` and surfaces a "Copied: <url>" toast.

Trait change: `SyncProvider::push_replay` now returns `Result<String,
SyncError>` (the share URL) instead of `Result<(), SyncError>`. The
default (`UnsupportedPlatform`) is unchanged for non-server backends;
`SolitaireServerClient` parses the response body's `id` field and
composes `<base_url>/replays/<id>`. Both call paths (initial + 401
retry) go through the new `share_url_from_response` helper so the
parse logic isn't duplicated.

New deps:
- `arboard` (~10 KB, cross-platform clipboard) added to workspace +
  `solitaire_engine`. `default-features = false` keeps the X11/Wayland
  binary-feature deps off the dependency graph; arboard handles the
  fallback. Approved per the ASK BEFORE rule.

Persistence: the URL is in-memory only — the player must share within
the session of the win. A future revision can persist it alongside
the replay history file if cross-session sharing is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:32:57 +00:00
funman300 ce38b26721 feat(engine): theme zip importer with safety validation (Card theme phase 7)
Implements Phase 7 of CARD_PLAN.md — the entry point that takes a
user-supplied theme zip archive, validates it end-to-end, and
atomically unpacks it into the per-platform user themes directory.

Public API:
  import_theme(zip_path) -> Result<ThemeId, ImportError>
    Resolves user_theme_dir() and unpacks into <user>/<id>/.
  import_theme_into(zip_path, target_root) -> Result<ThemeId, ImportError>
    Test-friendly variant that takes the destination explicitly so
    unit tests never touch the global OnceLock override.

Safety guarantees enforced:
- 20 MB hard cap on archive size (read from the central directory
  before any extraction).
- Zip-slip path traversal rejected via ZipFile::enclosed_name plus a
  Component::Normal-only belt-and-braces check.
- Manifest parsed via ron::de and validated via the existing
  ThemeManifest::validate (Phase 2) — surfaces named diagnostics for
  missing-of-52, unknown keys, duplicate keys, and meta errors.
- Every referenced face + back rasterised through rasterize_svg as a
  structural validity check before any bytes hit the destination.
- Atomic install: writes to <root>/.<id>.tmp/ then std::fs::rename
  into place, with a recursive copy + remove fallback for cross-
  device renames. Failed extraction wipes the staging dir; the user
  themes root is never touched on error.
- Id collision with an existing theme dir rejected up front.

7 new tests covering the happy path plus six failure modes (missing
manifest, missing face, oversized archive, zip-slip, missing-file,
id collision). Tests build zips in tempfile::TempDir so they never
touch the real user themes directory.

Workspace deps: zip 8.6 (default-features off + deflate only),
tempfile 3.27 (dev only).

cargo check --workspace --all-targets / clippy --workspace
--all-targets -- -D warnings clean. cargo test could not be run in
this turn because cc disappeared from the sandbox; tests compile
under cargo check --tests and will run on a normal toolchain.
2026-05-01 05:47:30 +00:00
funman300 205ad6f646 feat(engine): per-platform user-theme directory (Card theme phase 5)
Implements Phase 5 of CARD_PLAN.md. Phase 3 (asset sources) and
Phase 7 (zip importer) both depend on this so it goes first.

solitaire_engine/src/assets/user_dir.rs
  user_theme_dir() -> PathBuf
    Desktop (Linux/macOS/Windows): joins dirs::data_dir() with
    "solitaire_quest/themes" — same parent as the rest of the
    project's per-user files (settings.json, stats.json, etc.)
    Mobile (Android/iOS): reads a process-wide OnceLock populated
    by set_user_theme_dir() at entry-point bootstrap. Panics with a
    targeted message if the override is missing — there is no
    platform default we can guess that won't be wrong inside iOS
    sandboxing or the Android storage model.
  set_user_theme_dir(PathBuf) -> Result<(), PathBuf>
    First-write-wins. Mobile entry points call this before App::run().

The plan suggested the `directories` crate; reused the existing `dirs`
workspace dep instead to keep the dependency surface minimal — both
crates share an author and the platform behaviour we need is identical.

3 new tests covering pure path composition (desktop nesting + empty
root) and a desktop-target-gated check that the detected data dir is
absolute. The OnceLock override is intentionally not unit-tested
because asserting its semantics would pollute global state for any
sibling test that calls `user_theme_dir()`.
2026-05-01 05:25:21 +00:00
funman300 936d035750 feat(engine): CardTheme asset + manifest loader (Card theme phase 2)
Implements Phase 2 of CARD_PLAN.md — the data types and `.theme.ron`
asset loader that build on Phase 1's SVG rasteriser.

solitaire_engine/src/theme/
  mod.rs        — CardKey { suit, rank } as the HashMap lookup key
                  (distinct from solitaire_core::Card which carries
                  per-deal id + face_up state); CardKey::all() yields
                  the 52 keys in suit-major / rank-ascending order;
                  manifest_name() and parse_manifest_name() round-trip
                  via the canonical "{suit}_{rank}" form.
                  ThemeMeta with structural validation (id non-empty,
                  no path separators, non-zero aspect components).
                  CardTheme #[derive(Asset, TypePath)] storing the
                  53 image handles + meta.
  manifest.rs   — ThemeManifest { meta, back, faces } with serde for
                  RON round-trip. validate() returns a strongly-typed
                  HashMap<CardKey, PathBuf>, surfacing precise errors
                  for unknown face keys, missing-of-52 entries, and
                  duplicate keys (RON silently keeps the last; brittle
                  for a release).
  loader.rs     — AssetLoader for .theme.ron. Validates manifest, then
                  composes sibling SVG paths via AssetPath::resolve so
                  the same loader works for both embedded:// and
                  themes:// asset sources (Phase 3 territory).
                  Schedules every face + back load through SvgLoader
                  with target_size derived from meta.card_aspect.

24 new tests covering: 52-key enumeration uniqueness, manifest-name
round trip, garbage-name rejection, complete/missing/unknown/duplicate
manifest validation, RON round-trip integrity, target-size aspect
math (2:3 → 512x768; non-standard; degenerate 1:10000 clamps to 1px).

Workspace deps added: ron 0.12.

cargo build / clippy --workspace --all-targets -- -D warnings / test
all green (937 passed total — +24 from Phase 2 vs the +7 from
Phase 1's b8fb3fb baseline).
2026-05-01 05:19:12 +00:00
funman300 b8fb3fbd6e feat(engine): SVG → Image asset loader (Card theme phase 1)
Implements the runtime SVG rasterisation pipeline that the card-theme
system (CARD_PLAN.md) is built on. Bevy 0.18 has no native SVG support;
this loader bridges usvg (parser) + resvg (renderer) + tiny-skia (CPU
pixmap) so the rest of the engine consumes themes as plain
Handle<Image>. Rasterisation happens once per (asset, settings) pair at
load time — Bevy's asset cache absorbs the cost.

solitaire_engine/src/assets/
  mod.rs           — module entrypoint
  svg_loader.rs    — SvgLoader (AssetLoader for .svg → Image)
                     SvgLoaderSettings { target_size: UVec2 } default 512×768
                     SvgLoaderError (Io / Parse / PixmapAlloc) via thiserror
                     rasterize_svg() helper exposed for non-asset-graph
                     callers (the future zip-importer validation step)

The rasteriser scales-to-fit while preserving aspect ratio, centring
the SVG inside the target box so a non-2:3 source doesn't pin to the
top-left corner.

7 new unit tests — default + custom target size, zero-dimension reject,
malformed-input reject, RGBA byte-count, extension advertisement, and
a compile-time guard that SvgLoaderSettings still satisfies the
AssetLoader::Settings trait bounds.

Workspace deps added: usvg 0.47, resvg 0.47, tiny-skia 0.12 (latest
minor versions; CARD_PLAN.md called out the placeholder numbers
needed verification).

cargo build / cargo clippy --workspace --all-targets -- -D warnings
/ cargo test --workspace all green (913 passed, 0 failed, 9 ignored —
+7 from the new loader tests).
2026-05-01 05:05:30 +00:00
funman300 800dfb50ce chore(pkg): add Arch Linux PKGBUILDs for game client and sync server
- pkg/solitaire-quest/PKGBUILD: builds solitaire_app binary, depends on
  alsa-lib, libxkbcommon, systemd-libs (Bevy Linux requirements); check()
  runs only non-Bevy crates (solitaire_core, solitaire_sync) since Bevy
  integration tests require a GPU/display unavailable in chroot
- pkg/solitaire-quest-server/PKGBUILD: builds solitaire_server binary,
  installs systemd service unit and hardened environment file template
- pkg/solitaire-quest-server/solitaire-quest-server.service: systemd unit
  with ProtectSystem=strict, NoNewPrivileges, dedicated service user
- pkg/solitaire-quest-server/server.env: documented env template installed
  to /etc/solitaire-quest-server/server.env (mode 0640, listed in backup=)
- LICENSE: add MIT license
- Cargo.toml: add license = "MIT" to [workspace.package]
- All member crates: add license.workspace = true

Both PKGBUILDs follow the Arch Rust package guidelines:
  prepare() uses --locked + cargo fetch
  build() uses --frozen --release -p <crate>
  RUSTUP_TOOLCHAIN=stable and CARGO_TARGET_DIR=target set in each stage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:44:44 +00:00
root 34ba4dc6ed feat(workspace): full server + sync implementation, all tests green
- solitaire_server: Axum auth, sync push/pull, leaderboard, daily
  challenge, account deletion, JWT middleware, rate limiting via
  tower_governor, SQLite migrations, health endpoint
- solitaire_server: expose build_test_router (no rate limiting) so
  integration tests work without a peer IP in oneshot requests
- solitaire_sync: SyncPayload, merge logic, shared API types
- solitaire_data: SyncProvider trait, LocalOnlyProvider,
  SolitaireServerClient, auth_tokens keyring integration, blanket
  Box<dyn SyncProvider> impl
- solitaire_data/settings: derive Default on SyncBackend (clippy fix)
- .sqlx/: offline query cache so server compiles without a live DB
- sqlx: removed non-existent "offline" feature flag
- keyring v2: fixed Entry::new() returning Result<Entry>
- sqlx 0.8: all SQLite TEXT columns wrapped in Option<T>
- Integration tests: max_connections(1) on in-memory pool so all
  connections share the same schema

All 191 tests pass; cargo clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:32:56 +00:00
Solitaire Quest a8a323c6c3 chore(deps): replace bevy_egui+bevy_kira_audio with bevy_ui+kira, drop AssetServer 2026-04-23 21:02:46 -07:00
funman300 c393eab17d feat(engine): add resources, events, and GamePlugin event routing
Introduces the plumbing layer for Phase 3: GameStateResource wraps
solitaire_core::GameState, DragState tracks in-progress drags, and
SyncStatusResource holds runtime sync status. GamePlugin routes
Draw/Move/Undo/NewGame request events into GameState and emits
StateChangedEvent and GameWonEvent for downstream systems.

Also adds the Phase 3 implementation plan under docs/superpowers/plans/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 16:15:38 -07:00
Solitaire Quest 684f07746d feat(workspace): initialize all seven crates with stubs and blank Bevy window
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 11:00:42 -07:00