Commit Graph

120 Commits

Author SHA1 Message Date
funman300 2f373784bf feat(engine): Phase M — sync transparency + local data backup
Test / fmt (pull_request) Successful in 5s
Test / test (pull_request) Successful in 4m22s
Closes out the 13-phase menu redesign. Three parts:

1. Post-sync merge summary: the pull poller now keeps the
   `ConflictReport`s the merge produces (previously discarded) in a new
   `SyncConflictLog` resource and toasts "Synced — N conflicts, kept
   newer values" when a merge wasn't clean. No wire changes — the
   server-side `SyncResponse.conflicts` field already existed.

2. Account tab detail: a per-field conflict list under the sync row
   ("win_streak_current — this device: 3 / server: 5") plus a
   clean-merge caption, so the last merge is always inspectable.

3. Local data export/import: new `solitaire_data::transfer` module —
   one versioned JSON bundle (settings, stats, achievements, progress)
   written atomically next to the other saves, with a version-gated
   loader that surfaces every failure instead of defaulting. Account
   tab grows an Export/Import button pair (desktop+Android only); the
   handler runs in `Last`, off the annotated Update spine. Import
   rewrites the live resources, persists via the existing save fns,
   and fires SettingsChangedEvent so appliers react normally.

Tests: bundle round-trip/version-gate/missing-file in solitaire_data;
ECS-level export→import round trip and failed-import-warns in
settings_plugin. Gate: workspace tests green, clippy -D warnings clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:06:02 -07:00
funman300 c2256582cd feat(engine): reduce-transparency + high-contrast support for glass chrome
New Settings::reduce_transparency_mode (serde-default, Accessibility
toggle row) swaps every GlassSurface's gradients for a flat opaque
BG_ELEVATED fill; high-contrast mode now also boosts the glass fill
opacity and rim luminance to BORDER_SUBTLE_HC levels. Both applied by
settings_plugin::update_glass_surfaces, which retargets the gradients
in place on toggle or on newly spawned glass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:16:32 -07:00
funman300 28be65f092 feat(engine): UI scale setting — 90/100/115/130% (Phase K)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m22s
Settings -> Accessibility gains a UI Scale row cycling four steps,
applied live through bevy::ui::UiScale: every menu, modal, and HUD
element scales while the table itself stays window-fit via
compute_layout. New Settings::ui_scale (serde default 1.0, sanitized
clamp to [0.9, 1.3]).

Safe-area anchors and modal scrim padding pre-divide physical insets
by the UI scale so post-multiplication lands exactly on the system
bars — without this, 90% would sink the bottom action bar into the
Android gesture zone. The anchor systems also re-run on UiScale
changes, not just inset changes.

Deferred from Phase K (noted in the doc): seeding the setting from the
Android system font scale on first run (needs JNI), and the 44px
touch-target audit (on-device).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:39:19 -07:00
funman300 65913de2cb feat(engine): contextual one-time tips — stall hint, radial menu teach (Phase I)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m15s
Two situation-fired teaches, each shown exactly once and recorded in
Settings like shown_achievement_onboarding:

- Stall tip: 45s with no board change in an active, started game
  (clock frozen while paused / a modal is open) points at Hint with
  platform-correct copy (H on desktop, bottom-bar Hint on touch).
- Radial teach: a player 15 moves into a game who has never opened the
  radial menu learns the long-press / right-click gesture. Organic
  radial use marks the tip done silently — nobody is taught what they
  already know.

Tips ride the queued InfoToastEvent path, so they render in the unified
toast stack and never interrupt play. This completes Phase I.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:16:56 -07:00
funman300 25f1fd27d9 feat(engine): one-shot What's-new card after updates (Phase I)
Test / fmt (pull_request) Successful in 5s
Test / test (pull_request) Successful in 5m0s
ObtainX updates install silently, so shipped features went unnoticed.
On the first launch where the running release differs from the new
Settings::last_seen_whats_new, a dismissible card summarises the
latest CHANGELOG.md section (embedded; its top version doubles as the
app's release identity — no build-time version plumbing). Internal
sections are dropped and bullets reduce to their bold lead sentence.

Launch beat: splash -> onboarding (first run) -> what's-new -> Home;
spawn_home_on_launch waits on the new WhatsNewPending resource. Fresh
installs never see the card — onboarding completion stamps the current
version silently. The seen-stamp persists on spawn, not dismissal, so
the card can never nag twice.

8 new tests (changelog parsing incl. the real embedded file, upgrade/
seen/fresh-install gating, dismissal). Workspace + clippy green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:05:15 -07:00
funman300 ae8118d7df feat(engine): theme-store preview thumbnails (Phase H part 2)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m36s
The live catalog has advertised preview_url per theme since the store
shipped, but the client never fetched it — rows were text-only. The
modal now downloads each advertised preview PNG off the main thread
(same AsyncComputeTaskPool + Tokio pattern as the catalog fetch and
avatar image), decodes it into an Image asset, and renders a thumbnail
sized by the theme's own card_aspect at the head of the row. Previews
pop in as they arrive and are cached for the session, so reopening the
store is instant. Failures log and leave the row text-only; the decode
path is skipped entirely under MinimalPlugins (no Assets<Image>).

solitaire_data grows ThemeStoreClient::fetch_preview with a 512 KB cap
(previews carry no checksum — decorative only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:26:47 -07:00
funman300 560470b86b feat(engine): Phase B home hierarchy — Continue card, hero New Game, deal-options disclosure
Test / test (pull_request) Failing after 10s
Restructures the Home overlay per docs/ui-redesign-2026-07.md Phase B:

- Continue card (mode / elapsed / score) when a game is in progress;
  clicking returns to the table
- Hero New Game replays the persisted Settings::last_mode (new field,
  serde-default Classic) with one tap; locked modes fall back to Classic
- Deal options (draw 1/3, winnable-only, difficulty tiers) move into a
  disclosure under the hero — off the Home top level (decision 2)
- Compact symmetric 2x3 mode grid; descriptions on wide viewports only
- Stats strip moves to the bottom (right pane on wide)
- Two-pane body on wide viewports (>= 1000 logical px) via new
  ui_modal::spawn_modal_sized (default card stays 720 px)
- Cancel becomes "Back to table" and only renders while a live
  (un-won) game exists

10 new home_plugin tests; all 936 engine tests + clippy green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:08:07 -07:00
funman300 07044f439c fix(core): save files store the deal via upstream serializers (schema v6)
Test / test (pull_request) Successful in 36m38s
GameState's save serde had the same latent flaw PR #170 removed from
replays: v5 persisted seed + saved_moves and re-dealt the board from the
seed on load, so any RNG or upstream upgrade that shifts the seed->deal
mapping would invalidate every in-progress save (graceful error, but the
player loses their game). v6 persists the existing SessionRecording
payload (upstream card_game session serde: config + dealt board +
instructions) instead; seed stays as presentation metadata only.

- v4/v5 files still load through the legacy seed path and are rewritten
  as v6 on the next save; v6 files load from the recording and replay
  with per-instruction validation, so tampered or corrupt files surface
  an error rather than a silently wrong board
- saved_moves() removed (dead once serialisation reads recording())
- tests: v6 round-trip via storage, seed-corruption immunity, v5 legacy
  load, missing-recording rejection, invalid-history rejection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:21:01 -07:00
funman300 4cb4212829 fix(replay): store the deal via upstream card_game serializers (schema v4)
Test / test (pull_request) Successful in 36m34s
Replays previously persisted only seed + moves and re-dealt the board
from the seed at playback time, so any change to the seed->deal mapping
(RNG bumps, upstream upgrades) silently invalidated every existing
replay. Schema v4 instead embeds a SessionRecording - the upstream
card_game Session serde ({config, initial_state, instructions}) - so
playback rebuilds the exact recorded board; seed/draw_mode/mode remain
caption metadata only.

- core: SessionRecording newtype delegating to Session<Klondike> serde;
  GameState::recording() / from_recording(); from_instructions_unchecked
  fixture helper; serde_json added to dev-deps (tests only)
- data: Replay v4 (recording replaces moves); v1-v3 files rejected by
  the existing version gate
- engine: win-recording and sync upload freeze game.recording();
  playback rebuilds from the recording; Playing carries the extracted
  move list (+ Box<Replay> for clippy large_enum_variant)
- wasm: replay_export() builds the full v4 upload payload so JS never
  hand-assembles it (the old game.js path hardcoded schema_version: 2
  and corrupted u64 seeds via Math.round); ReplayPlayer::from_json
  enforces schema_version == 4 with a descriptive error
- web: game.js/play.html use replay_export; replay.js surfaces player
  construction errors in the caption instead of dying silently
- server: mode validation accepts data-carrying GameMode variants
  (Difficulty uploads previously 400'd against the String field)

Both replays on prod are May-era v1 rows with empty move lists - every
shared replay was already unplayable; the viewer now says why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:10:38 -07:00
funman300 ccfb9394e0 chore: remove dead code and stale doc claims found in multi-agent sweep
Test / test (pull_request) Successful in 37m58s
Compiler-verified via RUSTFLAGS=--force-warn dead_code plus workspace-wide
reference greps; three parallel audit agents covered the engine crate, the
other eight crates, and Copilot commit-message-vs-diff drift.

Removed:
- replay_overlay/input.rs: 19 orphaned twins (~950 lines) of items also
  defined in mod.rs — the glob re-export made the mod.rs copies win and
  the file-level #![allow(dead_code)] hid the corpses. The live keyboard/
  button handlers and ReplayScrubKeyHold stay; the allow is retired.
- retarget_animation (never called; doc examples were its only refs)
- ScanThemesRequestEvent (never registered/written/read; its doc claimed
  a handle_scan_themes consumer that does not exist)
- _VEC3_REFERENCED workaround const + now-unneeded Vec3 import
- solitaire_data: load_stats/save_stats/time_attack_session_with_now
  default-path wrappers (the _from/_to variants are the live API) and
  surplus re-export names (settings MIN/MAX bounds, token loaders)
- solitaire_core: Session re-export (no external consumer)
- solitaire_wasm: ReplayPlayer::is_finished (no JS caller)
- solitaire_app: build_app wrapper (real entry is run())

Doc fixes:
- audio_plugin: WAV count 5→7, add FoundationCompletedEvent table row,
  drop bogus 'placeholder' label, bevy_kira_audio→kira
- ToastVariant::Warning: variant is live (5 writer plugins); dropped the
  stale allow(dead_code) and its 'currently unused' comment

Deliberately kept: Spider module (staged forward work), WinCascadePlugin
(documented alternative cascade, pending owner decision), SyncCompleteEvent
and solitaire_sync ApiError/merge_at (§8 change-controlled, flagged to owner).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:48:33 -07:00
funman300 d87397b382 feat: in-game theme store — server catalog + verified downloads + install UI
Test / test (pull_request) Successful in 10m15s
Phase 1+2 of the theme-store roadmap: a free catalog served by
solitaire_server and an in-app browse/install flow, making custom
themes installable on Android for the first time (the manual
drop-a-zip flow can't reach the app-private themes dir there).

- solitaire_sync: ThemeCatalogEntry/ThemeCatalogResponse wire types
  (additive module; SyncPayload and SyncProvider untouched)
- solitaire_server: THEME_STORE_DIR scan at startup (meta-only
  theme.ron parse, sha256, 20 MiB cap, best-effort per archive);
  public GET /api/themes, /api/themes/{id}/download, /{id}/preview;
  compose volume + README_SERVER docs
- solitaire_data: ThemeStoreClient — catalog fetch + download with
  mandatory size/sha256 verification before bytes are released
- solitaire_engine: ThemeStorePlugin — 'Browse theme store' button in
  Settings → Cosmetic, modal catalog (leaderboard-style rebuild),
  download on AsyncComputeTaskPool, atomic .tmp+rename write into
  user_theme_dir, then the existing hardened import_theme pipeline and
  an in-place registry refresh

New deps: sha2 (workspace, server+data); ron/zip reused in server;
serde_json added to solitaire_sync dev-deps for DTO round-trip tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:12:41 -07:00
funman300 018b69285d fix(engine): unify light/dark theme manifest URL resolution
Test / test (pull_request) Successful in 13m4s
- set_theme resolved every id to themes:// — the bundled dark/classic
  themes live at embedded://, so switching to them via the public API
  would NotFound and silently keep the old theme. All three load paths
  now share one theme_manifest_url resolver.
- load_initial_theme's settings-absent fallback was "classic", a
  leftover from v0.33 when classic was the default; it now derives from
  Settings::default() so it can't drift from the data crate again, with
  a test pinning the default id to a bundled theme.
- settings.rs doc no longer claims "classic" is migrated to "dark";
  only the pre-rename "default" id is rewritten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:25:35 -07:00
funman300 abf1312cf5 fix(data): serialise token refreshes so overlapping 401s can't force a logout
Test / test (pull_request) Successful in 15m33s
Refresh tokens are single-use since the rotation change (PR #136). Two
in-flight requests hitting 401 together (replay upload racing a manual
sync) both called /api/auth/refresh; the second presented an already-
consumed token, got rejected, and surfaced as a spurious 'session
expired' re-login prompt.

refresh_token() now takes the stale access token that earned the 401
and runs behind a tokio::sync::Mutex; a loser of the lock race that
finds the stored token already rotated returns Ok and retries with it
instead of spending the new refresh token.

Untested caveat: exercising the race in a unit test needs an in-memory
auth_tokens double (the real keyring is unavailable on headless Linux
runners) — noted for a future test-support addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:04:30 -07:00
funman300 113a933170 style: cargo fmt under rustfmt 1.9 and gate formatting in CI
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95)
wraps signatures and call sites differently, so every touched file was
picking up unrelated formatting hunks. One mechanical pass, and a
'cargo fmt --check' step in the test workflow (same pinned 1.95.0
toolchain) so drift can't accumulate again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:59:20 -07:00
funman300 9299176b2d refactor(android): forbid unsafe workspace-wide, quarantine JNI in app (#91)
Addresses #91: the #90 posture (`deny(unsafe_code)` + three scattered
`#![allow(unsafe_code)]` across solitaire_data and solitaire_engine) punched
unsafe holes into otherwise-pure logic crates. Replace it by *reducing* the
unsafe rather than relocating it, then forbidding it everywhere it can be
forbidden.

Changes:
- solitaire_data: new safe `android_jni` bridge owning the cached `JavaVM`
  and activity `GlobalRef`; exposes `with_env` / `with_activity_env` so
  keystore/clipboard/safe-area never touch a raw handle.
- keystore: drop the `JavaVM::from_raw` init path (now in the app) and
  replace the three `unsafe { JByteArray::from_raw(x.into_raw()) }` casts
  with the safe `JByteArray::from(JObject)` conversion jni 0.21 provides.
- engine clipboard + safe_area: route through the bridge; remove their
  `#![allow(unsafe_code)]` and all `from_raw` calls.
- solitaire_app: becomes the single owner of FFI unsafe. `android_main`
  reconstructs the raw `JavaVM` / activity once (it must, as the cdylib that
  exports `#[unsafe(no_mangle)]`) and registers the safe wrappers. It opts to
  its own `deny`-level lints with two scoped `#[allow(unsafe_code)]`.
- workspace: `unsafe_code` is now `forbid`. Every crate except the app entry
  point is fully unsafe-free.

Net: 7 unsafe sites across three crates collapse to 3 at the OS boundary in
one crate. Verified with host `clippy --workspace -- -D warnings` and an
`aarch64-linux-android` clippy build of solitaire_app (transitively engine +
data); also fixed two latent android-only `collapsible_if` warnings surfaced
in the keystore by the cross-target check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:32:26 -07:00
funman300 ceb9c950a1 chore: add pedantic workspace lints (#90)
Add [workspace.lints.rust] and wire each member crate up with
[lints] workspace = true:

  unsafe_code = "deny"        (forbid would break the Android JNI build)
  single_use_lifetimes = "warn"
  trivial_casts = "warn"
  unused_lifetimes = "warn"
  unused_qualifications = "warn"
  variant_size_differences = "warn"
  unexpected_cfgs = "warn"

unsafe_code is "deny" rather than the issue's "forbid" so the three
Android JNI FFI modules (android_keystore, android_clipboard, safe_area)
can opt back in with a scoped #![allow(unsafe_code)] — forbid cannot be
locally overridden. Pure crates carry no unsafe and stay clean.

Clean up the warnings the new lints surface:
- 150ish unused_qualifications removed via `cargo fix` (purely syntactic
  redundant-path-prefix removals).
- table_plugin: the TABLE_COLOUR import was #[cfg(test)]-gated while the
  camera clear-colour used the fully-qualified path; unqualifying it left
  a non-test build with no import. Made the import unconditional instead.
- assets/sources: the `as &[u8]` casts in embed_*_svg! coerce each
  fixed-size &[u8; N] to a uniform slice so the tuples fit the
  &[(&str, &[u8])] arrays — load-bearing, so scoped #[allow(trivial_casts)].

Workspace clippy -D warnings and the full test suite pass. Android build
not compiled here (needs the NDK; built separately per CLAUDE.md §15) —
the deny + scoped-allow keeps the JNI unsafe blocks legal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:05:28 -07:00
funman300 9bbb57134f refactor: persist replay/save moves as KlondikeInstruction, not pile coords (#89)
Pile-position types (Tableau, Foundation, KlondikePile, KlondikePileStack)
are runtime-only and have no serde upstream. Per Rhys's guidance, the
persistence layer now stores the moves (KlondikeInstruction) rather than
board coordinates, decoding back to runtime pile positions on demand.

Core / data:
- game_state: instruction_history() -> Vec<KlondikeInstruction>; add
  instruction_to_piles() and apply_instruction(); drop AnyInstruction.
- klondike_adapter: delete the entire Saved* serde mirror section
  (SavedTableau/Foundation/SkipCards/KlondikePile/TableauStack/
  KlondikePileStack/DstFoundation/DstTableau/SavedInstruction).
- replay: drop the bespoke ReplayMove serde mirror; Replay.moves is now
  Vec<KlondikeInstruction>; REPLAY_SCHEMA_VERSION 2 -> 3.
- storage: game_state save format v3 rejected (v4/v5 only).

Engine / wasm consumers:
- record via KlondikeInstruction (stock click = RotateStock).
- playback decodes each instruction to (from, to, count) against the
  live state via instruction_to_piles, then fires the canonical event;
  undecodable instructions are skipped with a warning, never panic.
- remove all use solitaire_data::ReplayMove and Saved* imports.

Workspace check, clippy -D warnings, and the full test suite all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:43:47 -07:00
funman300 5c992cbdca refactor: replace local DrawMode with upstream klondike::DrawStockConfig (#82)
DrawMode was a 1:1 mirror of klondike::DrawStockConfig (DrawOne/DrawThree).
Delete it and use the upstream type everywhere; re-export DrawStockConfig from
solitaire_core. config_for assigns draw_stock directly and draw_mode() returns
session.config().inner.draw_stock.

Serde is unchanged — DrawStockConfig serialises to the same "DrawOne"/"DrawThree"
named variants, so persisted game_state.json / replay JSON stay byte-compatible
(no migration). Field/method/variable names containing draw_mode are unchanged.

35 files, mechanical type swap across all crates. Implemented via a multi-agent
workflow (core → per-crate consumers → verify). cargo test --workspace and
clippy --workspace --all-targets -- -D warnings green.

Closes #82

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:01:11 -07:00
funman300 e841a7ab4f refactor: delete solitaire_data::solver wrapper; solve via card_game directly
Remove the standalone solver wrapper module. Its thin shaping — build a
solve-budgeted Session, run card_game::Session::solve(), extract the first
useful move — moves onto the domain type in solitaire_core as
GameState::solve_first_move() / GameState::solve_fresh_deal(), with the budget
consts and the SolveOutcome alias re-exported from solitaire_core.

Solving is deterministic, IO-free game logic, so core (which already owns
GameState and exposes session().solve()) is its correct home; solitaire_data is
the persistence/sync layer and never should have owned it.

Consumers now call the core API directly:
- engine: pending_hint (solve_first_move), game_plugin + play_by_seed_plugin
  (solve_fresh_deal), input_plugin (budget consts)
- assetgen: gen_seeds + gen_difficulty_seeds (solve_fresh_deal)

The solver tests move to solitaire_core. cargo test --workspace and
clippy --workspace --all-targets -- -D warnings both green.

Resolves the "delete the solver" directive — card_game provides the solver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:04:47 -07:00
funman300 372b6423d8 refactor(core): derive score/undo/recycle from upstream session stats
Replace the bespoke WXP scoring engine with the upstream
card_game/klondike session stats, eliminating duplicated state that
could drift from the single source of truth.

score()/undo_count()/recycle_count() now read session.stats(); the -15
undo penalty is configured as SessionConfig::undo_penalty and applied by
the upstream score formula. Save schema bumped v4 -> v5 (the three
counters are no longer persisted -- they are rebuilt by replaying the
forward instruction history on load).

- Remove GameState fields score, undo_count, recycle_count (#87)
- Remove score_history / is_recycle_history undo journal (#86)
- Remove KlondikeAdapter::apply_undo_score and the score_for_* helpers,
  plus pre_instruction_score_delta / will_flip_tableau_source (#84)

These three issues are a single atomic change: each removed field/helper
is consumed by the same draw/apply_instruction/undo/serde/PartialEq
paths, so they cannot compile or pass tests in isolation.

Behaviour changes (intentional): the escalating recycle penalty and
per-step score floor are gone (upstream linear scoring, floored once at
0); recycle_count is now cumulative; undo_count resets across save/load.

Refs #84, #86, #87

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:36:31 -07:00
funman300 f0832f3dfa refactor: remove leftover redundancies after card_game migration
Post-migration audit found the card_game/klondike migration essentially
complete; these are the four small redundancies that remained:

- core: delete dead GameState::compute_time_bonus (zero callers; engine
  uses the klondike_adapter free fn directly)
- data: drop dead public re-exports load_latest_replay_from /
  save_latest_replay_to (no callers outside replay.rs); keep
  latest_replay_path (engine legacy migration still uses it)
- data+engine: lift win-XP scoring into a shared XpBreakdown so the
  win-summary modal breakdown and xp_for_win share one source of truth
  instead of duplicating the speed/no-undo constants
- engine: replace feedback_anim_plugin's private foundation_from_slot
  copy with the canonical klondike_adapter::foundation_from_slot

cargo test --workspace + clippy -D warnings green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:12:03 -07:00
funman300 ef1efdc3b5 refactor(core): make KlondikeInstruction the move currency
Build and Deploy / build-and-push (push) Failing after 1m1s
Web E2E / web-e2e (push) Failing after 3m26s
Remove the (from, to, count) tuple as an internal move-passing wrapper.
Game logic now stays in KlondikeInstruction space end to end:

- Add GameState::apply_instruction, the native apply path. move_cards
  becomes a thin pile-coordinate adapter that converts to an instruction
  and delegates, so move bookkeeping (validation, score/recycle history,
  undo snapshot) lives in one place instead of being duplicated.
- next_auto_complete_move matches DstFoundation directly instead of
  projecting every candidate to pile coordinates.
- proptests and the storage round-trip test apply instructions directly
  rather than round-tripping instruction -> tuple -> move_cards.

The single instruction -> pile decode is renamed instruction_to_highlight
-> instruction_to_piles and kept in core: decoding a tableau run length
needs upstream pile-stack types core does not re-export, so relocating it
would duplicate the logic across engine and wasm. The two rendering edges
(engine hint highlight, wasm debug move list) call this one decoder; the
engine's hint_piles is a thin delegation to it.

Also includes the CardEntityIndex render-side index and a SelectionPlugin
init_resource fix so update_selection_highlight no longer panics in test
harnesses that omit CardPlugin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:58:28 -07:00
funman300 cac77a54a6 refactor: slim solver to card_game-native types
Build and Deploy / build-and-push (push) Failing after 1m34s
Web E2E / web-e2e (push) Failing after 4m22s
Per Rhys: card_game's solver is the real engine, so drop the redundant
adapter types in solitaire_data::solver rather than maintain a parallel
verdict/config/move vocabulary.

- Delete SolverResult, SolverConfig, SolverMove, and snapshot_to_solver_move.
  The verdict now reads straight off card_game's return:
    Ok(Some(instr)) = winnable (first move on the path)
    Ok(None)        = provably unwinnable
    Err(_)          = inconclusive (budget exceeded)
- SolveOutcome is now Result<Option<KlondikeInstruction>, SolveError>.
- try_solve / try_solve_from_state take plain (moves_budget, states_budget)
  u64s; add DEFAULT_SOLVE_{MOVES,STATES}_BUDGET consts.
- snapshot_to_solver_move duplicated core's GameState::instruction_to_move,
  so make that pub and have the hint convert the first-move instruction to
  highlighted (from, to) piles through it. Re-export KlondikeInstruction
  from solitaire_core.
- HintSolverConfig now holds { moves_budget, states_budget } instead of
  wrapping the deleted SolverConfig.
- Update consumers: pending_hint, play_by_seed (verdict badge), game_plugin
  (choose_winnable_seed), input_plugin, hud_plugin, and the gen_seeds /
  gen_difficulty_seeds asset tools.

solver.rs drops 274 -> 140 lines. cargo test --workspace and
cargo clippy --workspace --all-targets -- -D warnings pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:05:47 -07:00
funman300 056459619b refactor(core): derive draw_mode/is_won/move_count/is_auto_completable from session
Remove the draw_mode, move_count, is_won, and is_auto_completable fields
from GameState; they are now &self methods deriving from the underlying
card_game session (draw_mode from session config, move_count from history
length, is_won/is_auto_completable from check_win/check_auto_complete).

Tests previously fabricated these via direct field writes, which is no
longer possible. Add gated test-support overrides on TestPileState
(won/auto_completable/move_count) plus setters set_test_won,
set_test_auto_completable, set_test_move_count, and set_test_draw_mode
(re-deals the seed). All compiled out in production builds.

Fix the field->method ripple across solitaire_data, solitaire_wasm, and
solitaire_engine. Add a test-support dev-dependency to solitaire_data for
the won-game storage test.

cargo test --workspace and cargo clippy --workspace -- -D warnings pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:24:03 -07:00
funman300 920f2c8597 refactor(core): move solver to solitaire_data, DrawMode to klondike_adapter, remove pile/solver/schema_version
- Delete solitaire_core::solver — moved wholesale to solitaire_data::solver (re-exported at crate root)
- Delete solitaire_core::pile — no external users
- Move DrawMode from game_state to klondike_adapter; re-export as solitaire_core::DrawMode
- Remove schema_version field from GameState (redundant — deserializer stamps it from the constant)
- Update all callers across solitaire_data, solitaire_engine, solitaire_assetgen, solitaire_wasm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:38:04 -07:00
funman300 7fe6ac6c1c docs: catch up handoff and changelog
Build and Deploy / build-and-push (push) Successful in 5m23s
2026-06-08 19:03:40 -07:00
funman300 26f1b00186 docs(core,data): complete Phases 0–2 of in-place card_game rewrite
Phase 0 – doc fixes (docs/card-game-integration.md):
- Correct stale "no serde" claim: upstream has serde at rev 99b49e62
- Correct take_from_foundation default description (Allowed, not Disallowed)
- Document schema v3→v4 migration and AnyInstruction strategy

Phase 1 – delegate check_win / check_auto_complete to upstream:
- Proptests verify semantic agreement with is_win() / is_win_trivial()
  across 256 random states before delegation

Phase 2 – schema v4 with v3 auto-migration:
- SavedInstruction mirror types kept as legacy compat module (needed by
  solitaire_data::ReplayMove and solitaire_wasm replay layer)
- klondike_adapter.rs: add comprehensive legacy-purpose doc comment
- proptest_tests.rs: add check_auto_complete/check_win semantic proofs
- storage.rs: rename round-trip test to v4, add v3-migrates-to-v4 test

Also track the rewrite plan (docs/in-place-card-game-rewrite-plan.md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:59:18 -07:00
funman300 9bcf13d8f2 test(core,data): verify schema-v3 round-trip; pin upstream git deps
- solitaire_data: add game_state_v3_mid_game_round_trip — first test to
  exercise the schema-v3 instruction-replay path with a real mid-game
  state (draws + card move + undo); GameState::PartialEq validates all
  pile layouts, score, move_count, undo_count, and recycle_count
- solitaire_data: add save_format_v2_is_rejected — schema-version gate
  test, parallel to the existing v1 rejection fixture
- solitaire_core: add SavedInstruction proptest (256 random cases across
  all three instruction variants) and four boundary unit tests for
  out-of-range Tableau/Foundation/SkipCards values
- solitaire_core: document pile() KlondikePile::Stock → waste mapping
- solitaire_core: document replay_config() take_from_foundation=true
  invariant and the re-export policy for upstream types
- Cargo.toml: pin card_game + klondike git deps to rev 99b49e62

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:41:50 -07:00
funman300 7fa91b6fb4 data(seeds): regenerate difficulty seeds (2026-06-04)
Replace the 2026-05-09 seed lists with seeds regenerated on 2026-06-04.
All seeds remain verified winnable within their respective solver budgets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:05:37 -07:00
funman300 fa786bafcf feat(android): wire Android Keystore JNI via OnceLock
Remove the dependency on bevy::android::ANDROID_APP inside
android_keystore.rs. Instead, solitaire_data owns a process-wide
OnceLock<JavaVM> initialised by a new pub fn init_android_jvm().
solitaire_app calls it from android_main before run() so JNI is
ready before any auth-token operation can execute.

- android_keystore: drop ANDROID_APP import; add ANDROID_JVM OnceLock
  and init_android_jvm(vm_ptr: *mut c_void)
- solitaire_data/lib.rs: re-export init_android_jvm for android target
- auth_tokens.rs: update doc comment (Android backend is now complete)
- solitaire_app/lib.rs: call init_android_jvm from android_main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:05:23 -07:00
funman300 d864d985c8 refactor(engine,wasm,data): route all klondike/card_game imports through solitaire_core
Build and Deploy / build-and-push (push) Failing after 53s
Web E2E / web-e2e (push) Failing after 4m16s
All downstream crates now import Foundation, KlondikePile, Tableau,
Klondike, Session, Suit, Rank exclusively from solitaire_core.
solitaire_core is the single version-pin point for the upstream crates.

- solitaire_engine: 19 files updated, klondike direct dep removed
- solitaire_wasm: use statement updated, klondike direct dep removed
- solitaire_data: unused klondike dep removed
- Cargo.lock: klondike no longer a direct dep of engine/wasm/data
- Full workspace clippy clean, all tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:04:05 -07:00
funman300 baf524ec75 fix(web): rebuild Bevy canvas WASM; add SolitaireGame interactive API
Grey screen fix (canvas_bg.wasm):
- Rebuilt Bevy WASM from refactored solitaire_core that removes the
  per-game KlondikeAdapter field from GameState. The old binary was
  built with wasm-opt -Oz; the large adapter allocation pattern appears
  to trigger an over-aggressive wasm-opt optimisation that corrupts
  Bevy's render pipeline, causing a permanent grey screen on /play.
- build_wasm.sh: change wasm-opt -Oz → -O2. Speed-optimised level avoids
  the size-focused transforms that miscompile Bevy's deep render stacks.

solitaire_core refactoring:
- game_state.rs: remove adapter: KlondikeAdapter field; use static
  KlondikeAdapter::config_for() instead of a per-instance allocation.
  Gate test_pile_state behind #[cfg(feature = "test-support")] so
  production builds carry no test-only heap state.
  Add instruction_history() public accessor (delegates to saved_moves()).
- card.rs: add Card::new(), face_up(), face_down() const constructors
  for more ergonomic test and wasm code.
- pile.rs, solver.rs: cargo fmt.

solitaire_wasm interactive API:
- lib.rs: add SolitaireGame wasm-bindgen struct with draw(), move_cards(),
  undo(), auto_complete_step(), serialize(), from_saved() — the full
  player-action surface used by game.js.
  Add DebugSnapshot, DebugMove, DebugInvariantReport structs and
  debug_snapshot(), debug_legal_moves(), debug_apply_move_json()
  methods for e2e test automation (window.__FERROUS_DEBUG__ bridge).
  Add replay_moves() to export the current game as a Replay v2 payload.
- solitaire_wasm.js + solitaire_wasm_bg.wasm: rebuilt with new API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:21:20 -07:00
funman300 a92ac066a6 fix(web): resolve wasm32 runtime panics; game boots and renders in Firefox
Build and Deploy / build-and-push (push) Failing after 1m6s
Fixes found while testing the Bevy WASM build in a real browser:

1. chrono wasmbind: add `wasmbind` feature to workspace chrono dep so
   Local::now()/Utc::now() use js-sys::Date on wasm32 (previously
   fell through to std::time::SystemTime which panics on wasm32).

2. std::time::SystemTime: replace all remaining direct SystemTime::now()
   calls (4 sites across game_plugin, difficulty_plugin, time_attack_plugin,
   solitaire_data/storage) with chrono::Utc::now() which is wasm32-safe.

3. user_dir: return empty PathBuf (instead of panicking) when data_dir()
   is None on wasm32; there is no filesystem in the browser so user themes
   are unsupported and a benign empty path is correct.

4. ThemeRegistryPlugin: gate build_registry_on_startup to non-wasm32
   (the filesystem scan for user themes has nothing to scan in the browser;
   only the bundled embedded themes are available).

5. AssetMetaCheck::Never: configure AssetPlugin in solitaire_web to skip
   `.meta` sidecar fetches — we don't ship .meta files, so the default
   AssetMetaCheck::Always produced a 404 flood on every card/background asset.

Result: `http://localhost:<port>/play` boots in Firefox with zero errors
and renders the full Bevy game — home screen, onboarding modal, HUD all
visible. Assets load correctly from /assets/. Chromium has a separate
wgpu-27/ANGLE/GLES shader translation bug (not in our code); Firefox works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:16:19 -07:00
funman300 f464aab543 fix(web): clean up wasm32 build warnings and wire /play route to Bevy canvas
Build and Deploy / build-and-push (push) Failing after 44s
- solitaire_data/sync_client.rs: fix SyncPayload/SyncResponse import split
  (SyncResponse is needed by LocalOnlyProvider which compiles on wasm32)
- solitaire_engine/assets/sources.rs: cfg-gate AssetApp/AssetSourceBuilder
  imports (only used in the non-wasm FileAssetReader block)
- solitaire_engine/auto_complete_plugin.rs: cfg-gate AUTO_COMPLETE_CHIME_VOLUME
- solitaire_engine/daily_challenge_plugin.rs: cfg-gate Task/AsyncComputeTaskPool
  imports and DailyChallengeTask struct (server fetch systems are non-wasm only)
- solitaire_engine/resources.rs: cfg-gate std::sync::Arc (TokioRuntimeResource
  is non-wasm only)
- solitaire_engine/settings_plugin.rs: cfg-gate ScanThemes variant, pill_button,
  and their match arms; fix refresh_registry import placement
- solitaire_server/src/lib.rs: point /play route at play.html (Bevy canvas);
  keep /play-classic serving game.html during transition period
- build_wasm.sh: add --no-typescript to wasm-bindgen call for canvas build
- solitaire_server/web/pkg: add canvas.js + canvas_bg.wasm build artifacts

wasm32 build and native clippy --workspace -D warnings both clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:55:39 -07:00
funman300 835a48fe9d feat(web): add solitaire_web Bevy WASM build targeting play.html canvas
Build and Deploy / build-and-push (push) Failing after 58s
Adds a new `solitaire_web` crate that compiles the full `solitaire_engine`
to `wasm32-unknown-unknown` and renders to a `<canvas id="bevy-canvas">`
element in `play.html` — the same ECS code path as desktop and Android.

Changes to enable the WASM target:
- .cargo/config.toml: add wasm32-unknown-unknown rustflags for getrandom
- Workspace Cargo.toml: add solitaire_web member
- solitaire_data/Cargo.toml: gate tokio/reqwest/dirs/keyring to non-wasm
- solitaire_data/src: add wasm32 branch to data_dir() (returns None);
  cfg-gate sync_client network types, auth_tokens, matomo_client
- solitaire_engine/Cargo.toml: gate tokio/reqwest/kira/arboard/dirs/zip
  to non-wasm (mio/cpal/arboard don't compile for wasm32-unknown-unknown)
- solitaire_engine/src/lib.rs: cfg-gate module declarations and re-exports
  for analytics, audio, sync, sync_setup, avatar, leaderboard plugins
- solitaire_engine/src/core_game_plugin.rs: cfg-gate plugin registrations
  that require TokioRuntime (audio, sync, analytics, leaderboard, avatar)
- solitaire_engine/src/resources.rs: cfg-gate TokioRuntimeResource
- solitaire_engine/src/game_plugin.rs: cfg-gate std::fs::remove_file (x10)
- solitaire_engine/src/theme/mod.rs: cfg-gate importer module (uses dirs+zip)
- solitaire_engine/src/settings_plugin.rs: cfg-gate theme ZIP import UI
- solitaire_engine/src/assets/sources.rs: cfg-gate FileAssetReader/user_theme_dir
- solitaire_engine/src/auto_complete_plugin.rs: cfg-gate audio system
- solitaire_engine/src/daily_challenge_plugin.rs: cfg-gate server fetch
- solitaire_engine/src/hud_plugin.rs: cfg-gate AvatarResource import
- solitaire_engine/src/profile_plugin.rs: cfg-gate AvatarResource import
- solitaire_server/web/play.html: minimal HTML canvas shell
- solitaire_web/: new crate (Cargo.toml + src/lib.rs)
- build_wasm.sh: add Bevy WASM build step (cargo + wasm-bindgen + wasm-opt)

All tests pass; clippy --workspace -- -D warnings clean; native build
(solitaire_engine, solitaire_app) unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:46:45 -07:00
funman300 9260ca7994 refactor: migrate PileType → KlondikePile across core/wasm/engine
Build and Deploy / build-and-push (push) Failing after 1m24s
- Replace PileType with typed KlondikePile (Foundation/Tableau variants)
  throughout solitaire_core, solitaire_wasm, and solitaire_engine;
  ReplayMove now uses SavedKlondikePile for serialisation stability
- Split replay_overlay.rs into replay_overlay/ module (mod, format,
  input, update, tests) for maintainability
- Add klondike dep to solitaire_engine and solitaire_data Cargo.toml
- Add TestPileState infrastructure to game_state.rs for engine unit tests
- Rebuild solitaire_wasm pkg (js + wasm artefacts updated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:13:35 -07:00
funman300 6496e130f3 feat(core): Step 2 — replace pile management with Session<Klondike>
Build and Deploy / build-and-push (push) Failing after 29s
- Delete rules.rs (228 lines) — move validation now handled by klondike engine
- Delete SolverState DFS from solver.rs (~900 lines) — replaced by session.solve()
- Rewrite GameState::new_with_mode() using Klondike::with_seed() (removes deck.rs dep)
- Rewrite move_cards/draw/undo to use Session<Klondike> as move executor
- Remove internal undo_stack (VecDeque<StateSnapshot>) — session owns history
- Sync piles from KlondikeState after each move via sync_piles_from_session()
- Update engine layer (game_plugin, input_plugin, card_plugin, etc.) to new API
- Net: 821 insertions, 3872 deletions (-3051 lines)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 17:31:09 -07:00
funman300 927598202e feat(engine,data): add tap-to-select touch input mode (#70)
- Add TouchInputMode enum (OneTap | TapToSelect) to solitaire_data settings
- Create TouchSelectionPlugin with TouchSelectionState resource and highlight
- Branch handle_double_tap: OneTap → existing auto-move, TapToSelect → two-tap flow
- Add Settings UI toggle row (Touch Input Mode) with TouchInputModeText marker
- Register TouchSelectionPlugin in CoreGamePlugin

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 14:04:40 -07:00
funman300 6e407a3ea7 fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 13:07:22 -07:00
funman300 0437c36463 fix(assets,theme): remove assert in svg_loader, log theme failures, fix default theme id (#58, #63, #64)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 19:36:05 -07:00
funman300 d5d869a6c8 fix(multi): resolve 16 bugs from comprehensive rules and code review
Build and Deploy / build-and-push (push) Successful in 4m12s
Core (solitaire_core):
- fix(core): auto-complete now requires waste empty to prevent deadlock
- fix(core): reject multi-card moves from waste pile (Klondike rule)
- fix(core): reject foundation-to-foundation moves (score farming exploit)
- fix(core): undo restores score from snapshot baseline, not live score
- feat(scoring): add +5 flip bonus when face-down tableau card is exposed
- feat(scoring): add recycle penalty (Draw-1: -100/pass, Draw-3: -20/pass)

Engine (solitaire_engine):
- fix(engine): remove TokioRuntimeResource::default() panic; degrade gracefully
- fix(engine): add ModalScrim guard to handle_new_game spawn site
- fix(engine): add ModalScrim guard to spawn_restore_prompt spawn site
- fix(engine): add ModalScrim guard to check_no_moves spawn site

Server / Web (solitaire_server):
- fix(web): correct draw_mode casing in replay submission (DrawOne/DrawThree)
- fix(web): correct mode casing in replay submission (Classic) for leaderboard
- fix(web): trim recorded_at to YYYY-MM-DD for NaiveDate deserialization
- fix(server): move /avatars route outside auth middleware (was always 401)

Data / Sync (solitaire_data, solitaire_sync):
- fix(data): namespace Android token file under APP_DIR_NAME with migration
- fix(data): Android token store now multi-user (HashMap); no silent overwrite
- fix(sync): draw_one_wins + draw_three_wins invariant preserved after merge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:27:09 -07:00
funman300 8f86d66ffe fix(engine): fix three leaderboard bugs — wrong toast type, stale name label, name not synced to server
Android Release / build-apk (push) Successful in 3m51s
- poll_opt_in_task / poll_opt_out_task: error branches now fire WarningToastEvent instead of InfoToastEvent
- Settings gains leaderboard_opted_in: bool (serde-defaulted to false); set true/false when opt-in/out tasks succeed
- handle_display_name_confirm: when already opted in and a remote provider is active, spawns an opt_in_leaderboard task to push the new name (server endpoint is an upsert)
- LeaderboardPublicNameText marker component added; update_leaderboard_public_name_label system rewrites the label each frame the panel is open, so it reflects SettingsResource immediately after the display-name modal saves

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:55:22 -07:00
funman300 2e52f544f1 fix(data): enforce 32-char display_name limit at sync client boundary (M-22)
opt_in_leaderboard in sync_client.rs was passing display_name through
as-is, relying solely on the engine's .chars().take(32) call upstream.
Add the truncation in the sync client so any caller is protected, and
also apply it at save-time in handle_display_name_confirm so settings
never stores an over-length name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:29:38 -07:00
funman300 2301cc65d3 fix(data): align android_keystore temp extension with cleanup glob (M-21)
The keystore atomic write used path.with_extension("tmp") producing
auth_tokens.tmp, while cleanup_orphaned_tmp_files only matched *.json.tmp.
A crash after the write but before the rename left an orphaned file
invisible to cleanup.

Fix: use path.with_extension("bin.tmp") to produce auth_tokens.bin.tmp,
and broaden the cleanup glob from ends_with(".json.tmp") to
ends_with(".tmp") so both JSON and binary temp files are caught.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:26:23 -07:00
funman300 67271266e1 refactor(data,core): consolidate APP_DIR_NAME and add #[must_use] on pure fns
- Hoist APP_DIR_NAME = "ferrous_solitaire" to solitaire_data crate root
  as pub(crate); remove 5 duplicate local definitions across achievements,
  progress, settings, storage, replay modules (L-9)
- Add #[must_use] to can_place_on_foundation, can_place_on_tableau, and
  is_valid_tableau_sequence in solitaire_core::rules so callers that
  accidentally discard the result get a compile-time warning (L-6)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:43:47 -07:00
funman300 69c6e88188 fix(core,sync,data): deterministic pile serialization, undo skip, url-encode bytes, merge_at
- Derive PartialOrd+Ord on PileType and sort pile entries in pile_map_serde
  before serializing so save-file output is deterministic (M-4)
- Add #[serde(skip)] to undo_stack so transient undo history is never written
  to save files, eliminating unnecessary bloat (M-3)
- Add merge_at() accepting an explicit resolved_at timestamp so callers can
  inject the server-side time; merge() wraps it with Utc::now() for
  backwards compatibility (M-1)
- Fix url_encode to percent-encode UTF-8 bytes rather than Unicode codepoints
  so multi-byte characters produce RFC 3986-compliant output (M-2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:28:46 -07:00
funman300 7f450aab17 fix(android): default to classic theme to fix AMOLED card-back invisibility
Android Release / build-apk (push) Successful in 4m7s
Dark theme back.svg uses #151515 (near-black) as the card back background,
which AMOLED screens render as fully-off pixels, leaving only the tiny
#a54242 red badge visible — user sees solid red squares instead of card backs.

Fix: change fresh-install default theme from "dark" to "classic" (white
background with navy diamond pattern, clearly visible on all display types).
Also remove the stale "classic" -> "dark" sanitize migration, correct wrong
asset paths in load_card_images (classic/ subdirectory was missing), and
update tests that hardcoded the old TABLEAU_FAN_FRAC=0.25 constant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:24:25 -07:00
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 c35c045f08 fix(core): enable take-from-foundation by default (closes #3)
Build and Deploy / build-and-push (push) Successful in 4m55s
Standard Klondike allows returning the top card of a foundation pile to a
compatible tableau column. The flag was previously off by default, making the
move impossible for all players.

Changes:
- GameState::new_with_mode: take_from_foundation now initialises to true
- Settings: default changed to true; custom serde default function ensures
  older settings.json files without the key also resolve to true
- Tests: rename "blocked_by_default" → "allowed_by_default" (asserts the
  move now succeeds); add "blocked_when_disabled" to cover the flag=false path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:57:25 -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