Compare commits

..

139 Commits

Author SHA1 Message Date
funman300 7cda2a9f1a fix(engine): resolve all clippy warnings introduced by PNG asset pipeline
CI / Test & Lint (push) Failing after 1m34s
CI / Release Build (push) Has been skipped
- Collapse nested-if patterns into let-chains across 13 plugins (42 instances)
- Add #[allow(clippy::too_many_arguments)] to 5 Bevy systems in card_plugin
  and input_plugin where ECS parameter count exceeds the lint threshold
- Gate Theme import in table_plugin under #[cfg(test)] — only used by
  test-only colour helpers; removing the unconditional import silences the
  unused-import lint without breaking the test suite
- Wrap ButtonInput<MouseButton> in Option<> in update_input_platform so that
  tests using MinimalPlugins (no InputPlugin) no longer panic on startup

All 789 tests pass; cargo clippy --workspace -- -D warnings is clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 03:35:41 +00:00
funman300 2b04718f33 feat(assetgen): upgrade card backs and backgrounds to 120×168 with richer patterns
Replace 16×16 placeholder PNGs with 120×168 canvas-drawn art matching card
face dimensions. Each card back has a distinctive coloured pattern (blue diamond
grid, red crosshatch, green circle array, purple concentric diamonds, teal
horizontal stripes). Each background has textured detail (green felt weave, wood
plank grain, navy star field, burgundy diagonal tile, charcoal checkerboard).
Removes the now-unused save_small_png/make_small helpers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:27:31 +00:00
funman300 505f0ebda3 fix(docker): remove unneeded openssl deps, verify sqlx offline cache path
All crypto uses pure-Rust backends: jsonwebtoken with rust_crypto feature,
sqlx with runtime-tokio-rustls, reqwest with rustls. Neither libssl-dev
(builder) nor libssl3 (runtime) are required. pkg-config is also removed
as no build.rs in the dep tree invokes it. EXPOSE updated to reflect the
SERVER_PORT env var with an 8080 fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:25:45 +00:00
funman300 0f40e717e1 docs(arch): update CardImageSet and asset pipeline for 52-face PNG system
Replace the stale single-face placeholder description with the live 52-PNG
system: CardImageSet.faces is now [[Handle<Image>; 13]; 4] indexed by
[suit][rank], face images are generated by solitaire_assetgen using ab_glyph
with rank/suit baked in, and Text2d overlays are fallback-only. Remove the
now-completed "Future art pass / texture atlas upgrade" note from Section 14.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:25:30 +00:00
funman300 08202f9351 docs(engine): update card_plugin module comment for PNG-based rendering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:24:45 +00:00
funman300 e22fcadb22 feat(engine,assetgen): generate 52 individual card face PNGs
Replace the single shared face.png placeholder with 52 individual card
face images (120×168 px each), generated by the updated gen_art tool:

- solitaire_assetgen: add ab_glyph dep; rewrite gen_art to render each
  card with FiraMono rank characters, programmatic suit symbols (heart,
  spade, diamond, club drawn via circles/triangles), and standard pip
  layout for numbered cards (A–10) plus large face letter for J/Q/K.
- CardImageSet: replace single `face` handle with `faces: [[Handle; 13]; 4]`
  indexed by [suit][rank].
- card_sprite(): select the per-card face image by suit/rank indices.
- spawn/update_card_entity: suppress Text2d overlay when PNG faces are
  loaded (rank/suit baked into image); keep overlay in solid-colour
  fallback for tests.
- gen_sfx.rs: rename `gen` variable to `make` (reserved keyword in 2024).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:20:31 +00:00
funman300 11d53245cf ci: add libwayland-dev to both CI jobs
wayland-sys (pulled in by Bevy via winit) requires libwayland-dev to
satisfy pkg-config at compile time. Missing it causes a build failure
for both the clippy step (which compiles all crates) and the release
build job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:00:31 +00:00
funman300 f27a002c91 fix(server,core): use SmartIpKeyExtractor for rate limiter, collapse nested if
- tower_governor: switch from PeerIpKeyExtractor (socket address) to
  SmartIpKeyExtractor so x-forwarded-for headers are honoured in tests
  and behind reverse proxies. Fixes auth_rate_limit_returns_429 test
  returning 500 instead of 429.
- solitaire_core: collapse nested if/if-let per clippy::collapsible_if.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:54:53 +00:00
funman300 ce8ba6a8c4 chore(workspace): pin rust-toolchain to stable, set MSRV 1.95
Add rust-toolchain.toml so all developers and CI use the same Rust
channel (latest stable = 1.95.0 as of 2026-04-14). Set rust-version
= "1.95" in workspace Cargo.toml to declare the minimum supported
Rust version explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:47:17 +00:00
funman300 66695683eb chore(workspace): upgrade rand 0.9, edition 2024, expand server tests
- rand "0.8" → "0.9": StdRng/SliceRandom API unchanged; 142 core tests pass
- edition "2021" → "2024" workspace-wide: no gen keyword conflicts found;
  204 tests (core + sync) pass clean with zero warnings
- ARCHITECTURE.md: Edition 2021 → Edition 2024 in header
- solitaire_server tests: add 5 new integration tests covering
  refresh-with-garbage-token, expired-refresh-token, push-without-token,
  delete-account-without-token, and leaderboard-authenticated-but-empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:36:12 +00:00
funman300 18ac5adef5 feat(engine): art pass — PNG assets, custom font, and keyring v4 upgrade
Art pass (Phase 4):
- Generate placeholder PNG assets: face.png, back_0–4.png, bg_0–4.png via
  solitaire_assetgen gen_art binary (16×16 RGBA, embedded via include_bytes!)
- Add FiraMono-Medium font (assets/fonts/main.ttf) embedded at compile time
- Add FontPlugin: loads font at startup, exposes FontResource; gracefully
  falls back to default handle when Assets<Font> absent (MinimalPlugins tests)
- Wire CardImageSet into card_plugin: face/back PNGs replace solid-colour
  sprites when available; tests continue using colour fallback via MinimalPlugins
- Wire BackgroundImageSet into table_plugin: bg PNGs replace solid-colour
  background; empty set inserted when Assets<Image> absent in tests
- Fix hint highlight system (input_plugin): tint sprite.color directly instead
  of replacing the whole Sprite (which would discard the image handle)
- Export FontPlugin, FontResource, CardImageSet from solitaire_engine::lib
- Register FontPlugin in solitaire_app before other plugins

Dependency upgrades (latest releases):
- keyring "2" → keyring "4" + keyring-core "1" (v4 split architecture into
  separate core library crate)
- auth_tokens.rs: Entry::new now returns Result; delete_password →
  delete_credential; NoDefaultStore error variant handled
- solitaire_app: add keyring::use_native_store(true) at startup for Linux
  Secret Service / macOS Keychain / Windows Credential Store selection

ARCHITECTURE.md: fix Edition 2025→2021, update asset pipeline section,
add FontPlugin/CardImageSet/BackgroundImageSet to plugin and resource tables,
update Section 14 to reflect actual include_bytes!() rendering approach,
add Decision Log entries for embedded PNG and font decisions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:30:55 +00:00
funman300 41d75b50de feat/fix/perf(engine,data,assetgen): ambient audio, sync bug fixes, hot-path cleanup
**ambient_loop.wav (task 5)**
- solitaire_assetgen: add ambient_loop() synthesizer — 5 s seamless loop,
  55 Hz drone with 2nd/3rd harmonics, 0.2 Hz LFO breath, 16-bit mono 44100 Hz
- audio_plugin: load ambient_loop.wav via include_bytes!() replacing the
  card_flip.wav placeholder; decouple start_ambient_loop() from SoundLibrary

**sync bug fixes (task 11)**
- sync_plugin: LocalOnlyProvider returning UnsupportedPlatform now sets
  SyncStatus::Idle instead of displaying a misleading "Sync not configured" error
- sync_client: extract_pull_body / extract_push_body now return SyncError::Auth
  only for HTTP 401/403; all other non-2xx statuses return SyncError::Network
- sync_plugin: push_on_exit now logs a warn! on failure instead of silently
  discarding the result

**hot-path performance (task 12)**
- card_plugin: card_positions() now returns &Card references (lifetime-bound to
  GameState) instead of owned Card clones — eliminates 52 Card clones per
  sync_cards() call (runs every animation frame)
- input_plugin: card_position() takes &PileType instead of PileType, eliminating
  PileType copies at every drag hit-test call site
- animation_plugin: eliminate intermediate AnimSpeed clone in handle_win_cascade()

**docs (tasks 11, 13)**
- docs/sync_test_runbook.md: manual test runbook for cross-machine sync
- docs/android_investigation.md: cargo-mobile2 port investigation and effort estimate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:51:58 +00:00
funman300 4997356cb5 docs(project): add README, CI workflow, migration guide, and fix asset docs
- README.md: player-facing install, controls, features, and test instructions
- .github/workflows/ci.yml: clippy + headless tests + release build on push/PR
- solitaire_server/migrations/README.md: naming convention and workflow for
  adding future schema migrations
- ARCHITECTURE.md §14: rewrite Asset Pipeline to reflect procedural rendering
  (no image files used; audio only, embedded via include_bytes!)
- ARCHITECTURE.md §2 / §13: fix workspace structure and audio file listing
- CLAUDE.md: clarify asset embedding rule (audio only; visuals are procedural)
- server_tests.rs: add auth_rate_limit_returns_429_on_11th_request test using
  build_router() (rate limiting ON) to verify the GovernorLayer is wired correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:41:16 +00:00
funman300 4bd562671e chore(data,engine,docs): remove Google Play Games Services sync backend
Deletes the solitaire_gpgs crate and all GPGS references from settings,
sync client, profile plugin, CLAUDE.md, and ARCHITECTURE.md. The
self-hosted server covers all sync needs without the Android-only backend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:22:25 +00:00
funman300 8221ebc803 fix(engine): replace EventReader with MessageReader for TouchInput events
EventReader was removed in Bevy 0.18 in favour of MessageReader.
Three touch drag/tap handlers used the old type, causing compile errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:00:53 +00:00
funman300 4d6f8bccb7 chore(pkg): simplify PKGBUILDs for local private builds
Remove GitHub source tarball and b2sums. Both PKGBUILDs now build
directly from the local workspace via _srcdir="$startdir/../..".
Run makepkg from pkg/solitaire-quest/ or pkg/solitaire-quest-server/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:45:52 +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
funman300 735d8766a2 docs(engine): add missing doc comments on layout, ProgressPlugin; fix audio format in ARCHITECTURE.md
- Add field-level doc comments to Layout::card_size and Layout::pile_positions
- Add struct-level doc comment to ProgressPlugin
- Fix ARCHITECTURE.md Section 14: .ogg → .wav throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:37:07 +00:00
funman300 ccfeb055e5 fix(server): load JWT_SECRET at startup, add auth logging, fix challenge race
- Introduce AppState { pool, jwt_secret } so JWT_SECRET is loaded once in
  main() and any missing value is a fatal startup error rather than a 500
  on the first request.  All four env::var("JWT_SECRET") call sites in
  auth.rs and middleware.rs are replaced with state.jwt_secret.
- build_test_router embeds the fixed test secret so integration tests do
  not need to set JWT_SECRET in the environment.
- Add tracing::warn! in login (invalid password) and register (username
  taken) to surface brute-force attempts in production logs.
- Fix daily-challenge race condition: after INSERT OR IGNORE, re-SELECT
  the persisted row so concurrent requests both return the winner's data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:35:46 +00:00
funman300 8f957d919f test(core,sync,server): add EmptySource, ConflictReport, and roundtrip coverage
- core/game_state.rs: move_from_empty_pile_returns_empty_source covers the
  EmptySource error path in move_cards() that had no test
- sync/merge.rs: four new tests verifying ConflictReport field/value content
  for win_streak_current and daily_challenge_streak divergence, plus negative
  cases asserting no report is generated when values are equal
- server/tests: register_login_push_pull_full_roundtrip drives the full
  register → login → push → pull sequence through the test router, confirming
  that a login-derived JWT can push stats and retrieve them unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:34:57 +00:00
funman300 2407686e13 fix(engine,gpgs,core,server): export CardFaceRevealedEvent, explicit gpgs stub, enum/constant docs
- engine/lib.rs: re-export CardFaceRevealedEvent so external crates can consume flip-midpoint audio events
- gpgs/stub.rs: add explicit impls for all six defaulted SyncProvider methods; future trait changes now cause a compile error in the stub rather than silently picking up wrong defaults
- core/game_state.rs: add /// doc comments to DrawMode and GameMode variants
- server/auth.rs: replace terse BCRYPT_COST comment with full /// doc comment matching ARCHITECTURE.md §19
- server/leaderboard.rs: add /// doc comment to DISPLAY_NAME_MAX; fix misplaced comment that was prepended to the opt_in handler instead of the constant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:30:22 +00:00
funman300 1ec2593137 fix(engine): resolve input coordination bugs in selection/pause/keyboard
- SelectionPlugin: add clear_selection_on_state_change system so undo/move/reject
  never leave a stale selection pointing at the wrong card
- SelectionPlugin: expose SelectionKeySet system set for cross-plugin ordering
- PausePlugin: skip Escape→pause when a card is keyboard-selected; toggle_pause
  now runs before SelectionKeySet so it reads SelectionState before it is cleared
- InputPlugin: guard Space→DrawRequestEvent when SelectionState has an active pile
  so Space executes a card move instead of also drawing from stock
- window: enforce 800×600 minimum via WindowResizeConstraints
- game_state: add precondition doc to next_auto_complete_move (waste exclusion)
- card_plugin: 12 unit tests for constants, face_colour, label_visibility, label_for
- pause_plugin: add paused_resource_default and draw_mode_label exhaustiveness tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:13:10 +00:00
funman300 ffc79447d4 fix+refactor+docs: P0–P3 todo list items
P0 fixes:
- Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs
  (all three were exported but never wired — features silently did nothing)
- game_state::draw(): increment move_count on waste→stock recycle, not just
  on normal draws; add move_count_increments_on_recycle regression test

P1 fixes:
- solitaire_server/Cargo.toml: remove duplicate dev-dependencies
  (solitaire_sync, uuid, chrono, jsonwebtoken were in both sections)

P2 — input_plugin refactor:
- Split 198-line handle_keyboard() into three focused systems under 110 lines each:
  handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G)
- Introduce KeyboardConfirmState resource to share countdown timers across systems
- Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck,
  new_game_confirm_window_is_positive

P2 — achievement predicate tests (solitaire_core):
- Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer,
  on_a_roll, comeback predicates (previously only covered via check_achievements())
- 141 core tests now passing

P2 — server tests:
- solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required)
- solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order

P3 — documentation:
- Add struct-level ///  to 12 Plugin structs (ChallengePlugin, CursorPlugin,
  AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin,
  HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin)
- Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef
- Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win

card_animation module (new files from previous session):
- chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs
- Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants
- Add handle_touch_stock_tap so touch users can draw from the stock pile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:02:52 +00:00
funman300 71c0c273a1 chore(deps): migrate kira 0.9 → 0.12
- Import paths simplified: manager/tween modules re-exported from kira root
  (AudioManager, AudioManagerSettings, DefaultBackend, Tween all via kira::*)
- Volume::Amplitude removed; replaced with Value<Decibels> using a new
  amplitude_to_decibels() helper (20*log10 conversion, clamps to SILENCE)
- output_destination field removed from StaticSoundSettings; sounds routed
  to sub-tracks by calling TrackHandle::play() directly instead of
  AudioManager::play()
- set_volume() now accepts f32 (Decibels) not f64
- start_ambient_loop signature updated to take &mut Option<TrackHandle>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:54:01 -07:00
funman300 21d0c289b5 chore(deps): migrate to Bevy 0.18
- BorderRadius is no longer a Component; moved into Node.border_radius
  field at all 15 spawn sites across 6 plugin files
- Events<T> renamed to Messages<T> in test code (12 files)
- KeyboardEvents SystemParam renamed to KeyboardMessages to match the
  MessageWriter rename done in the 0.17 hop
- WindowResolution::from((f32,f32)) removed; use (u32,u32) tuple in main.rs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:48:41 -07:00
funman300 648cd44387 chore(deps): migrate to Bevy 0.17
- Event/EventReader/EventWriter renamed to Message/MessageReader/MessageWriter
- add_event → add_message for all 67 call sites
- ScrollPosition changed to tuple struct ScrollPosition(Vec2)
- CursorIcon import moved from bevy::winit::cursor to bevy::window
- WindowResolution::from((f32,f32)) removed — use (u32,u32) tuple
- World::send_event → World::write_message in test code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:04:44 -07:00
funman300 c8553dc8c5 chore(deps): migrate to Bevy 0.16, axum 0.8, and other package updates
- Bump bevy 0.15 → 0.16; fixes all breaking API changes:
  ChildBuilder → ChildSpawnerCommands, Parent → ChildOf,
  despawn_descendants → despawn_related::<Children>(),
  despawn_recursive → despawn (now recursive by default),
  EventWriter::send → write, Query::{get_single,get_single_mut}
  → {single,single_mut}, ChildOf::get → parent()
- Bump axum 0.7 → 0.8; remove axum::async_trait from FromRequestParts
- Bump tower_governor 0.4 → 0.8; fix GovernorLayer::new() API
- Bump jsonwebtoken 9 → 10 with rust_crypto feature only
- Bump thiserror 1 → 2, dirs 5 → 6, bcrypt 0.15 → 0.19,
  reqwest 0.12 → 0.13 (rustls feature rename)
- Regenerate .sqlx offline cache for sqlx compile-time query checks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:31:12 -07:00
funman300 eedddb979e feat(engine): add curve-based card animation module
Introduces solitaire_engine::card_animation — a drop-in upgrade over the
existing linear CardAnim. Supports MotionCurve easing, parabolic z-lift,
scale interpolation, delay, retargeting mid-flight, and per-card timing
variation. Coexists with the legacy AnimationPlugin during migration.

Also adds .claude/ to .gitignore so Claude Code local tooling is never
committed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 18:06:58 +00:00
funman300 59a023ed5e chore(workspace): fix all clippy warnings in test code
Resolves 15 violations found by `cargo clippy --workspace --tests -D warnings`:
- Remove unused imports (Card, Rank) in cursor_plugin tests
- Replace absurd i32::MAX comparison with a meaningful >= 0 check
- Use range .contains() instead of manual >= && <= (manual_range_contains)
- Move impl FromRequestParts before test module in middleware.rs (items_after_test_module)
- Move _VEC3_REFERENCED const before test module in input_plugin.rs
- Convert runtime assert on constant to const { assert!(...) }
- Use .contains() instead of .iter().any() for slice membership
- Replace .get(...).is_none() with !.contains_key(...) in HashMap checks
- Collapse Default::default() + field assignment into struct literal initializers
  across solitaire_sync, solitaire_data, and solitaire_engine test helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 18:02:27 +00:00
funman300 8cd28cfb29 feat(engine): right-click highlight timer and visual hint glow (#5, #6)
Task #5: Add RightClickHighlightTimer(1.5 s) so destination highlights
auto-despawn after 1.5 s. Existing clear-on-state-change and
clear-on-pause logic still fires early when a move is made or the game
is paused. Three unit tests cover timer countdown behaviour.

Task #6: Add HintVisualEvent emitted on H key. Source card gets
HintHighlight + HintHighlightTimer(2 s) for a yellow glow. Destination
PileMarker gets HintPileHighlight with a gold tint (Color::srgb(1.0,
0.85, 0.1)) that restores the original colour when the 2 s timer
expires. Five unit tests cover timer expiry and colour invariants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 17:36:23 +00:00
funman300 03227f8c77 feat(engine): playability improvements — rounds 7–9 (#40–#64)
Round 7 — Input & feedback
- H key cycles hints; F1 opens help (conflict resolved)
- N key cancels active Time Attack session
- Hint text distinguishes "draw from stock" vs "recycle waste"
- Forfeit (G) clears AutoCompleteState so chime does not bleed into new deal
- Zen mode timer clears immediately on Z press
- HUD shows recycle count in both draw modes
- Settings scroll position persisted across open/close

Round 8 — Polish & clarity
- Undo unavailable fires "Nothing to undo" toast
- Streak-break toast on forfeit/abandon when streak > 1
- F11 fullscreen toggle with toast; documented in help and home screens
- H-after-win, new-game countdown expiry, Tab-no-cards toasts
- Win cascade duration/stagger scales with animation speed setting
- Draw-Three cycle counter HUD ("Cycle: N/3")
- Forfeit requires G×2 confirmation within 3 s (mirrors N key)

Round 9 — Game feel & information
- Escape dismisses game-over/stuck overlay (PausePlugin skips Escape when overlay visible)
- Shake animation on rejected drag before snap-back
- Forfeit countdown cancels when any other key is pressed (U/H/D/Z/Space)
- Tab wrap-around fires "Back to first card" toast
- HUD selection indicator shows active Tab-selected pile in yellow
- Challenge time-limit HUD turns orange < 60s, red < 30s
- Win summary shows XP breakdown (+50 base, +25 no-undo, +N speed)
- Game-over overlay: "No more moves available" with clear N/Escape/G instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 02:35:15 +00:00
funman300 d387ee68d7 feat(engine): stats improvements, toast queue, keyboard selection (#65, #66, #67, #68)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 20:07:49 +00:00
funman300 1c6094dc93 feat(engine): auto-complete badge, confirm dialog, game-over overlay (#56, #57, #58)
Task #56 — HudAutoComplete badge: shows "AUTO" in green when AutoCompleteState.active
is true; announce_auto_complete system fires InfoToastEvent on leading edge.
Task #57 — ConfirmNewGameScreen: intercepts NewGameRequestEvent when move_count > 0 and
is_won=false, shows modal with Y/N keyboard handling.
Task #58 — GameOverScreen: spawns when check_no_moves detects no legal moves; handle_game_over_input
responds to N (new game) and U (undo + despawn). Fix animation system ordering
(chain enqueue_toasts → drive_toast_display) to eliminate flaky test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 20:07:47 +00:00
funman300 f32e53dd0b feat(engine): shake/settle/deal animations (#54, #55, #69)
Add FeedbackAnimPlugin with three card feedback animations:
- #54 ShakeAnim: horizontal shake on MoveRejectedEvent targeting
  destination pile cards; 0.3 s damped sine wave
- #55 SettleAnim: Y-scale bounce on valid placement (StateChangedEvent);
  1.0 → 0.92 → 1.0 over 0.15 s for all top-of-pile cards
- #69 Deal animation: slides each card from stock position to its deal
  position on NewGameRequestEvent (move_count == 0), using existing
  CardAnim with 0.04 s per-card stagger

Pure-function helpers shake_offset, settle_scale, and deal_stagger_delay
are public and covered by 6 unit tests. Fix pre-existing compile/clippy
errors: stubbed handle_confirm_input/handle_game_over_input, removed dead
CycleCardBack/CycleBackground variants, annotated ambient_handle field,
and fixed draw_mode.clone() in pause_plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:55:24 +00:00
funman300 ddd7502a06 feat(engine): playability improvements — input intelligence, audio, HUD, onboarding (#27–#30, #37, #39–#40, #44, #48–#49)
Task #27: Double-click auto-move — best_destination() finds optimal target
(foundation over tableau); handle_double_click() fires MoveRequestEvent.

Task #28: Hint system — find_hint() returns first legal from/to/count triple;
H key tints the source stack HintHighlight (yellow pulse via tick_hint_highlight).

Task #29: No-moves detection — has_legal_moves() checks stock/waste/all face-up
cards; check_no_moves system fires InfoToastEvent("No moves available") once per
stalemate (debounced so it fires only once until the state changes).

Task #30: Forfeit — G key fires ForfeitEvent; StatsPlugin records abandoned game,
persists stats, starts a new deal.

Task #37: Mute-all (M) and mute-music (Shift+M) toggles; MuteState resource
applied in apply_volume_on_change.

Task #39: Daily challenge HUD constraint label (time limit / target score).

Task #40: Undo-count HUD label; amber colour when undos > 0.

Task #44: Win-streak and level line on pause screen.

Task #48: Undo sound routes UndoRequestEvent → lib.flip audio channel.

Task #49: Onboarding banner rich-text key highlights — D and H rendered as
orange KeyHighlightSpan children so they stand out from body text.

Also registers CursorPlugin in solitaire_app (tasks #31/#32 wire-up).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:11:47 +00:00
funman300 c3ee7c45a7 feat(engine): card visual improvements — flip animation, foundation/tableau placeholders, drag shadow
Task #34: CardFlipAnim component + start_flip_anim/tick_flip_anim systems animate revealed
cards by squashing scale.x to 0 then expanding back to 1 (2×0.08 s). Skipped at Instant speed.

Task #35: spawn_pile_markers now adds a Text2d child (S/H/D/C, 45% alpha) on Foundation
markers so the suit is visible while the pile is empty.

Task #43: Tableau pile markers get a "K" Text2d child (35% alpha) indicating only Kings land
on empty columns.

Task #38: update_drag_shadow system maintains a single ShadowEntity while dragging — a
card_w+8 × card_h+8 dark semi-transparent sprite at z−1 behind the top dragged card.

Also fixed pre-existing clippy/compiler errors in hud_plugin, pause_plugin, stats_plugin,
cursor_plugin, and settings_plugin (missing imports, too-many-arguments, doc formatting).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:03:59 +00:00
funman300 4d132afdc2 test(engine): add unit tests for format_reward variants in achievement_plugin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 05:22:22 +00:00
funman300 eee220fbf0 test(engine): add unit tests for format_duration in stats_plugin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 05:19:28 +00:00
funman300 fe23e89971 test(engine): add advance_elapsed saturation and theme colour pure-function tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 05:17:15 +00:00
funman300 34f60e048a test(sync): add unit tests for StatsSnapshot::record_abandoned
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 05:13:02 +00:00
funman300 87fe51a0d0 test(sync): add unit tests for PlayerProgress methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 05:12:36 +00:00
funman300 0318480ba7 test(server): add unit tests for JWT middleware pure functions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 05:10:32 +00:00
funman300 adacc40592 test(server): add unit tests for username_chars_ok validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 05:08:18 +00:00
funman300 0e7a34d6bf test(server): verify merge-on-push keeps higher stats across two pushes
Pushes games_played=20, then pushes games_played=5 (lower). Pulls and
asserts games_played is still 20 — confirming the server merges (takes
the max) rather than overwriting with the lower value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:54:47 +00:00
funman300 3014b65c92 test(core): add scoring boundary tests for non-waste destinations
Three new tests: non-waste→tableau scores zero (tableau restack and
impossible foundation→tableau), move→stock/waste scores zero (guard
against non-obvious destinations panicking), and time_bonus capped at
i32::MAX via the .min() guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:54:11 +00:00
funman300 721c17e9f8 test(core): add undo_count boundary tests
Three tests: undo_count starts at zero, increments on each undo call,
and saturates at u32::MAX without panicking. The undo_count field is
read by ProgressPlugin to determine the no-undo XP bonus but had no
coverage in game_state tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:52:36 +00:00
funman300 60e853f52b test(engine): add InfoToastEvent test for locked challenge X-key press
Verifies that pressing X when the player's level is below
CHALLENGE_UNLOCK_LEVEL emits an InfoToastEvent containing the unlock
level, rather than silently doing nothing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:48:55 +00:00
funman300 be4cefe79a test(engine): add XpAwardedEvent and LevelUpEvent total_xp coverage
Two tests in progress_plugin: verify XpAwardedEvent fires with the
correct amount on a slow no-undo win (75 XP), and verify LevelUpEvent's
total_xp field matches the ProgressResource after the win.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:46:11 +00:00
root 74fa6c7cff fix(engine): Draw-Three waste fan hit-testing; add HUD and input coverage
fix(input_plugin): card_position() now applies the same X-fan offset for
Draw-Three waste cards as card_plugin uses for rendering. Previously the
top waste card appeared at base_x + 0.56 * card_width but was only
hittable at base_x, making it impossible to drag from its visual position.

test(hud_plugin): add five behaviour tests — score/moves/time display
format, Zen mode score suppression, Draw-Three mode badge.

test(input_plugin): add find_draggable test that clicks the top fanned
waste card at its visual X position and confirms it hits in Draw-Three.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:42:25 +00:00
root c06458cf80 test(engine): add missing coverage for settings and animation plugins
settings_plugin: tests for cycle_unlocked (wrap, advance, single-element,
unknown-current, empty), volume floor clamping, and O-key screen toggle.

animation_plugin: tests for anim_speed_to_secs mapping (Fast < Normal,
Instant = 0), toast auto-dismiss on expired timer, toast survival when
timer positive, InfoToastEvent spawning a ToastOverlay, and
SettingsChangedEvent updating EffectiveSlideDuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:37:32 +00:00
root de01566e47 fix(engine): eliminate waste-pile bleed-through on card draw
Only the top N waste cards are now passed through card_positions():
Draw-One renders 1 card, Draw-Three renders up to 3 fanned in X
(standard Klondike layout). Non-top waste card entities are despawned,
so nothing is visible at the waste position while the newly drawn card
slides in from the stock — the bleed-through bug is gone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:31:26 +00:00
root 2a01ecdbfd test(core): add missing check_auto_complete coverage
Three test cases were missing:
- waste_not_empty guard: stock clear, waste has a card, all tableau
  face-up — must return false even with all tableau conditions satisfied
- true case: positive path confirming all-face-up + empty stock/waste
  returns true (previously untested entirely)
- The previous face_down_cards test covered only that guard, not the
  waste guard or the positive path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:08:51 +00:00
root bf150f11f1 test(engine): add boundary tests for format_secs and pause toggle cycle
leaderboard_plugin: format_secs was missing the 60-second crossing
boundary (1:00 vs 60s), the zero case (0s), and leading-zero padding
verification (1:05 not 1:5).

pause_plugin: added third-press test confirming the Esc toggle is
symmetric across multiple pause/resume cycles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:06:43 +00:00
root 3d4d834c58 test(engine): add paused-timer test for TimeAttackPlugin
The advance_time_attack system has an early-return path when
PausedResource is true, but this branch had no test coverage.
New test: with remaining_secs = -1 (normally triggers expiry),
inserting PausedResource(true) must suppress the ended event
and leave remaining_secs negative (timer did not advance).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:03:07 +00:00
root d605fd5536 test(core): add missing boundary tests for can_place_on_foundation/tableau
- King-on-Queen foundation completion (the end-game move had no test)
- King wrong-suit rejected on completed foundation
- Ace-on-Two valid placement (Ace.value()+1 == Two.value(), different color)
- Same-rank different-color invalid (rank difference is 0, not 1)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:00:59 +00:00
root 96ac44fbef test(server): add unit tests for hash_date_to_u64 and generate_goal
The daily-challenge seed function had no unit test coverage. Added tests
for determinism, cross-day and cross-year distinctness, non-zero output,
and all six generate_goal variants (score/time field correctness).
This acts as a change-detection guard for the authoritative seed algorithm
that all players worldwide rely on to receive the same daily deal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:00:08 +00:00
root 2dd5b1fc9c test(sync): add missing merge coverage for stats fields
Seven stats fields (games_lost, win_streak_best, lifetime_score,
draw_one_wins, draw_three_wins, avg_time_seconds on both branches)
had no isolated test coverage in the merge test suite. Added boundary
tests for each, including a concrete arithmetic verification of the
weighted-average recomputation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:55:58 +00:00
root d0b650e08b test(sync): add unit tests for StatsSnapshot::win_rate and AchievementRecord::unlock
Both public APIs in solitaire_sync had no test coverage:
- win_rate(): None before any game, 100/50/0% cases
- AchievementRecord::locked(), unlock(), idempotency preserving earliest date

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:54:49 +00:00
root 9e9ce2b752 fix(server): use char count (not byte length) for display_name limit
The leaderboard opt-in handler was calling `.len()` on the display name,
which returns byte count. Multi-byte Unicode characters (emoji, CJK, etc.)
would be rejected well before the 32-character visual limit and with a
misleading error message. Switched to `.chars().count()` to enforce the
limit in terms of Unicode scalar values as the error message advertises.

test(core): add boundary tests for 7 uncovered achievement conditions
test(server): add display_name validation integration tests (empty,
too-long ASCII, 32-emoji succeeds, 33-emoji rejected)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:50:41 +00:00
root fe986ef4a1 fix(engine): fire XpAwardedEvent for achievement BonusXp rewards
The Reward::BonusXp path in evaluate_on_win was adding XP directly
without sending XpAwardedEvent, so players saw no "+25 XP" toast when
the no_undo achievement first unlocked. Adds the missing event send
and a regression test verifying the event fires on unlock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:44:29 +00:00
root fd5d488361 test(core): add missing move_cards edge case tests
Cover four previously untested rule branches:
- Moving a face-down card in a multi-card lift → RuleViolation
- Moving two cards to foundation → RuleViolation
- Requesting more cards than the pile holds → RuleViolation
- Valid 3-card sequence tableau→tableau → succeeds and updates move_count

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:39:59 +00:00
root e624dd26b0 fix(sync): merge weekly goal progress per-goal when same ISO week
Previously, same-week progress from the remote device was discarded
entirely — local counts were always preferred. Now each goal's count
is merged with max() so progress earned on any device is preserved.
Adds two regression tests covering same-week and newer-week cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:34:11 +00:00
root cdb1145061 fix(core): differentiate night_owl and early_bird achievement conditions
Both predicates previously matched the same window (h < 6), making them
indistinguishable. night_owl now triggers 22:00–02:59 (late night) and
early_bird triggers 05:00–06:59 (pre-dawn). Updated descriptions and
tests to match the distinct windows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:29:36 +00:00
root e174ed93a4 fix(server): trim username whitespace on login like register does
register() strips leading/trailing whitespace from the username before
storing it; login() was not, so a user who typed " alice " at login
would get a 401 even though their account existed as "alice". Now both
handlers trim consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:26:12 +00:00
root 3eb7901023 feat(engine): fire XpAwardedEvent for daily and weekly bonus XP
Daily challenge completions (+100 XP) and weekly goal bonuses (+75 XP)
now fire XpAwardedEvent so the player sees a "+N XP" toast — consistent
with the post-win XP toast already shown by ProgressPlugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:23:55 +00:00
root 91b675f2f1 fix(engine): settings toast shows correct volume type (SFX vs Music)
Previously every SettingsChangedEvent fired an "SFX: X%" toast, even
when the music volume was adjusted in the Settings panel. Now the toast
handler tracks the previous SFX and music levels independently and only
shows a toast for whichever value actually changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:21:37 +00:00
root 0b0e0180c0 feat(engine): tighter tableau fan for face-down cards
Face-down cards in tableau columns now use a TABLEAU_FACEDOWN_FAN_FRAC
of 0.12 instead of the full 0.25, so the visible face-up portion of
each column is easier to read at a glance — matching standard Klondike
rendering where only the playable cards are prominently spread.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:18:31 +00:00
root bc021acfd0 feat(data,engine): add WinUnder-5min and WinDrawThree weekly goal types
Adds two new weekly goals — "Win 1 game in under 5 minutes" and
"Win 1 Draw-3 game" — broadening variety beyond the existing three.
WeeklyGoalContext gains a draw_mode field so the new WinDrawThree
variant can match on draw mode. Existing tests updated to pre-complete
new goals where the win conditions overlap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:13:33 +00:00
root cacacb00dc feat(server): expand daily challenge to 6 goal variants
Adds three new challenge types (win in 3 min, score ≥5000, win in 8 min)
so the daily challenge rotates through a fortnight of variety before any
variant repeats. Seeds are still deterministic worldwide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:05:18 +00:00
root 0a76c089d0 feat(engine): hide score and timer in Zen mode per spec
Zen mode is intended for relaxed play with no performance pressure.
The HUD now clears score and elapsed-time displays when GameMode::Zen
is active, matching the ARCHITECTURE.md spec ("No timer. No score
display."). The mode badge still shows "ZEN" so the player knows
which mode they're in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:04:18 +00:00
root de840fb006 feat(engine,server): XP toast on win + display_name max-length validation
ProgressPlugin now fires XpAwardedEvent on every win. AnimationPlugin
shows a "+N XP" toast so players see XP feedback immediately after
winning. Server leaderboard opt-in endpoint also now validates that
display_name is at most 32 characters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 03:02:59 +00:00
root e3ac494e85 feat(server): validate username length/chars and minimum password length on register
Username: 3–32 chars, alphanumeric + underscore only.
Password: minimum 8 characters.
Both return HTTP 400 Bad Request with a human-readable message.
Adds three integration tests for the new validation rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:59:27 +00:00
root 11cb53ab29 feat(engine): InfoToastEvent — show locked-mode messages on-screen
Replaces silent info!() log calls with on-screen toasts when the player
presses Z/X/T without reaching the required unlock level. Any system
can now fire InfoToastEvent(message) to surface a brief text overlay
without depending on a specific plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:57:34 +00:00
root 4a33cbdc22 feat(engine): require N-key confirmation when abandoning an active game
Pressing N during an active game (move_count > 0, not won) now shows a
"Press N again to start a new game" toast and only starts a new game if
N is pressed a second time within 3 seconds. Starting a fresh game or
pressing N after a win still acts immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:53:28 +00:00
root dfeaed6de2 feat(engine): fire CardFlippedEvent + play flip sound on tableau reveal
When a move exposes a face-down tableau card, game_plugin now fires
CardFlippedEvent carrying the flipped card's id. AudioPlugin listens
and plays card_flip.wav so the reveal has satisfying audio feedback.
Two unit tests verify the event fires only when needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:48:25 +00:00
root ed0aff4714 feat(data): expand challenge seed list from 5 to 25 seeds
Adds 4 more rounds of 5 seeds each, organized by difficulty tier.
Seeds wrap modulo list length so the pool grows non-destructively.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:42:50 +00:00
root 46dd9cdfab feat(engine): achievements screen shows reward and unlock date per entry
Each achievement row now displays:
- "Reward: Card Back #N / Background #N / +N XP / Badge" (green, all entries)
- "Unlocked YYYY-MM-DD" (dim, unlocked entries only)

Helps players understand what they earn and when they earned it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:38:16 +00:00
root 14ef19a396 feat(engine): HUD shows 'Draw 3' badge when playing in Draw Three mode
Classic + DrawOne shows nothing (clean default). Classic + DrawThree shows
a "Draw 3" badge in the mode position so the player always knows their
current game variant without opening the stats or settings screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:33:02 +00:00
root 3d5f34a650 feat(engine): stats overlay shows XP progress to next level
Adds "Next Level: N XP (P%)" line so players can see how far they are
through the current level without doing the arithmetic themselves.
Tested with three unit tests covering level 0, mid-level, and level 10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:14:54 +00:00
root 314186d6f4 feat(data): SyncProvider::delete_account + SolitaireServerClient impl
Adds delete_account() as a default no-op on the SyncProvider trait.
SolitaireServerClient sends DELETE /api/account with JWT (retry on 401).
The server handler already existed (DELETE /api/account, ON DELETE CASCADE).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:10:26 +00:00
root c6a596299e feat(engine): HUD shows Time Attack countdown instead of game elapsed time
During Time Attack sessions the time display updates every frame with the
remaining countdown (from TimeAttackResource) rather than the per-second
game clock tick, giving the player a live countdown. Non-Time-Attack mode
is unchanged — the clock still updates once per second via game state change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:08:30 +00:00
root 07bf1977bd feat(engine): win toast includes score and time
"You Win!" now displays "You Win!  Score: N  Time: M:SS" so the player
sees their result without opening the stats screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:05:08 +00:00
root 3363da2d1a fix(engine): settings panel max_height + overflow clip on small windows
Inner card capped at 88% of window height with Overflow::clip_y so
the panel stays on-screen even when many rows are present. Matches the
same approach used by the leaderboard panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:02:45 +00:00
root 648c5c18d9 feat(leaderboard): opt-out support — server endpoint, client method, UI button
- Server: DELETE /api/leaderboard/opt-in sets leaderboard_opt_in=0,
  hiding the player without deleting their row (scores preserved for re-opt-in)
- SyncProvider trait: opt_out_leaderboard() default no-op method + blanket impl
- SolitaireServerClient: implements opt_out_leaderboard via DELETE request with JWT refresh
- Leaderboard UI: "Opt Out" button (dark red) alongside existing "Opt In" button
- Server integration test: opt-out hides, opt-in restores (round-trip verified)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:01:20 +00:00
root 15b9b5477b feat(engine): show challenge mode progress in stats overlay
Stats screen (S key) now shows "Challenge: N / total" in the Progression
section so players can see how far they've advanced through the challenge
seed list without leaving the screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:57:11 +00:00
root fff8c66bf7 feat(engine): in-game HUD — score, move count, elapsed time, mode badge
Adds HudPlugin with a persistent top-left overlay that shows score,
move count, and elapsed time during every game. A mode badge highlights
DAILY, CHALLENGE, ZEN, or TIME ATTACK when the game is not in Classic
mode. HUD updates whenever GameStateResource changes (moves and per-second
time ticks) without a separate polling system.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:50 +00:00
root 299e0c6a94 feat(engine): cosmetic selectors applied, stats screen expanded, daily goals enforced
- Card backs: selected_card_back index maps to distinct Color values in card rendering
- Backgrounds: selected_background index applied in TablePlugin alongside theme
- Both re-render immediately on SettingsChangedEvent
- Stats screen now shows Games Lost, Draw 1/3 Wins, and Lifetime Score
- Daily challenge win no longer credited if server-supplied target_score or max_time_secs constraints are not met

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:46:52 +00:00
root f579b96d76 feat(engine): wire AnimSpeed to animation, new achievements, leaderboard opt-in, daily goal display
- AnimSpeed setting now drives card slide duration (Normal=0.15s, Fast=0.07s, Instant=snap);
  EffectiveSlideDuration resource updated on SettingsChangedEvent; AnimSpeed row added to Settings panel
- GameState.recycle_count tracks waste recycles; perfectionist/comeback/zen_winner achievements added
  with full unit tests
- SyncProvider gains opt_in_leaderboard(); SolitaireServerClient implements POST /api/leaderboard/opt-in;
  Opt In button added to leaderboard panel
- DailyChallengeResource stores goal_description/target_score/max_time_secs from server;
  pressing C shows goal description as toast (DailyGoalAnnouncementEvent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:38:25 +00:00
root bd48813900 fix(engine): fire LevelUpEvent when weekly bonus XP crosses a level threshold
evaluate_weekly_goals discarded the return value of add_xp(bonus_xp),
so a level-up triggered by a weekly goal completion never fired
LevelUpEvent — the level-up toast and mode-unlock at L5 were silently
skipped. Now captures prev_level, checks leveled_up_from(), and sends
LevelUpEvent matching the pattern used by progress_plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:16:11 +00:00
root 9a38873891 feat(engine): fetch daily-challenge seed from server on startup
- Add fetch_daily_challenge() to SyncProvider trait (default: Ok(None))
- SolitaireServerClient calls GET /api/daily-challenge (public endpoint)
  and returns the ChallengeGoal; non-2xx responses return Ok(None) so
  callers fall back to the local date-hash seed
- DailyChallengePlugin spawns an async task on Startup (only when
  SyncProviderResource is present) and polls it in Update; on success
  it overwrites DailyChallengeResource.seed with the server's seed,
  ensuring all players worldwide get the same deal on a given date

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:09:24 +00:00
root 9a4071c74e refactor(engine): propagate SyncError through pull task instead of String
PullTask and PullTaskResult now carry Result<SyncPayload, SyncError>
instead of Result<SyncPayload, String>. poll_pull_result pattern-matches
on the error variant to show user-friendly messages:
  Network  → "Can't reach server — check your connection"
  Auth     → "Login expired — tap Sync Now after re-logging in"
  Other    → original error Display

Also removed the stale TODO comment from SyncError in lib.rs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:05:59 +00:00
root 45ef3a2058 fix(engine): reset weekly goals at app startup if the ISO week changed
evaluate_weekly_goals only ran the roll on GameWonEvent, so stale goal
progress from a prior week would linger until the player's next win.
A new Startup system calls roll_weekly_goals_if_new_week on launch and
persists immediately when the week has rolled over.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:02:05 +00:00
root 6728a4311f feat(engine): grant achievement rewards + gate cosmetic selectors
- Add Reward enum to solitaire_core with CardBack/Background/BonusXp/Badge variants
- Wire rewards into ALL_ACHIEVEMENTS per architecture spec
- evaluate_on_win now applies rewards on first unlock: pushes cosmetic
  indices into PlayerProgress, awards BonusXp (with level-up detection),
  and marks reward_granted = true so rewards are never double-granted
- Add selected_card_back / selected_background fields to Settings
- Settings panel grows Card Back and Background cycle rows, shown only
  when the player has unlocked more than the default (index 0)
- cycle_unlocked() cycles only through earned options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:00:18 +00:00
root b37fe5b49b fix(engine): add missing A/L/O shortcuts to help screen overlay
The help overlay only listed S and H; added Achievements (A),
Leaderboard (L), and Settings (O) which were already implemented
but undocumented in the cheat sheet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:52:20 +00:00
root d56abcd7a9 feat(engine): leaderboard screen (press L to toggle)
Press L to open a leaderboard overlay. On open, an async fetch is
dispatched against the active SyncProvider. Results cache in
LeaderboardResource; the panel rebuilds live when data arrives.
Closing during a fetch is safe (ClosedThisFrame flag prevents
re-spawning the panel in the same frame as the user's despawn command).
Format: ranked table with player name, best score, and fastest win time.
Non-authenticated / LocalOnly providers return an empty list gracefully.

- solitaire_data: add fetch_leaderboard() to SyncProvider trait
  (default → Ok([])) and implement in SolitaireServerClient
- solitaire_engine: new LeaderboardPlugin with 5 unit tests
- solitaire_app: register LeaderboardPlugin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:38:55 +00:00
root a7b781cd36 feat(engine): achievements screen (press A to toggle)
Adds a full-screen overlay listing all achievements with unlock status.
Unlocked achievements show in gold with a check mark; locked ones are
greyed out. Secret achievements that are still locked are hidden. Header
shows unlocked/total count. Press A again to dismiss.

Two new unit tests: spawns on first A press, dismisses on second.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:27:21 +00:00
root f7850c0075 feat(engine): auto-complete — cards auto-deal to foundations
When is_auto_completable flips true (stock/waste empty, all cards
face-up), AutoCompletePlugin fires MoveRequestEvent every 120 ms,
driving cards to the foundation one at a time without player input.
An "Auto-completing…" toast announces the sequence.

- solitaire_core: add next_auto_complete_move() to GameState with
  3 new unit tests
- solitaire_engine: new AutoCompletePlugin with detect + drive systems
  and 4 unit tests; animation_plugin shows one-shot toast on activation
- solitaire_app: register AutoCompletePlugin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:24:21 +00:00
root 00f0383867 feat(engine): in-progress game state persistence
Save game_state.json on app exit and on pause open so players can
resume interrupted sessions. Delete the file on win, loss, or new-game
start. Restore the saved game on launch if it exists and isn't won.

- solitaire_core: add pile_map_serde module so HashMap<PileType,Pile>
  round-trips through JSON (serialized as Vec of pairs)
- solitaire_data: add game_state_file_path, load_game_state_from,
  save_game_state_to, delete_game_state_at with 8 new unit tests
- solitaire_engine/GamePlugin: restore saved game on startup, expose
  GameStatePath resource, save on AppExit, delete on new-game and win
- solitaire_engine/PausePlugin: save on pause open (guards against
  OS-level kills while the overlay is showing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:17:47 +00:00
root 20db4b312a feat(engine): ManualSyncRequestEvent + Sync Now button in settings
- Added ManualSyncRequestEvent to events.rs (exported from lib.rs).
- SyncPlugin now handles ManualSyncRequestEvent: if no pull is in
  flight, spawns a new AsyncComputeTaskPool task and sets status to
  Syncing. Ignores duplicate requests while a pull is active.
- Settings panel "Sync" section now shows the status text alongside a
  "Sync Now" button that fires ManualSyncRequestEvent.
- Cleaned up stale doc comment in input_plugin.rs (Esc pause note).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:03:24 +00:00
root f7f14efe07 feat(engine): live sync status in Settings panel
The Settings panel now shows a "Sync" section with a live status line:
- "Status: idle" (no SyncPlugin installed)
- "Status: syncing…" (pull in progress)
- "Last synced: Xs ago" (successful pull)
- "Sync error: <msg>" (failed pull)

The text is snapshotted from SyncStatusResource when the panel opens and
updated reactively via update_sync_status_text whenever SyncStatusResource
changes while the panel is visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:59:45 +00:00
root 303c78aa4c feat(server): update leaderboard scores from sync push
When a user pushes sync data and is opted in to the leaderboard, the
server now updates their leaderboard row with the merged stats using
MAX(best_score) and MIN(best_time_secs) — scores never regress even if
the client sends stale data.

Eliminates the need for a separate score-submission API call: the sync
push already carries the full stats, so the leaderboard stays current
after every push.

Added two integration tests:
- push_after_opt_in_updates_leaderboard_score
- push_lower_score_does_not_overwrite_leaderboard_best

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:56:49 +00:00
root 3c01cef5f3 feat(engine): implement Draw Mode, Theme, and Music Volume settings
Settings panel "coming soon" stubs replaced with live controls:

- Draw Mode toggle (Draw 1 / Draw 3): new games read draw_mode from
  SettingsResource instead of the previous game's mode. Falls back to
  the current game's mode in headless/test contexts where SettingsPlugin
  is absent.

- Theme selector (Green → Blue → Dark → Green): SettingsChangedEvent
  drives TablePlugin's background Sprite colour so the table re-colours
  immediately without a restart.

- Music Volume [−]/[+]: dedicated kira sub-tracks created for SFX and
  music on startup. SFX sounds are routed to the SFX track; the music
  track exists for future ambient audio. Both volumes are set on
  SettingsChangedEvent and at startup.

Also fixed: time_attack timer_expiry test double-fires when
MinimalPlugins time delta is nonzero — removed the intermediate
0.001-remaining update step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:53:48 +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
funman300 13b428b81c feat(engine): first-run onboarding banner
OnboardingPlugin spawns a centered welcome banner at PostStartup
when Settings.first_run_complete is false. Any key or mouse
press dismisses it, sets the flag, and persists settings.json
so returning players never see it again.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 23:14:10 -07:00
funman300 9d0f9478b2 feat(data,engine): persistent Settings + SFX volume hotkeys
- solitaire_data::Settings { sfx_volume, first_run_complete } with
  atomic JSON persistence and clamping sanitizer.
- SettingsPlugin (engine): [ / ] adjust SFX volume by 0.1, clamped;
  persists on change; emits SettingsChangedEvent. No-op at rails.
- AudioPlugin applies sfx_volume to kira's main track at startup
  and on every change so live tweaks take effect without restart.
- Brief "SFX: N%" toast on each change. Help cheat sheet updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 23:08:20 -07:00
funman300 b720588687 feat(engine): MoveRejectedEvent + PausePlugin (Esc)
- New MoveRejectedEvent fires from end_drag when the cursor is over
  a real pile but the placement is illegal. AudioPlugin plays
  card_invalid.wav on it.
- New PausePlugin + PausedResource: Esc toggles a full-window
  overlay and the flag. tick_elapsed_time and advance_time_attack
  skip work while paused. Help cheat sheet updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:56:35 -07:00
funman300 adacdf533c feat(engine,assetgen): synthesized SFX + kira AudioPlugin
- New solitaire_assetgen crate with gen_sfx binary: synthesizes
  five 44.1kHz mono 16-bit PCM WAVs (flip/place/deal/invalid/fanfare)
  from an LCG noise source + sine/square synths. Output committed
  under assets/audio/.
- AudioPlugin (engine): embeds the WAVs via include_bytes!, decodes
  once with kira::StaticSoundData, plays on Draw / Move / NewGame /
  GameWon events. card_invalid is loaded but unused — wiring it
  needs a MoveRejectedEvent.
- AudioManager kept on the main thread (NonSend) since cpal is !Send
  on some platforms; degrades gracefully if no audio device present.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:48:58 -07:00
funman300 7dfbff45d1 feat(engine): add HelpPlugin (H/?) and Challenge cleared toast
- HelpPlugin: full-window cheat sheet listing every keybinding,
  toggled with H or ?. Three unit tests cover open/close/slash.
- AnimationPlugin: ChallengeAdvancedEvent now surfaces as a
  3-second "Challenge N cleared!" toast.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:39:08 -07:00
funman300 193410200e feat(engine,core): add Time Attack mode + unlocks panel
- Core: GameMode::TimeAttack variant (no scoring/undo changes — session marker only)
- Engine: TimeAttackPlugin with TimeAttackResource, TimeAttackEndedEvent,
  T hotkey (gated to level >= 5), auto-deal on win, summary toast
- Engine: Stats overlay (S) gains an Unlocks subsection (card backs /
  backgrounds, sorted/deduped) and a live Time Attack panel while active

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 17:27:53 -07:00
funman300 294f6fe9d4 docs: mark Phase 6 part 4b (Challenge mode + level-5 gate) complete
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 17:18:50 -07:00
funman300 788ac9f65a feat(engine,core,data): add Challenge mode with seed list and level-5 gate
Phase 6 part 4b (partial):
- GameMode::Challenge variant in solitaire_core. undo() returns
  RuleViolation when mode is Challenge so the player commits to each
  decision.
- solitaire_data::challenge defines a stable CHALLENGE_SEEDS list with
  challenge_seed_for(index) wrapping modulo length.
- PlayerProgress.challenge_index (serde-default for older saves) tracks
  how far the player has progressed.
- ChallengePlugin advances the cursor on Challenge-mode wins, persists,
  and emits ChallengeAdvancedEvent. Pressing X starts a Challenge-mode
  game with the current seed; gated to level >= CHALLENGE_UNLOCK_LEVEL (5).
- InputPlugin's Z key (Zen mode) is now also gated to level >= 5.

Time Attack and unlock UI still deferred.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 17:18:32 -07:00
funman300 09d62f4255 docs: mark Phase 6 part 4a (elapsed time + Zen mode) complete
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:16:04 -07:00
funman300 8afb1f3fe5 feat(engine,core): add elapsed-time tick system and Zen GameMode
Phase 6 part 4 (partial):
- GameState now tracks elapsed_seconds via tick_elapsed_time in GamePlugin
  (per-second increment while not won). Pure helper advance_elapsed makes
  the tick logic directly testable without mocking Bevy Time.
- New GameMode enum (Classic / Zen) on GameState. Zen mode suppresses
  scoring in move_cards and undo. GameState::new_with_mode allows callers
  to construct non-Classic games; the existing GameState::new still
  defaults to Classic. mode is serde(default) for backwards-compatible
  persistence.
- NewGameRequestEvent gains an optional mode field; handle_new_game
  honours it (falling back to the current game's mode when None).
- InputPlugin: pressing Z starts a fresh Zen-mode game.

Time Attack, Challenge mode, level-5 unlock gating, and unlock UI are
still deferred.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:14:57 -07:00
funman300 6b793aa2ab modified: solitaire_engine/src/game_plugin.rs 2026-04-24 20:12:10 -07:00
funman300 0fdfbced6d docs: mark Phase 6 part 3 (completion toasts + progression panel) complete
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:28:26 -07:00
funman300 363ddc9b75 feat(engine): surface daily/weekly completions as toasts + progression panel
Phase 6 part 3 (partial):
- AnimationPlugin now shows a 3-second toast on DailyChallengeCompletedEvent
  and WeeklyGoalCompletedEvent.
- Stats overlay (S key) appends a Progression section with level, total XP,
  daily streak, and a live Weekly Goals list pulling from WEEKLY_GOALS.

Special modes (Time Attack / Challenge / Zen) and unlock UI deferred.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:28:13 -07:00
funman300 0609d4eef3 docs: mark Phase 6 part 2b (weekly goals) complete in session handoff
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:25:31 -07:00
funman300 b730902d76 feat(engine): add weekly goals with ISO-week rollover and +75 XP bonus
Phase 6 part 2b:
- solitaire_data::weekly defines WeeklyGoalKind, WeeklyGoalDef,
  WeeklyGoalContext, current_iso_week_key, and three starter goals
  (5 wins, 3 no-undo wins, 3 fast wins).
- PlayerProgress gains weekly_goal_week_iso, roll_weekly_goals_if_new_week,
  and record_weekly_progress (returns true exactly once per goal completion).
- WeeklyGoalsPlugin evaluates GameWonEvent against WEEKLY_GOALS, rolls the
  week if needed, increments matching counters, awards WEEKLY_GOAL_XP for
  newly-completed goals, persists progress, and fires
  WeeklyGoalCompletedEvent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:25:18 -07:00
funman300 578938a9b2 docs: mark Phase 6 part 2a (daily challenge + level-up toast) complete
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:18:04 -07:00
funman300 622b35a3bf feat(engine): add daily challenge, level-up toast, and daily_devotee achievement
Phase 6 part 2 (partial):
- daily_seed_for(date) and PlayerProgress::record_daily_completion in
  solitaire_data, with streak logic that increments on consecutive days,
  resets on a skipped day, and is idempotent on same-day re-completions.
- DailyChallengePlugin tracks today's seed, awards +100 XP and updates
  the streak when the player wins a game whose seed matches. Pressing C
  starts a new game with the daily seed.
- LevelUpEvent toast in AnimationPlugin announces level changes.
- AchievementContext gains daily_challenge_streak; daily_devotee
  achievement unlocks at streak >= 7. AchievementPlugin reads
  ProgressResource and runs after ProgressUpdate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:17:59 -07:00
funman300 0cb8b32ec4 docs: mark Phase 6 part 1 (XP/levels) complete in session handoff
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:11:32 -07:00
funman300 ef043c14d4 feat(engine): add ProgressPlugin awarding XP on wins with level-up events
On GameWonEvent, computes xp_for_win(time, used_undo) from
solitaire_data, calls PlayerProgress::add_xp, and emits LevelUpEvent
when the level changes. Persists atomically through the configurable
storage path; ProgressPlugin::headless() disables I/O for tests.

Introduces ProgressUpdate system set so future systems can run after
progress mutations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:11:22 -07:00
funman300 cfdb3b7547 feat(data): add PlayerProgress with XP/level helpers and atomic persistence
level_for_xp implements the two-segment level formula from
ARCHITECTURE.md §13. xp_for_win = base 50 + linearly-scaled speed bonus
(10..=50 for sub-2-minute wins) + 25 if no undo was used. PlayerProgress
exposes add_xp returning the previous level so callers can detect
level-up events.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:10:28 -07:00
funman300 5512a141b6 docs: mark Phase 5 complete in session handoff
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:54:40 -07:00
funman300 1f6994a084 feat(engine): add AchievementPlugin with persistent unlock tracking
On GameWonEvent, build an AchievementContext from StatsResource + GameState
+ wall-clock hour, evaluate ALL_ACHIEVEMENTS, flip newly-satisfied records
to unlocked, persist atomically, and emit AchievementUnlockedEvent for
each new unlock. AnimationPlugin's toast resolves the event's ID to the
achievement's display name via achievement_plugin::display_name_for.

Introduces StatsUpdate system set so AchievementPlugin can reliably run
after StatsResource reflects the win. AchievementPlugin::headless() used
in tests to avoid touching ~/.local/share/solitaire_quest/achievements.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:53:31 -07:00
funman300 4589c52368 feat(data): add AchievementRecord and atomic achievements.json persistence
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:51:15 -07:00
funman300 82fa584cbb feat(core): add achievement module with 14 unlock conditions
Introduces AchievementContext (stats + last-win snapshot), AchievementDef,
ALL_ACHIEVEMENTS, and check_achievements. Adds undo_count to GameState
so the no_undo and speed_and_skill conditions are evaluable.

Skipped achievements that depend on features not yet built:
daily_devotee (progress), comeback (recycle counter), zen_winner (modes),
perfectionist (max-score calc). They land in later phases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:50:46 -07:00
funman300 b9957909b1 docs: mark Phase 3 and Phase 4 complete in session handoff
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:44:18 -07:00
funman300 2ce11f8f4d feat(engine): add StatsPlugin with persistent stats and toggleable overlay
StatsPlugin loads stats on startup, persists them on every GameWonEvent
and abandoned NewGameRequestEvent (>=1 move, not won), and provides a
full-window overlay toggled with `S` showing games played/won, win rate,
streak, best score, fastest win, and average win time.

The storage path is configurable via StatsPlugin::storage_path: the
default ctor uses dirs::data_dir(); StatsPlugin::headless() disables
I/O entirely so tests don't read or overwrite the user's real
stats.json. record_abandoned runs before GameMutation so it reads
move_count before handle_new_game clobbers it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:43:49 -07:00
funman300 5ced4c01ce feat(data): add atomic stats persistence (load_stats_from, save_stats_to)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:37:57 -07:00
funman300 f8cce2433d feat(data): add StatsSnapshot with update_on_win and record_abandoned
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:37:21 -07:00
Solitaire Quest bef7ab3c13 docs: add Phase 4 statistics implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 12:29:03 -07:00
Solitaire Quest 4d2379c426 feat(engine): add AnimationPlugin with slide, cascade, and toast (Phase 3F)
- CardAnim component lerps cards from old to new position on every move
- card_plugin now adds CardAnim instead of teleporting cards on state change
- Snap-back on invalid drag reuses the same mechanism (StateChangedEvent)
- Win cascade flies all 52 cards off-screen with staggered delay on GameWonEvent
- Achievement toast scaffold wired to AchievementUnlockedEvent (Phase 5 content)
- Fix input_plugin test: click Queen's visible strip, not geometric centre

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:32:42 -07: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 b3646d6cad modified: solitaire_engine/src/card_plugin.rs
modified:   solitaire_engine/src/input_plugin.rs
2026-04-23 20:48:57 -07:00
funman300 900de7f376 feat(engine): add InputPlugin with keyboard and stock-click
Keyboard: U=undo, N=new game, D=draw, Escape=pause placeholder (logged
only until the pause screen lands). Mouse: left-click on the stock pile
fires DrawRequestEvent. Cursor coordinates are converted via the active
Camera2d's viewport_to_world_2d so the hit-test works under arbitrary
camera setups.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 16:26:40 -07:00
funman300 0a87f0f8f2 feat(engine): add CardPlugin with procedural card rendering
Each card is a parent Sprite (white for face-up, blue for face-down) with
a Text2d child showing rank+suit (e.g. "AH", "10C", "KS"). Hearts and
diamonds render red; clubs and spades black. Face-down labels are hidden.
Tableau cards fan downward; other piles stack at the same position with a
small z-offset.

Sync runs in PostStartup for the initial deal and in Update after every
StateChangedEvent. To avoid a one-frame lag, downstream plugins run after
the new GameMutation system set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 16:22:49 -07:00
funman300 d92b4a8648 feat(engine): add layout, LayoutResource, and TablePlugin
compute_layout is a pure function that maps window size to card size and
the 13 pile positions, with clamping at the 800x600 minimum and seven
tableau columns horizontally aligned with stock/waste (cols 0,1) and the
four foundations (cols 3,4,5,6). TablePlugin spawns a 2D camera, a felt
background sprite, and 13 translucent pile-marker sprites, and
repositions them on WindowResized. Plugin registers WindowResized
explicitly so it works under MinimalPlugins in tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 16:18:24 -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
194 changed files with 35775 additions and 2751 deletions
+15 -3
View File
@@ -1,4 +1,16 @@
DATABASE_URL=sqlite://solitaire.db
JWT_SECRET=replace_with_64_char_hex_from_openssl_rand_hex_32
# Copy to .env and fill in the values before running docker compose up.
# SQLite database path inside the container.
# When using docker-compose, leave as-is — the volume handles persistence.
DATABASE_URL=sqlite:///data/solitaire.db
# HS256 signing secret for JWT tokens.
# Generate with: openssl rand -hex 32
JWT_SECRET=replace_me_with_a_64_char_hex_secret
# TCP port the server listens on inside the container.
SERVER_PORT=8080
ADMIN_USERNAME=admin
# Public domain name used by Caddy for automatic HTTPS.
# Example: solitaire.example.com
SOLITAIRE_DOMAIN=solitaire.example.com
+88
View File
@@ -0,0 +1,88 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs:
test:
name: Test & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install Linux audio/display dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
libasound2-dev \
libudev-dev \
libwayland-dev \
libxkbcommon-dev
- name: Cache cargo registry and build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Clippy (all crates, zero warnings)
run: cargo clippy --workspace -- -D warnings
- name: Test (headless crates only — no display required)
run: |
cargo test -p solitaire_core
cargo test -p solitaire_sync
cargo test -p solitaire_data
cargo test -p solitaire_server
build:
name: Release Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install Linux audio/display dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
libasound2-dev \
libudev-dev \
libwayland-dev \
libxkbcommon-dev
- name: Cache cargo registry and build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-release-
- name: Build release binaries
run: cargo build --workspace --release
+2
View File
@@ -4,3 +4,5 @@
*.db-wal
.env
*.tmp
data/
.claude/
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT goal_json FROM daily_challenges WHERE date = ?",
"describe": {
"columns": [
{
"name": "goal_json",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "04d8dced0cc309bc0d968ab8f542a7f6c504ce7c2f682c0bef35dfe441ffb6e8"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT id FROM users WHERE username = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "2e61cd30a6cd3e0937dd096b4f94493e8bcb8c10687d0f8c0592fe38ed956fa6"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id, password_hash FROM users WHERE username = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false
]
},
"hash": "2f12541a6b99acf6d9e8f7ad13438df4ab92edcbfa01f38930aae63f1554b534"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "36bab3ca8f126c66a6f4865d2699702689518bd3a796c374f932aba0434a4c94"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC",
"describe": {
"columns": [
{
"name": "display_name",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "best_score",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "best_time_secs",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "recorded_at",
"ordinal": 3,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
true,
true,
false
]
},
"hash": "57c93a6acd7eea44d00412e62f0d3fed7ffbe4cd759353d29f38a8eb37f69112"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE users SET leaderboard_opt_in = 1 WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "5e9e61cae887a08e4224fbea43a047c093bf5a4413499bfec858e302efa91bc3"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO sync_state (user_id, stats_json, achievements_json, progress_json, last_modified)\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT(user_id) DO UPDATE SET\n stats_json = excluded.stats_json,\n achievements_json = excluded.achievements_json,\n progress_json = excluded.progress_json,\n last_modified = excluded.last_modified",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "720edc3b85eec90744179f2c73ed9798814b734a2f3d57405ef95772ae66a24d"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM users WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "73ffdf5be39aa5c4c160c2f77d6634a6970eeb4e1d3395f045ded747f0ce9d2a"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT leaderboard_opt_in FROM users WHERE id = ?",
"describe": {
"columns": [
{
"name": "leaderboard_opt_in",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "765c87463905b2edbf37f7416d5bc38e639c7e4c5feb0f5a9d8d6fb724e7a0a8"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO leaderboard (user_id, display_name, recorded_at)\n VALUES (?, ?, ?)\n ON CONFLICT(user_id) DO UPDATE SET\n display_name = excluded.display_name,\n recorded_at = excluded.recorded_at",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "8697100790ebcdf4058812cf6debad8f9a13ac91c2d0ef20cff3bd4bb9385360"
}
@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT stats_json, achievements_json, progress_json FROM sync_state WHERE user_id = ?",
"describe": {
"columns": [
{
"name": "stats_json",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "achievements_json",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "progress_json",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "a908d8d19647494f2ac2f801dc9c977f7ce23b44779da1567049856eab922645"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE leaderboard\n SET best_score = MAX(COALESCE(best_score, 0), ?),\n best_time_secs = CASE\n WHEN ? IS NULL THEN best_time_secs\n WHEN best_time_secs IS NULL THEN ?\n ELSE MIN(best_time_secs, ?)\n END,\n recorded_at = ?\n WHERE user_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "a931c228c46dc06a5657d419cd5dfa5a810bbce9501845c92c6619d29806d70c"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO daily_challenges (date, seed, goal_json) VALUES (?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "ab537795ba39fdd28c502a8b9e615f53e7cb08f3eddd8f0574f4dc8156436de5"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "cc1e091bf5e46e6d98baf94bac6d9ac1ca1845eff7c4b8196dd2efb93fd9e0dd"
}
+145 -200
View File
@@ -1,9 +1,9 @@
# Solitaire Quest — Architecture Document
> **Version:** 1.1
> **Language:** Rust (Edition 2021)
> **Language:** Rust (Edition 2024)
> **Engine:** Bevy (latest stable)
> **Last Updated:** 2026-04-20
> **Last Updated:** 2026-04-29
---
@@ -16,28 +16,25 @@
5. [Game Engine Architecture](#5-game-engine-architecture)
6. [Persistence & Sync Architecture](#6-persistence--sync-architecture)
7. [Sync Server Architecture](#7-sync-server-architecture)
8. [Google Play Games Services (Android Future)](#8-google-play-games-services-android-future)
9. [Data Models](#9-data-models)
10. [API Reference](#10-api-reference)
11. [Merge Strategy](#11-merge-strategy)
12. [Achievement System](#12-achievement-system)
13. [Progression System](#13-progression-system)
14. [Audio System](#14-audio-system)
15. [Asset Pipeline](#15-asset-pipeline)
16. [Platform Targets](#16-platform-targets)
17. [Build & Development Guide](#17-build--development-guide)
18. [Deployment Guide](#18-deployment-guide)
19. [Security Model](#19-security-model)
20. [Testing Strategy](#20-testing-strategy)
21. [Decision Log](#21-decision-log)
8. [Data Models](#8-data-models)
9. [API Reference](#9-api-reference)
10. [Merge Strategy](#10-merge-strategy)
11. [Achievement System](#11-achievement-system)
12. [Progression System](#12-progression-system)
13. [Audio System](#13-audio-system)
14. [Asset Pipeline](#14-asset-pipeline)
15. [Platform Targets](#15-platform-targets)
16. [Build & Development Guide](#16-build--development-guide)
17. [Deployment Guide](#17-deployment-guide)
18. [Security Model](#18-security-model)
19. [Testing Strategy](#19-testing-strategy)
20. [Decision Log](#20-decision-log)
---
## 1. Project Overview
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops (iOS/Android as a stretch goal). It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
On Android (stretch goal), sync is enhanced with Google Play Games Services (GPGS) for native achievement popups, leaderboards, and cloud saves — sitting on top of the same underlying sync payload so data stays consistent regardless of which backend was used last.
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
### Sync Backend by Platform
@@ -46,8 +43,6 @@ On Android (stretch goal), sync is enhanced with Google Play Games Services (GPG
| macOS | Self-hosted server | Full feature set |
| Windows | Self-hosted server | Full feature set |
| Linux | Self-hosted server | Full feature set |
| Android (stretch) | Google Play Games Services | + server as fallback |
| iOS (stretch) | Self-hosted server | GPGS not supported on iOS |
### Design Principles
@@ -72,26 +67,25 @@ solitaire_quest/
├── Dockerfile # Multi-stage server build
├── docker-compose.yml # Server + Caddy reverse proxy
├── assets/ # All runtime assets (loaded via Bevy AssetServer)
├── assets/ # Assets embedded at compile time via include_bytes!()
│ ├── cards/
│ │ ├── faces/ # Card face sprites (suit + rank)
│ │ └── backs/ # Card back designs (back_0.png back_4.png)
│ ├── backgrounds/ # Table backgrounds (bg_0.png bg_4.png)
│ ├── fonts/ # .ttf font files
│ │ ├── faces/{rank}_{suit}.png # 52 individual card faces (120×168, generated by solitaire_assetgen)
│ │ └── backs/back_0.png back_4.png # placeholder patterns
│ ├── backgrounds/bg_0.png bg_4.png # placeholder textures
│ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
│ └── audio/
│ ├── card_deal.ogg
│ ├── card_flip.ogg
│ ├── card_place.ogg
│ ├── card_invalid.ogg
│ ├── win_fanfare.ogg
│ └── ambient_loop.ogg
│ ├── card_deal.wav
│ ├── card_flip.wav
│ ├── card_place.wav
│ ├── card_invalid.wav
│ ├── win_fanfare.wav
│ └── ambient_loop.wav
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
├── solitaire_sync/ # Shared API types — used by client and server
├── solitaire_data/ # Persistence, sync client, settings
├── solitaire_engine/ # Bevy ECS systems, components, plugins
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
├── solitaire_gpgs/ # Google Play Games Services bridge (Android only, stub until stretch goal)
└── solitaire_app/ # Main binary entry point
```
@@ -135,25 +129,10 @@ Owns:
- `SyncBackend` enum and backend selection
- Solitaire Server sync client (JWT auth, auto-refresh)
- OS keychain integration (`keyring`)
- `SyncProvider` trait — implemented by both `SolitaireServerClient` and `GpgsClient` (Android)
### `solitaire_gpgs` *(stub — implement when targeting Android)*
**Dependencies:** `solitaire_sync`, `jni` (Android only), `solitaire_data` trait impls.
Android-only crate, compiled only when `target_os = "android"`. Bridges the Google Play Games Services Java SDK via JNI.
Owns:
- `GpgsClient` implementing the `SyncProvider` trait from `solitaire_data`
- GPGS Saved Games API calls (load/save cloud save slot)
- GPGS Achievements API calls (unlock, reveal, increment)
- GPGS Leaderboards API calls (submit score, load scores)
- Google Sign-In token management (via JNI into Android SDK)
- Conversion between GPGS cloud save blob ↔ `SyncPayload`
> **Note:** This crate contains only a trait stub and compile-time stub implementations until Android support is actively developed. Do not implement JNI bindings until Phase: Android.
- `SyncProvider` trait — implemented by `SolitaireServerClient`
### `solitaire_engine`
**Dependencies:** `bevy`, `bevy_egui`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`.
**Dependencies:** `bevy`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`.
All Bevy-specific code. Structured as a collection of Plugins that `solitaire_app` registers.
@@ -162,9 +141,10 @@ Owns:
- Rendering systems (card sprites, table, backgrounds)
- Drag-and-drop input handling
- Animation systems (slide, flip, win cascade, toast)
- All egui screens (Home, Stats, Achievements, Settings, Profile)
- All Bevy UI screens (Home, Stats, Achievements, Settings, Profile)
- Audio playback systems
- Sync status display
- Card, background, and font asset loading (embedded via `include_bytes!()` — no `AssetServer` dependency)
### `solitaire_server`
**Dependencies:** `solitaire_sync`, `axum`, `sqlx`, `jsonwebtoken`, `bcrypt`, `tower-governor`, `tracing`, `tokio`, `dotenvy`.
@@ -209,7 +189,7 @@ RenderSystem ScoreSystem AchievementSystem
│ fires AchievementUnlockedEvent
ToastSystem (egui popup)
ToastSystem (Bevy UI popup)
PersistenceSystem (write to disk)
```
@@ -223,8 +203,7 @@ SyncPlugin::on_startup()
│ spawns AsyncComputeTask
solitaire_data::sync_pull() ← dispatches to active SyncProvider
│ SolitaireServerClient (desktop / iOS)
│ GpgsClient (Android, future)
│ SolitaireServerClient
solitaire_sync::merge(local, remote)
@@ -245,7 +224,7 @@ SyncPlugin::on_exit()
│ blocking push (acceptable on exit, not on main loop)
active SyncProvider::push(local)
│ POST to server — or — GPGS Saved Games PUT (Android)
│ POST to server
Done
```
@@ -256,16 +235,36 @@ Done
### Bevy Plugins
| Plugin | Responsibility |
|---|---|
| `CardPlugin` | Card entity spawning, sprite management, drag-and-drop |
| `TablePlugin` | Pile markers, background, layout calculation |
| `AnimationPlugin` | Slide, flip, win cascade, toast animations |
| `AudioPlugin` | Sound effect and music playback via bevy_kira_audio |
| `UIPlugin` | All egui screens: Home, Stats, Achievements, Settings, Profile |
| `AchievementPlugin` | Listens for game events, evaluates unlock conditions, fires toasts |
| `SyncPlugin` | Manages sync lifecycle (pull on start, push on exit, status display) |
| `GamePlugin` | Core game state resource, input routing, win detection |
| Plugin | Key | Responsibility |
|---|---|---|
| `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop |
| `TablePlugin` | — | Pile markers, background, layout calculation |
| `FontPlugin` | — | Embeds FiraMono-Medium font at compile time; exposes `FontResource` handle |
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
| `AudioPlugin` | — | Sound effect and music playback via bevy_kira_audio |
| `InputPlugin` | — | Keyboard and mouse input routing |
| `CursorPlugin` | — | Custom cursor sprite during drag |
| `SelectionPlugin` | — | Keyboard-driven card selection |
| `GamePlugin` | N | Core game state resource, new-game flow, win/game-over overlays |
| `HudPlugin` | — | Score, move counter, timer, auto-complete badge |
| `StatsPlugin` | S | Stats overlay and persistence |
| `ProgressPlugin` | — | XP/level system, persistence |
| `AchievementPlugin` | A | Unlock evaluation, toast events, persistence |
| `DailyChallengePlugin` | — | Daily challenge resource and completion tracking |
| `WeeklyGoalsPlugin` | — | Weekly goal progress and completion events |
| `ChallengePlugin` | — | Challenge mode progression (seeded hard deals) |
| `TimeAttackPlugin` | — | 10-minute time-attack mode timer |
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
| `SettingsPlugin` | O | Settings panel: audio, draw mode, theme, sync, cosmetics |
| `LeaderboardPlugin` | L | Leaderboard overlay |
| `HelpPlugin` | H | Help / controls overlay |
| `PausePlugin` | Esc | Pause and resume |
| `OnboardingPlugin` | — | First-run welcome screen |
| `SyncPlugin` | — | Async sync lifecycle (pull on start, push on exit, status display) |
| `WinSummaryPlugin` | — | Win cascade overlay and screen-shake effect |
### Key Bevy Resources
@@ -290,6 +289,20 @@ struct StatsResource(StatsSnapshot);
struct ProgressResource(PlayerProgress);
struct AchievementsResource(Vec<AchievementRecord>);
struct SettingsResource(Settings);
// Pre-loaded card face and back PNG handles
struct CardImageSet {
faces: [[Handle<Image>; 13]; 4], // [suit][rank]: Clubs=0..Spades=3, Ace=0..King=12
backs: [Handle<Image>; 5], // indexed by selected_card_back setting
}
// Project-wide font handle (FiraMono-Medium embedded at compile time)
struct FontResource(Handle<Font>);
// Pre-loaded background PNG handles
struct BackgroundImageSet {
handles: Vec<Handle<Image>>, // indices 04 match selected_background setting
}
```
### Key Bevy Events
@@ -363,7 +376,6 @@ Implementations:
|---|---|---|
| `LocalOnlyProvider` | No-op (default) | All |
| `SolitaireServerClient` | Self-hosted server | All |
| `GpgsClient` *(future)* | Google Play Games Services | Android only |
Sync always runs on `bevy::tasks::AsyncComputeTaskPool` — the game thread is never blocked.
@@ -378,9 +390,6 @@ pub enum SyncBackend {
// JWT access + refresh tokens stored in OS keychain
// key: "solitaire_quest_server_{username}"
},
GooglePlayGames,
// No credentials stored locally — auth managed by Google Sign-In SDK via JNI
// Android only; selecting this on non-Android falls back to Local silently
}
```
@@ -392,10 +401,6 @@ On exit: `POST /api/sync/push` with payload
On 401: automatically attempt `POST /api/auth/refresh`, retry once, then surface error to user.
Credentials stored in OS keychain via `keyring` — never in plaintext on disk.
### Google Play Games Sync *(Android — future, see Section 8)*
Implemented in `solitaire_gpgs` crate. Uses the GPGS Saved Games API with named slot `"solitaire_quest_sync"`. The `GpgsClient` struct implements `SyncProvider` — the `SyncPlugin` treats it identically to `SolitaireServerClient`. The same `solitaire_sync::merge()` function applies regardless of which provider returned the remote data.
---
## 7. Sync Server Architecture
@@ -482,89 +487,7 @@ This ensures all players worldwide get the same challenge for a given date, rega
---
## 8. Google Play Games Services (Android Future)
> **Status: Stub only.** Do not implement JNI bindings until Android is actively targeted. The `solitaire_gpgs` crate exists in the workspace with a trait stub so the compiler enforces the interface contract from day one.
### Why GPGS on Android
Google Play Games Services provides first-class Android features that would otherwise require significant backend work:
| Feature | GPGS Provides | Our Alternative |
|---|---|---|
| Cloud saves | Saved Games API | Self-hosted server |
| Achievements | Native popups + Play profile | In-game toasts only |
| Leaderboards | Hosted by Google, visible in Play app | Server leaderboard |
| Auth | Google Sign-In, no registration | Username + password |
On Android, GPGS is the **primary** sync provider. The self-hosted server is the **fallback** if the player is not signed in or has no server configured. Both can be active simultaneously — a win pushes to both, pull merges from whichever responded last.
### Compatibility Reality
| Platform | GPGS Support |
|---|---|
| Android | ✅ Full |
| Windows | ✅ GPGS for PC (optional, separate SDK) |
| macOS | ❌ Not supported |
| Linux | ❌ Not supported |
| iOS | ❌ Not supported |
macOS, Linux, and iOS users always use the self-hosted server. This is why the server is the primary design and GPGS is an enhancement layer.
### `solitaire_gpgs` Crate Design
The crate is compiled only on Android (`#[cfg(target_os = "android")]`). On all other platforms the crate exports only the stub.
```rust
// solitaire_gpgs/src/lib.rs
#[cfg(target_os = "android")]
mod android;
#[cfg(not(target_os = "android"))]
mod stub;
pub use stub::GpgsClient; // stub on desktop
#[cfg(target_os = "android")]
pub use android::GpgsClient; // real impl on Android
```
### JNI Bridge (Android implementation — future)
The real `GpgsClient` uses the `jni` crate to call into the GPGS Android SDK:
```
Rust GpgsClient
│ jni::JNIEnv
Java: com.google.android.gms.games.PlayGames
├── getSnapshotsClient() → Saved Games (sync payload)
├── getAchievementsClient() → unlock / reveal
└── getLeaderboardsClient() → submit score
```
Steps required when Android work begins:
1. Add `cargo-mobile2` to the build toolchain
2. Implement `GpgsClient` with `jni` bindings in `solitaire_gpgs/src/android.rs`
3. Add `GpgsClient: SyncProvider` impl — pull/push map to Saved Games load/save
4. Mirror achievement unlocks: on `AchievementUnlockedEvent`, call GPGS unlock API alongside in-game toast
5. Submit scores to GPGS leaderboard on `GameWonEvent`
6. Add Google Sign-In button to the Settings screen (Android build only, `#[cfg]` gated)
### Dual-Sync on Android
When both GPGS and the self-hosted server are configured, the `SyncPlugin` runs both providers concurrently and merges all three payloads (local + GPGS + server) using the same `solitaire_sync::merge()` function applied twice:
```
local ──────┐
├── merge() ──► intermediate ──┐
gpgs ────────┘ ├── merge() ──► final
server ──────┘
```
---
## 9. Data Models
## 8. Data Models
### Core Game Models (`solitaire_core`)
@@ -588,6 +511,9 @@ pub enum PileType {
pub enum DrawMode { DrawOne, DrawThree }
/// Active game mode. Classic is the default; others unlock at level 5.
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
pub enum MoveError {
InvalidSource,
InvalidDestination,
@@ -600,13 +526,16 @@ pub enum MoveError {
pub struct GameState {
pub piles: HashMap<PileType, Vec<Card>>,
pub draw_mode: DrawMode,
pub mode: GameMode,
pub score: i32,
pub move_count: u32,
pub undo_count: u32, // number of undos used in this game
pub recycle_count: u32, // number of stock recycles
pub elapsed_seconds: u64,
pub seed: u64,
pub is_won: bool,
pub is_auto_completable: bool,
undo_stack: Vec<StateSnapshot>, // private, max 64
undo_stack: VecDeque<StateSnapshot>, // private, max 64 (VecDeque for O(1) pop_front)
}
```
@@ -652,14 +581,14 @@ pub struct Settings {
pub music_volume: f32,
pub animation_speed: AnimSpeed,
pub theme: Theme,
pub sync_backend: SyncBackend, // Local | SolitaireServer | GooglePlayGames
pub sync_backend: SyncBackend, // Local | SolitaireServer
pub first_run_complete: bool,
}
```
---
## 10. API Reference
## 9. API Reference
All endpoints are under the base URL configured by the user (e.g., `https://solitaire.example.com`).
@@ -702,9 +631,9 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
---
## 11. Merge Strategy
## 10. Merge Strategy
Used identically by the `SolitaireServerClient`, `GpgsClient`, and server-side handler. Lives in `solitaire_sync` as a pure function with no I/O. Called once per provider when multiple backends are active simultaneously (e.g. GPGS + server on Android).
Used by both `SolitaireServerClient` and the server-side handler. Lives in `solitaire_sync` as a pure function with no I/O.
```rust
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload {
@@ -744,7 +673,7 @@ pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload {
---
## 12. Achievement System
## 11. Achievement System
### Definition Structure
@@ -789,13 +718,9 @@ pub struct AchievementDef {
Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently.
### GPGS Mirroring *(Android, future)*
When the `GpgsClient` is active, every `AchievementUnlockedEvent` also triggers a GPGS `achievements.unlock()` call via JNI so the achievement appears in the player's Google Play profile. A static map in `solitaire_gpgs` maps our achievement IDs to GPGS achievement IDs (assigned in the Google Play Console). Mirroring is fire-and-forget — failures are logged but never block the in-game toast.
---
## 13. Progression System
## 12. Progression System
### XP Sources
@@ -824,18 +749,18 @@ Levels 11+: level = 10 + floor((total_xp - 5000) / 1000)
---
## 14. Audio System
## 13. Audio System
Audio uses `bevy_kira_audio`. All sound files are `.ogg` (good compression, cross-platform, royalty-free).
Audio uses `bevy_kira_audio`. All sound files are `.wav`.
| File | Trigger |
|---|---|
| `card_deal.ogg` | New game deal animation |
| `card_flip.ogg` | Card flips face-up |
| `card_place.ogg` | Valid card placement |
| `card_invalid.ogg` | Invalid move attempt |
| `win_fanfare.ogg` | Game won |
| `ambient_loop.ogg` | Looping background music (restarts seamlessly) |
| `card_deal.wav` | New game deal animation |
| `card_flip.wav` | Card flips face-up |
| `card_place.wav` | Valid card placement |
| `card_invalid.wav` | Invalid move attempt |
| `win_fanfare.wav` | Game won |
| `ambient_loop.wav` | Looping background music |
Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `bevy_kira_audio` channel volumes.
@@ -843,43 +768,64 @@ Audio systems listen for Bevy events and never block the game thread.
---
## 15. Asset Pipeline
## 14. Asset Pipeline
All assets are loaded through Bevy's `AssetServer`. No bytes are hardcoded in source.
### Rendering approach
### Card Sprites
Cards are Bevy `Sprite` entities with `Handle<Image>` from `CardImageSet`. Face-up cards use one of 52 individual face PNGs selected by `faces[suit][rank]` — rank and suit are baked into each image and no `Text2d` overlay is spawned. Face-down cards use `backs/back_N.png` indexed by `settings.selected_card_back`. `Text2d` labels are only used as a fallback when `CardImageSet` is absent (e.g. tests with `MinimalPlugins`). `CardImageSet` is populated at startup from `include_bytes!()` — no `AssetServer`.
Card faces can be either:
- A texture atlas (`assets/cards/atlas.png` + `atlas.ron` layout) — faster to load, preferred
- Individual files (`assets/cards/faces/2_of_clubs.png`, etc.) — easier to author
Backgrounds are Bevy `Sprite` entities with `Handle<Image>` from `BackgroundImageSet`. `BackgroundImageSet` is populated at startup from `include_bytes!()`.
Card backs: `assets/cards/backs/back_0.png` through `back_4.png`. Additional backs unlocked via achievements are in the same folder, gated by `PlayerProgress::unlocked_card_backs`.
The font `FiraMono-Medium` is embedded via `include_bytes!()` at startup by `FontPlugin` and exposed as `FontResource` for use by all UI and text systems.
### Backgrounds
The `assets/` directory layout:
`assets/backgrounds/bg_0.png` through `bg_4.png`. Same unlock gating as card backs.
```
assets/
├── cards/
│ ├── faces/{rank}_{suit}.png # 52 individual card faces (120×168, generated by solitaire_assetgen)
│ └── backs/back_0.png back_4.png # placeholder patterns
├── backgrounds/bg_0.png bg_4.png # placeholder textures
├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
└── audio/
├── card_deal.wav
├── card_flip.wav
├── card_place.wav
├── card_invalid.wav
├── win_fanfare.wav
└── ambient_loop.wav
```
### Fonts
### Audio
`assets/fonts/main.ttf` — used for card rank/suit text and all egui overrides.
All sound effect WAV files are embedded at compile time via `include_bytes!()` in `audio_plugin.rs`. There is no runtime asset loading — the binary is fully self-contained.
| File | Trigger |
|---|---|
| `card_deal.wav` | New game deal animation |
| `card_flip.wav` | Card flips face-up |
| `card_place.wav` | Valid card placement |
| `card_invalid.wav` | Invalid move attempt |
| `win_fanfare.wav` | Game won |
| `ambient_loop.wav` | Looping background music |
---
## 16. Platform Targets
## 15. Platform Targets
| Platform | Status | Primary Sync | Notes |
|---|---|---|---|
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain; optional GPGS for PC (future) |
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
| Android | Stretch | Google Play Games + server | `cargo-mobile2`, touch input, GPGS via JNI |
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input; GPGS unavailable on iOS |
| Android | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
Minimum Bevy window size enforced: 800×600. Desktop windows are freely resizable; layout recomputes on `WindowResized`.
---
## 17. Build & Development Guide
## 16. Build & Development Guide
### Prerequisites
@@ -940,7 +886,7 @@ Add `--features bevy/dynamic_linking` during development to dramatically reduce
---
## 18. Deployment Guide
## 17. Deployment Guide
### Docker Compose (Recommended)
@@ -985,7 +931,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
---
## 19. Security Model
## 18. Security Model
| Concern | Mitigation |
|---|---|
@@ -1001,7 +947,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
---
## 20. Testing Strategy
## 19. Testing Strategy
### Unit Tests (`solitaire_core`)
@@ -1040,12 +986,10 @@ Using `axum::test` and an in-memory SQLite database:
- [ ] Achievement toast appears and dismisses
- [ ] Server sync: register, login, push, pull on second machine
- [ ] Server sync: JWT refresh on 401 works transparently
- [ ] GPGS sync (Android only): sign in, unlock achievement, verify appears in Play Games app
- [ ] Dual sync (Android only): GPGS + server both configured, payloads merge correctly
---
## 21. Decision Log
## 20. Decision Log
| Decision | Rationale | Date |
|---|---|---|
@@ -1057,7 +1001,8 @@ Using `axum::test` and an in-memory SQLite database:
| bcrypt cost 12 | Balances security and registration latency (~300ms on modern hardware); higher than default 10 | 2026-04-19 |
| No email required for server accounts | Reduces PII collected; simplifies self-hosted deployments; password reset handled by server admin if needed | 2026-04-19 |
| Self-hosted server as primary sync (not WebDAV) | A proper Rust server gives us auth, leaderboards, and daily challenge seeding for minimal extra effort over WebDAV, and removes a redundant backend | 2026-04-20 |
| `SyncProvider` trait, not `SyncBackend` match arms | Allows adding Google Play Games Services cleanly; `SyncPlugin` stays backend-agnostic and testable | 2026-04-20 |
| GPGS as Android enhancement, not replacement | GPGS has no macOS/Linux support; the server must remain universal, with GPGS layered on top for Android players | 2026-04-20 |
| `SyncProvider` trait, not `SyncBackend` match arms | `SyncPlugin` stays backend-agnostic and testable; new backends can be added without touching the plugin | 2026-04-20 |
| Dropped WebDAV backend | Redundant once the self-hosted server exists; removing it reduces surface area and simplifies settings UI | 2026-04-20 |
| `solitaire_gpgs` crate stubbed from day one | Enforces the `SyncProvider` interface contract at compile time even before Android work begins; avoids architectural rework later | 2026-04-20 |
| Dropped GPGS backend | Redundant with the self-hosted server; adds JNI complexity for no user-visible benefit on the target platforms | 2026-04-28 |
| PNG assets embedded via `include_bytes!()` | Using `Image::from_buffer()` in startup systems rather than `AssetServer::load()` keeps the binary self-contained and eliminates runtime file-not-found errors | 2026-04-29 |
| FiraMono-Medium font embedded via `include_bytes!()` | Exposed through `FontResource`; avoids runtime font loading errors on headless systems and ensures consistent text rendering across all platforms | 2026-04-29 |
+4 -6
View File
@@ -6,15 +6,14 @@ See @ARCHITECTURE.md for full project design, crate responsibilities, data model
## Project Layout
```
```text
solitaire_core/ # Pure Rust game logic — NO Bevy, NO network, NO I/O
solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
solitaire_data/ # Persistence + SyncProvider trait + server client
solitaire_engine/ # Bevy ECS systems, components, plugins
solitaire_server/ # Axum sync server binary
solitaire_gpgs/ # Google Play Games bridge — STUB ONLY until Android phase
solitaire_app/ # Thin binary entry point
assets/ # Loaded at runtime via Bevy AssetServer only
assets/ # Source assets — embedded at compile time via include_bytes!()
```
---
@@ -48,12 +47,11 @@ cargo clippy -p solitaire_core -- -D warnings
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
- No hardcoded bytes in source. All assets go through Bevy's `AssetServer`.
- Audio assets are embedded at compile time using `include_bytes!()` in `audio_plugin.rs`. Cards and backgrounds are rendered procedurally (colored `Sprite` entities + text) — no image files are used and no `AssetServer` is needed.
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
- All sync backends implement the `SyncProvider` trait. The `SyncPlugin` is backend-agnostic — never `match` on `SyncBackend` inside a Bevy system.
- `solitaire_gpgs` is a stub until Android work begins. Do not write JNI bindings yet; keep the compile-time stub so the trait contract is enforced from day one.
- `cargo clippy --workspace -- -D warnings` must pass clean after every change.
- `cargo test --workspace` must pass after every change.
@@ -75,7 +73,7 @@ cargo clippy -p solitaire_core -- -D warnings
- One `Plugin` per major feature: `CardPlugin`, `AudioPlugin`, `AchievementPlugin`, `UIPlugin`, `SyncPlugin`.
- Resources own shared state. Events communicate between systems. Components own per-entity data.
- All egui screens live in `solitaire_engine::ui`. Never mix egui and Bevy spawn logic in the same system.
- All UI screens are built with Bevy UI (`bevy::ui`). Never mix UI layout and game logic in the same system.
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
---
+3
View File
@@ -0,0 +1,3 @@
{$SOLITAIRE_DOMAIN} {
reverse_proxy server:{$SERVER_PORT:-8080}
}
Generated
+3636 -2397
View File
File diff suppressed because it is too large Load Diff
+16 -14
View File
@@ -5,42 +5,44 @@ members = [
"solitaire_data",
"solitaire_engine",
"solitaire_server",
"solitaire_gpgs",
"solitaire_app",
"solitaire_assetgen",
]
resolver = "2"
[workspace.package]
edition = "2021"
edition = "2024"
version = "0.1.0"
license = "MIT"
rust-version = "1.95"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "1"
rand = "0.8"
thiserror = "2"
rand = "0.9"
async-trait = "0.1"
tokio = { version = "1", features = ["full"] }
dirs = "5"
keyring = "2"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
dirs = "6"
keyring = "4"
keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" }
bevy = "0.15"
bevy_egui = "0.30"
bevy_kira_audio = "0.21"
bevy = "0.18"
kira = "0.12"
axum = "0.7"
axum = "0.8"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
jsonwebtoken = "9"
bcrypt = "0.15"
tower_governor = "0.4"
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
bcrypt = "0.19"
tower_governor = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
+28
View File
@@ -0,0 +1,28 @@
# Stage 1 — builder
# Compiles the solitaire_server binary in release mode.
# Requires a pre-generated .sqlx/ query cache (run `cargo sqlx prepare --workspace`
# before building the image so sqlx macros work without a live database).
FROM rust:slim AS builder
WORKDIR /app
COPY . .
# Tell sqlx to use the cached query metadata instead of a live database.
ENV SQLX_OFFLINE=true
RUN cargo build --release -p solitaire_server
# Stage 2 — runtime
# Minimal image that only contains the compiled binary and its runtime deps.
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/solitaire_server /usr/local/bin/solitaire_server
EXPOSE ${SERVER_PORT:-8080}
ENTRYPOINT ["/usr/local/bin/solitaire_server"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 funman300
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+73
View File
@@ -0,0 +1,73 @@
# Solitaire Quest
A cross-platform Klondike Solitaire game written in Rust, featuring a full progression system with XP, levels, achievements, daily challenges, and optional self-hosted sync so your stats follow you across machines.
## Features
- **Klondike Solitaire** — Draw One and Draw Three modes
- **Progression** — XP, levels, unlockable card backs and backgrounds
- **18 Achievements** — including secret ones
- **Daily Challenge** — server-seeded so every player worldwide gets the same deal
- **Leaderboard** — opt-in, powered by your own self-hosted server
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
- **Sync** — pull/push stats across devices via a self-hosted server
- **Color-blind mode** — blue tint on red-suit cards
## Building
**Prerequisites**
- Rust stable toolchain (`rustup install stable`)
- Linux: `libasound2-dev libudev-dev libxkbcommon-dev`
- macOS: Xcode Command Line Tools
```bash
# Fast development build
cargo run -p solitaire_app --features bevy/dynamic_linking
# Release build
cargo build -p solitaire_app --release
./target/release/solitaire_app
```
## Controls
| Key | Action |
|---|---|
| Left click / drag | Move cards |
| Right click | Highlight legal moves for a card |
| Space / D | Draw from stock |
| Z / Ctrl+Z | Undo |
| N | New game |
| S | Stats overlay |
| A | Achievements overlay |
| P | Profile overlay |
| O | Settings |
| L | Leaderboard |
| H | Help / controls |
| Enter | Auto-complete (when badge is lit) |
| Escape | Pause / clear selection |
| Arrow keys | Navigate card selection |
## Sync Server (optional)
To sync stats across machines, run the self-hosted server. See [README_SERVER.md](README_SERVER.md) for setup instructions.
Once the server is running, open **Settings → Sync Backend**, enter the server URL and your username, and register an account from within the game.
## Running Tests
```bash
# All tests
cargo test --workspace
# Just game logic (no display required)
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
# Lint
cargo clippy --workspace -- -D warnings
```
## License
MIT — see [LICENSE](LICENSE).
+44
View File
@@ -0,0 +1,44 @@
# Solitaire Quest — Self-Hosting Guide
## Prerequisites
- Docker and Docker Compose
- `openssl` for generating a JWT secret
## Quick start
1. Clone the repo and enter it.
2. Copy the example environment file and fill in your values:
```bash
cp .env.example .env
# Edit .env: set JWT_SECRET and SOLITAIRE_DOMAIN
```
3. (First time only) Generate the sqlx query cache so the server builds without a live database:
```bash
cargo install sqlx-cli --no-default-features --features rustls,sqlite
export DATABASE_URL=sqlite://solitaire.db
sqlx database create
sqlx migrate run --source solitaire_server/migrations
cargo sqlx prepare --workspace
rm solitaire.db # the real DB lives in ./data/ at runtime
```
4. Start everything:
```bash
docker compose up -d
```
5. The server is now reachable at `https://<SOLITAIRE_DOMAIN>`.
## Backups
The entire server state is one SQLite file at `./data/solitaire.db`. Back it up with:
```bash
sqlite3 ./data/solitaire.db ".backup backup_$(date +%Y%m%d).db"
```
## Updating
```bash
git pull
docker compose build
docker compose up -d
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.
+30
View File
@@ -0,0 +1,30 @@
services:
server:
build: .
env_file: .env
environment:
# Override DATABASE_URL so the DB always lands in the persistent volume,
# regardless of what .env contains.
DATABASE_URL: sqlite:///data/solitaire.db
volumes:
- ./data:/data
restart: unless-stopped
expose:
- "${SERVER_PORT:-8080}"
caddy:
image: caddy:2-alpine
depends_on:
- server
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
+131 -27
View File
@@ -1,7 +1,8 @@
# Solitaire Quest — Session Handoff
> Last updated: 2026-04-21
> Last updated: 2026-04-25
> Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git
> Test count: **242 passing** (83 core + 60 data + 99 engine), `cargo clippy --workspace -- -D warnings` clean
---
@@ -73,44 +74,147 @@ f84d7c5 fix(workspace): add derives/docs per code review, remove unused thiserro
---
### Phase 3 — Bevy Rendering & Interaction ✅ COMPLETE
All sub-phases (3A3F) done. Plugins: `GamePlugin`, `TablePlugin`, `CardPlugin`, `InputPlugin`, `AnimationPlugin`. Full game playable — drag/drop with rule validation, keyboard shortcuts (U/N/D/Esc), animated slides, win cascade. UI via `bevy::ui`, no egui.
### Phase 4 — Statistics Persistence ✅ COMPLETE
- `solitaire_data::StatsSnapshot` with `update_on_win` / `record_abandoned` / `win_rate`
- Atomic file I/O via `save_stats_to` (`.tmp` → rename)
- `StatsPlugin` in `solitaire_engine` — loads on startup, persists on `GameWonEvent` (win) and `NewGameRequestEvent` (abandoned if move_count>0 and not won)
- Full-window overlay toggled with `S` — games played/won, win rate, streak, best score, fastest, avg
- `StatsPlugin::default()` for production, `StatsPlugin::headless()` for tests (no disk I/O)
### Phase 5 — Achievements ✅ COMPLETE (14 of ~19)
- `solitaire_core::achievement``AchievementContext` + `AchievementDef` + `ALL_ACHIEVEMENTS` + `check_achievements`
- `solitaire_core::GameState.undo_count` — tracks whether undo was used (for `no_undo` / `speed_and_skill`)
- `solitaire_data::AchievementRecord` + atomic `achievements.json` persistence
- `AchievementPlugin` — on `GameWonEvent`, build context from `StatsResource` + `GameState` + `chrono::Local` hour, evaluate all conditions, persist newly-unlocked records, emit `AchievementUnlockedEvent(id)`
- `AnimationPlugin`'s toast resolves the event's ID to the achievement's name via `achievement_plugin::display_name_for`
- New `StatsUpdate` system set lets `AchievementPlugin` order itself after stats are incremented
- Deferred: `daily_devotee` (needs `PlayerProgress`), `comeback` (needs recycle counter), `zen_winner` (needs modes), `perfectionist` (needs max-score calc). Stubs can be added in later phases.
### Phase 6 (part 1) — XP, Levels, ProgressPlugin ✅ COMPLETE
- `solitaire_data::PlayerProgress` with `total_xp`, `level`, daily/weekly/unlock fields
- `level_for_xp(xp)` and `xp_for_win(time, used_undo)` helpers (per ARCHITECTURE.md §13)
- `add_xp(amount) -> prev_level` with `leveled_up_from(prev)` for level-up detection
- Atomic `progress.json` persistence via `save_progress_to` / `load_progress_from`
- `ProgressPlugin` — on `GameWonEvent`, awards XP (base 50 + speed bonus 1050 + no-undo 25), persists, emits `LevelUpEvent`
- `ProgressUpdate` system set for ordering downstream systems
- `ProgressPlugin::default()` for production, `::headless()` for tests
### Phase 6 (part 2a) — Daily Challenge + Level-Up Toast ✅ COMPLETE
- `daily_seed_for(date)` deterministic per-date seed
- `PlayerProgress::record_daily_completion(date)` with streak / reset / idempotency rules
- `DailyChallengePlugin`: today's seed in a resource; pressing **C** starts a daily-seed new game; on winning a daily-seed game, awards **+100 XP**, updates streak, persists, fires `DailyChallengeCompletedEvent`
- `LevelUpEvent` now spawns a toast through `AnimationPlugin`
- `daily_devotee` achievement wired (streak ≥ 7); `AchievementContext` gains `daily_challenge_streak` and reads from `ProgressResource`
### Phase 6 (part 2b) — Weekly Goals ✅ COMPLETE
- `solitaire_data::weekly``WeeklyGoalKind`, `WeeklyGoalDef`, `WeeklyGoalContext`, `current_iso_week_key`, three starter goals (5 wins / 3 no-undo / 3 fast)
- `PlayerProgress``weekly_goal_week_iso`, `roll_weekly_goals_if_new_week`, `record_weekly_progress`
- `WeeklyGoalsPlugin` — on `GameWonEvent`, rolls week if needed, increments matching goals, awards `WEEKLY_GOAL_XP` (75) per completion, fires `WeeklyGoalCompletedEvent`
### Phase 6 (part 3) — Completion Toasts + Progression Panel ✅ COMPLETE
- `AnimationPlugin` now surfaces `DailyChallengeCompletedEvent` (shows streak) and `WeeklyGoalCompletedEvent` (shows goal description) as 3-second toasts.
- Stats overlay (**S** key) appends a Progression section: level, total XP, daily streak, and a Weekly Goals list iterating `WEEKLY_GOALS` with `progress/target` for each.
### Phase 6 (part 4a) — Elapsed Time + Zen Mode ✅ COMPLETE
- `tick_elapsed_time` in `GamePlugin` ticks `GameState.elapsed_seconds` once per real-world second while not won; `advance_elapsed` is a pure helper for direct unit testing.
- `GameMode` enum (`Classic` / `Zen`) added to `solitaire_core::game_state`. `GameState.mode` field; `GameState::new_with_mode` ctor. Zen suppresses scoring in `move_cards` and `undo`. Field is `#[serde(default)]` for backwards-compatible saved games.
- `NewGameRequestEvent` carries an optional `mode`; `handle_new_game` falls back to the current game's mode when `None`.
- `Z` key starts a fresh Zen game.
### Phase 6 (part 4b) — Challenge Mode + Level-5 Gate ✅ COMPLETE
- `GameMode::Challenge` variant in core; `undo()` returns `RuleViolation` in Challenge.
- `solitaire_data::challenge``CHALLENGE_SEEDS` static list, `challenge_seed_for(index)` wrapping modulo length, `challenge_count()`.
- `PlayerProgress.challenge_index` (serde-default) tracks progression.
- `ChallengePlugin` advances the cursor on Challenge-mode wins, persists, fires `ChallengeAdvancedEvent`. **X** key starts a Challenge-mode game with the current seed.
- Both **Z** (Zen) and **X** (Challenge) are gated to `level >= CHALLENGE_UNLOCK_LEVEL` (5).
### Phase 6 (part 4c) — Time Attack + Unlock UI ✅ COMPLETE
- `GameMode::TimeAttack` variant added to core (no scoring/undo changes — just a session marker).
- `TimeAttackPlugin` (engine) — `TimeAttackResource { active, remaining_secs, wins }` (session-only, not persisted), `TimeAttackEndedEvent { wins }`. **T** starts a session (gated to level ≥ 5) and deals a TimeAttack-mode game; the timer (`TIME_ATTACK_DURATION_SECS = 600.0`) decrements each frame; wins during the active session bump the counter and auto-deal a fresh game.
- `AnimationPlugin` surfaces `TimeAttackEndedEvent` as a 5-second summary toast.
- `StatsPlugin` overlay (**S**) appends an "Unlocks" subsection (card backs / backgrounds, sorted/deduped, "None" when empty) and a live "Time Attack" panel showing remaining minutes/seconds + wins while a session is active.
- Helper `format_id_list` factored out + tested.
### Phase 7 (part 1) — Help Overlay + Challenge Toast ✅ COMPLETE
- `HelpPlugin`: **H** or `?` toggles a full-window cheat sheet listing all keybindings (gameplay, mode hotkeys, overlays). 3 unit tests.
- `AnimationPlugin` now surfaces `ChallengeAdvancedEvent` as a 3-second toast ("Challenge N cleared!").
### Phase 7 (part 2) — Synthesized SFX + AudioPlugin ✅ COMPLETE
- New workspace crate `solitaire_assetgen` with bin `gen_sfx`. Synthesizes five 44.1kHz mono 16-bit PCM WAVs from a deterministic LCG noise source + sine/square synths into `assets/audio/`. Run with `cargo run -p solitaire_assetgen --bin gen_sfx`. Output is committed; end users never run the generator.
- `AudioPlugin` (`solitaire_engine`): embeds the WAVs via `include_bytes!()`, decodes once via `kira::StaticSoundData::from_cursor`, plays on `DrawRequestEvent` (flip), `MoveRequestEvent` (place), `NewGameRequestEvent` (deal), `GameWonEvent` (fanfare).
- Backend handle stored as `NonSend` (cpal stream is `!Send` on some platforms). Plugin degrades gracefully if no audio device is available — logs a warning, gameplay continues silently.
- Single decode unit test (`embedded_wavs_decode_successfully`) keeps the loader and generator in sync.
### Phase 7 (part 3) — MoveRejectedEvent + Pause Menu ✅ COMPLETE
- New `MoveRejectedEvent { from, to, count }`. `end_drag` fires it when the cursor is over a real pile but `can_place_*` rejects the placement. `AudioPlugin` plays `card_invalid.wav` on it.
- New `PausePlugin` + `PausedResource(bool)`. **Esc** toggles a full-window pause overlay (ZIndex 220) and flips the resource. `tick_elapsed_time` and `advance_time_attack` skip work while paused. Input is deliberately not blocked — pause is a "stop the clock" screen, nothing more.
- `HelpPlugin` cheat sheet updated to reflect the new Esc behaviour.
### Phase 7 (part 4) — Settings + SFX Volume Control ✅ COMPLETE
- New `solitaire_data::Settings { sfx_volume, first_run_complete }` with atomic JSON persistence (`save_settings_to` / `load_settings_from`). `sanitized()` clamps out-of-range volumes after deserialization. Default `sfx_volume = 0.8`.
- New `SettingsPlugin` (engine) with `SettingsResource`, `headless()` ctor, and `SettingsChangedEvent`. **\[** / **\]** adjust SFX volume by `SFX_STEP` (0.1), clamped; persists on change. No-op + no event when already at the rail.
- `AudioPlugin` applies `sfx_volume` to kira's main track at startup and on every `SettingsChangedEvent` (so changes take effect mid-game without restart).
- `AnimationPlugin` shows a brief "SFX: 70%" toast on every change so players see the new value.
- Help cheat sheet lists the **\[** / **\]** keys.
- 4 plugin tests + 6 data tests added — defaults, clamping, round-trip persistence.
### Phase 7 (part 5) — First-Run Onboarding ✅ COMPLETE
- New `OnboardingPlugin`. At `PostStartup`, if `Settings.first_run_complete == false`, spawns a centered welcome banner pointing at the **H**/`?` cheat sheet (ZIndex 230). Any key or mouse-button press dismisses it, sets the flag, and persists `settings.json` — returning players never see it again.
- 4 unit tests cover spawn-only-on-first-run, key dismiss, and click dismiss.
## What Is Next
### Phase 3 — Bevy Rendering & Interaction (`solitaire_engine`)
Phase 7 polish slate is done. Phase 8 (sync) is next.
This is the next phase to implement. Key tasks:
- Add `GameStateResource`, `DragState`, `SyncStatusResource` Bevy resources
- Add Bevy events: `MoveRequestEvent`, `DrawRequestEvent`, `UndoRequestEvent`, `NewGameRequestEvent`, `StateChangedEvent`, `GameWonEvent`
- `CardPlugin` — spawn card entities with 2D sprites, drag-and-drop input
- `TablePlugin` — pile markers, table background, layout calculation from window size
- `AnimationPlugin` — card slide (lerp 0.15s), flip (scale X 0.2s), win cascade, toast
- `GamePlugin` — wire `GameStateResource`, route input events to `solitaire_core::GameState`
- Responsive layout: recalculate positions on `WindowResized`
- Keyboard shortcuts: U=undo, N=new game, D=draw, Escape=pause
See the full spec in the master prompt (originally pasted by the user) or in `ARCHITECTURE.md` section 5.
### Phases 48 (in order after Phase 3)
### Phase 8 — Sync
| Phase | Scope |
|---|---|
| Phase 4 | Statistics (`StatsSnapshot`, persist to `stats.json`, stats screen in egui) |
| Phase 5 | Achievements (20+ achievements, `AchievementPlugin`, toast queue) |
| Phase 6 | XP/levels, daily challenges, weekly goals, special modes |
| Phase 7 | Audio (`bevy_kira_audio`), polish, hints, onboarding, pause menu |
| Phase 8AC | Local storage + `SyncProvider` + self-hosted Axum server + client |
| Phase 8D | GPGS stub fully wired into settings UI (already compiles, just UI) |
| Phase 8A | Local storage scaffolding + `SyncProvider` plumbing in `solitaire_data` |
| Phase 8B | Self-hosted Axum server (auth, sync endpoints, SQLite schema) |
| Phase 8C | `SolitaireServerClient` (`SyncProvider` impl) + `SyncPlugin` lifecycle |
| Phase 8D | GPGS stub fully wired into the settings UI (Android-only `cfg`-gated) |
### Tiny optional polish (anytime)
- **Ambient loop**: optional sixth WAV — needs taste, deferred until artwork phase.
- **Block input while paused**: drag/hotkeys still work mid-pause; tightening this would make pause behave more like a true modal.
---
## Important Implementation Notes
### Versions (Cargo.toml workspace deps)
- `bevy = "0.15"` (resolved to 0.15.3)
- `bevy_egui = "0.30"` (0.30.1)
- `bevy_kira_audio = "0.21"` (0.21.0)
- `bevy = "0.15"` (resolved to 0.15.3) — UI via built-in `bevy::ui`, no bevy_egui
- `kira = "0.9"` — audio via `kira` crate directly, no bevy_kira_audio or AssetServer
- `rand = "0.8"` — note: `small_rng` feature is NOT enabled; use `StdRng`, not `SmallRng`
### Asset strategy
- No `AssetServer` — assets embedded at compile time using `include_bytes!()`
- Fonts: `Font::try_from_bytes(include_bytes!("../assets/fonts/main.ttf"))`
- Audio: load from `&[u8]` via `kira` `StaticSoundData::from_cursor()`
- Card rendering: procedural (`bevy::prelude::Sprite` + `Text2d`) — no sprite sheets required
### Hard rules (from CLAUDE.md)
- `solitaire_core` and `solitaire_sync` must NEVER gain Bevy or network dependencies
- No `unwrap()` or `panic!()` in game logic — use `Result<_, MoveError>` everywhere
@@ -143,12 +247,12 @@ For Phase 3 onwards, write a new plan using the `superpowers:writing-plans` skil
# Check everything compiles
cargo check --workspace
# Run all tests (68 tests, all should pass)
# Run all tests (214 tests, all should pass)
cargo test --workspace
# Lint (must be zero warnings)
cargo clippy --workspace -- -D warnings
# Run the game (blank window for now — rendering added in Phase 3)
# Run the game
cargo run -p solitaire_app --features bevy/dynamic_linking
```
+247
View File
@@ -0,0 +1,247 @@
# Android Port Investigation
> **Date:** 2026-04-28
> **Author:** Claude Code
> **Scope:** Feasibility analysis for porting Solitaire Quest to Android using cargo-mobile2
---
## Summary
A working Android port is feasible but not trivial. The core game logic (`solitaire_core`, `solitaire_sync`) compiles to Android without changes. Every other crate requires at least minor surgery. The biggest blockers are the `keyring` crate (no Android backend), the `kira`/`AudioManager` audio stack (`DefaultBackend` uses CPAL which targets desktop), and the `dirs` crate returning `None` on Android in its current usage. Touch input already has a solid foundation in `input_plugin.rs`. Estimated effort from a clean Android toolchain is **1218 developer-days** to reach a playable-but-rough state.
---
## 1. Bevy on Android — Current Status
Bevy's Android support is community-maintained via the `winit` backend and is usable but carries known rough edges as of the 0.15/0.16 generation.
**What works:**
- Basic rendering via Vulkan (through `wgpu`). OpenGL ES fallback is available for older devices.
- Touch input events: Bevy's `TouchInput` events and the `Touches` resource are populated from Android `MotionEvent`s via `winit`. The existing `touch_start_drag`, `touch_follow_drag`, `touch_end_drag`, and `handle_touch_stock_tap` systems in `input_plugin.rs` will function correctly — this was already written with multi-touch in mind and uses `TouchPhase::Started/Moved/Ended/Canceled` cleanly.
- Bevy UI (the `bevy::ui` module used for all overlays).
- `WindowResized` events fire correctly, so the layout system will recompute for any screen size.
**What does not work / needs attention:**
- **`bevy/dynamic_linking`**: The dynamic linking feature must be stripped from any Android build profile. Dynamic linking is a desktop-only development shortcut; Android requires static linking.
- **Fixed window size**: `main.rs` sets `resolution: (1280u32, 800u32)`. On Android the window is always the full display. This value is harmlessly overridden by the OS, but `min_width`/`min_height` constraints should be removed or set to 0 for Android to avoid Winit warnings.
- **`F11` fullscreen toggle** (`handle_fullscreen` in `input_plugin.rs`): `WindowMode::BorderlessFullscreen` is desktop-only. On Android it should be a no-op.
- **Keyboard shortcuts**: The entire `handle_keyboard_core`, `handle_keyboard_hint`, `handle_keyboard_forfeit` systems are desktop-only workflows. They will not crash, but they are dead code on Android. No touchscreen replacement for Undo (U), New Game (N), Draw (D/Space), Hint (H), Forfeit (G) exists yet — these need an on-screen UI.
- **`CursorPlugin`**: The custom cursor sprite plugin is irrelevant on Android (no cursor). Harmless to leave registered, but it uses `PrimaryWindow` cursor APIs that may panic or warn on Android.
**cargo-mobile2 integration for Bevy:**
The standard path is:
1. Install `cargo-mobile2`: `cargo install --locked cargo-mobile2`
2. Run `cargo mobile init` in the workspace root. This generates an `android/` directory with the Gradle project, `AndroidManifest.xml`, and JNI glue.
3. cargo-mobile2 targets the `solitaire_app` binary crate (the thin entry point). The generated `lib.rs` shim calls `android_main` via `bevy::winit`'s Android entry point.
4. The `solitaire_app` crate needs a `[lib]` target added alongside the existing `[[bin]]`, with `crate-type = ["cdylib"]`, used only when building for Android.
**Required `Cargo.toml` changes (workspace level):**
```toml
[target.'cfg(target_os = "android")'.dependencies]
# android_logger and ndk-glue wiring are handled by cargo-mobile2's generated shim.
# No direct ndk-glue dependency is needed in app code when using Bevy + cargo-mobile2.
```
**NDK version:** Android NDK r25c or r26 LTS is the tested range for `wgpu`/Vulkan on Android. NDK r27+ may work but has had compatibility reports with CPAL. Set `ANDROID_NDK_ROOT` to the NDK root; the minimum API level should be 26 (Android 8.0) for Vulkan stability.
---
## 2. Audio — `kira` + `DefaultBackend`
**The problem:**
`solitaire_engine/src/audio_plugin.rs` creates an `AudioManager<DefaultBackend>`. `kira`'s `DefaultBackend` is an alias for `CpalBackend`, which wraps CPAL. CPAL's Android backend uses OpenSL ES and is functional but historically fragile. As of kira 0.9+, `kira` no longer bundles its own CPAL backend by default in the same way — the `DefaultBackend` feature must be enabled explicitly and requires `cpal` with the Android feature.
**Current code behavior:**
The `AudioPlugin::build` already handles the "no audio device" case gracefully:
```rust
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
if manager.is_none() {
warn!("audio device unavailable; SFX disabled");
}
```
This means if the audio manager fails to initialise on Android, the game continues silently. This is acceptable as a first-pass fallback.
**What is needed for working audio on Android:**
- Add `kira` dependency with `cpal` backend enabled for Android: The `kira` workspace dependency currently specifies `version = "0.12"`. Verify that `kira/Cargo.toml` exposes a `cpal` feature (or that `DefaultBackend` compiles on Android targets with NDK). If not, a `CpalBackend` with `cpal = { features = ["oboe"] }` may be needed.
- The `NonSend` resource `AudioState` should compile fine — `NonSend` is legal in Bevy Android builds.
- `include_bytes!` for the WAV assets is compile-time and unaffected by platform.
**Recommendation:** Defer full audio verification to a device test. The graceful fallback means a silent-but-working first build is achievable without resolving this.
---
## 3. `keyring` Crate — No Android Backend
**The problem:**
`keyring = "2"` is used in `solitaire_data/src/auth_tokens.rs` to store JWT access and refresh tokens in the OS keychain. The `keyring` crate's Android backend does not exist — as of v2.x, supported backends are: macOS Keychain, Windows Credential Manager, Linux Secret Service (D-Bus), and iOS Keychain. There is no Android KeyStore backend.
On Android, `Entry::new(...)` will return `keyring::Error::NoStorageAccess`, which the existing code already maps to `TokenError::KeychainUnavailable`. So the code will not crash — it will simply fail every token store/load operation.
**Current failure mode:**
Every call to `store_tokens`, `load_access_token`, `load_refresh_token`, or `delete_tokens` will return `Err(TokenError::KeychainUnavailable(...))`. The sync client in `sync_client.rs` needs to be verified to handle this gracefully rather than propagating an error that disables sync entirely.
**Options for Android credential storage:**
| Option | Security | Effort | Notes |
|---|---|---|---|
| **In-memory only (prompt re-login each session)** | N/A | 1 day | Simplest. On `TokenError::KeychainUnavailable`, the `SyncProvider` returns `SyncError::Auth`, user is prompted to log in. Already architecturally supported. |
| **Encrypted `SharedPreferences` equivalent via JNI** | Good | 46 days | Call Android's `EncryptedSharedPreferences` (Jetpack Security) via JNI. Significant JNI boilerplate. |
| **AES-256 file encryption using Android Keystore via JNI** | Excellent | 58 days | Proper Android keychain equivalent. Complex JNI. |
| **Store in app-private file, unencrypted** | Poor | 0.5 days | Only acceptable during development. Never ship. |
**Recommended approach (first pass):** Use the in-memory / re-login-each-session path. The existing `TokenError::KeychainUnavailable` variant already exists for exactly this reason (Linux without a running secret service). The `SyncPlugin` should detect this on startup and present a "Sync unavailable — please log in" message rather than a hard error. This requires:
1. Conditional compilation: when `cfg(target_os = "android")`, replace the `keyring` calls with a no-op in-memory store (a simple `Mutex<HashMap<String, String>>`).
2. A `#[cfg(not(target_os = "android"))]` guard on the `keyring` import/dependency in `solitaire_data/Cargo.toml`.
**Required `solitaire_data/Cargo.toml` change:**
```toml
[target.'cfg(not(target_os = "android"))'.dependencies]
keyring = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
# keyring is replaced by in-memory storage; no dependency needed
```
---
## 4. `dirs` Crate — Data Directory on Android
**The problem:**
`storage.rs` and other persistence modules use `dirs::data_dir()` to locate `~/.local/share/solitaire_quest/` (or platform equivalent). On Android, `dirs::data_dir()` returns `None` because there is no `XDG_DATA_HOME` and the `dirs` crate does not implement an Android-specific path.
**Current code behavior:**
All persistence functions already handle `None` gracefully (returning default values or `Err`), consistent with the CLAUDE.md lesson about `dirs::data_dir()`. Stats and progress will silently not persist across sessions if `data_dir()` returns `None`.
**Fix required:**
Android apps should store private data in the app's internal storage directory, obtained via JNI: `context.getFilesDir()`. This requires either:
- A thin JNI helper (via `jni` crate) called once on startup to obtain the path and store it as a global.
- Or passing the path in via the `android_main` entry point using `cargo-mobile2`'s `AndroidApp` handle, which exposes `internal_data_path()`.
The `cargo-mobile2` + Bevy path exposes an `AndroidApp` via `bevy::winit`'s Android entry point. Bevy 0.13+ passes `AndroidApp` through `WinitPlugin`, and it is accessible via a Bevy resource. A startup system can extract `app.internal_data_path()` and insert a `PlatformDataDirResource` that the storage functions read instead of calling `dirs::data_dir()`.
**Effort:** 12 days to implement the override and thread it through all `storage.rs` / `progress.rs` / `settings.rs` / `achievements.rs` call sites.
---
## 5. Touch Input — Current State and Gaps
**What already exists (strong foundation):**
The `InputPlugin` in `input_plugin.rs` has a complete parallel touch pipeline:
| System | Purpose | Status |
|---|---|---|
| `handle_touch_stock_tap` | Tap the stock pile to draw | Complete |
| `touch_start_drag` | Begin a touch drag on a face-up card | Complete |
| `touch_follow_drag` | Move card(s) with the active finger | Complete |
| `touch_end_drag` | Resolve the drag (move or reject) | Complete |
The touch systems use `TouchInput` events and the `Touches` resource, map touch IDs to `DragState.active_touch_id` to prevent multi-finger conflicts, and share the same `DragState`, `MoveRequestEvent`, `MoveRejectedEvent`, and `StateChangedEvent` infrastructure as the mouse pipeline. The drag threshold (`tuning.drag_threshold_px`) applies identically.
**Gaps for a production Android experience:**
1. **No double-tap equivalent for auto-move**: `handle_double_click` is mouse-only. Android users need a double-tap to trigger the same "move to best destination" logic. The `handle_double_click` system checks `buttons.just_pressed(MouseButton::Left)` and will be inert on Android. Estimated: 1 day.
2. **No touch equivalent for keyboard actions**: Undo, New Game, Draw (when stock is visible but tapping it is awkward), Hint, and Forfeit have no on-screen buttons. These need an Android-specific UI bar or gesture (e.g. two-finger tap for undo). Estimated: 23 days for a minimal floating action button strip.
3. **Drag threshold tuning**: The threshold is in `AnimationTuning` (`tuning.drag_threshold_px`). Touch screens typically need a larger threshold than mouse (physical screens have more accidental movement during a tap). The current value should be evaluated on a real device and likely increased for touch.
4. **No long-press for right-click equivalent**: The right-click highlight/hint glow (`HintHighlightTimer`) is triggered via right mouse button. Long-press detection is not yet implemented. This is a missing feature but not a blocker for basic play.
5. **`handle_double_click` uses `LocalDateTime`-based timing via `Time`**: This will work on Android, but `DOUBLE_CLICK_WINDOW = 0.35s` may feel too tight on touch. Should be configurable.
---
## 6. Additional Issues Not in Scope of the Four Research Areas
**`CursorPlugin`:** Uses Bevy's cursor APIs which are desktop-only. Should be conditionally compiled out on Android with `#[cfg(not(target_os = "android"))]`.
**`reqwest` with `rustls-native-certs`:** The `reqwest` dependency uses `rustls` with native root certificates. On Android, `rustls-native-certs` reads system certificates differently (via the `android_system_properties` crate internally). This generally works but should be tested; Android's certificate store is in a non-standard location vs Linux.
**App lifecycle (suspend/resume):** Android can suspend the process at any time. Bevy handles `WindowEvent::Suspended` and `WindowEvent::Resumed` via `winit`, pausing the render loop. The `SyncPlugin`'s "push on exit" path (`AppExit` event) should also trigger on `WindowEvent::Suspended` to avoid data loss when the user backgrounds the app. This is a separate feature (1 day).
**No `sqlx` on Android:** `solitaire_server` is a server binary and is never built for Android. The `sqlx` dependency only exists in `solitaire_server/Cargo.toml` and will not affect Android builds of the client crates.
**`solitaire_assetgen`:** The asset generation tool is desktop-only and not part of the client build. Unaffected.
---
## 7. Required Changes Per Crate
### `solitaire_core` and `solitaire_sync`
No changes required. Both are pure Rust with no platform dependencies.
### `solitaire_data`
| Change | Effort |
|---|---|
| Gate `keyring` dependency on `#[cfg(not(target_os = "android"))]` | 0.5 days |
| Implement `auth_tokens.rs` in-memory fallback for Android | 1 day |
| Add `internal_data_path()` override for `dirs::data_dir()` on Android | 1.5 days |
| Audit all `dirs::data_dir()` / `settings_file_path()` call sites to accept injected path | 0.5 days |
### `solitaire_engine`
| Change | Effort |
|---|---|
| Conditionally disable `CursorPlugin` on Android | 0.5 days |
| Disable `handle_fullscreen` on Android (or make it a no-op) | 0.25 days |
| Implement double-tap for auto-move (touch equivalent of `handle_double_click`) | 1 day |
| On-screen action bar for Undo, New Game, Hint (minimal floating buttons) | 2.5 days |
| Tune drag threshold for touch; expose as a platform-specific tuning constant | 0.5 days |
| Trigger sync push on `WindowEvent::Suspended` in `SyncPlugin` | 1 day |
| Verify `kira` audio on Android (test `DefaultBackend` / CPAL; implement fallback if needed) | 12 days |
### `solitaire_app`
| Change | Effort |
|---|---|
| Add `[lib]` target with `crate-type = ["cdylib"]` for Android builds | 0.25 days |
| Create `src/lib.rs` (or `src/android.rs`) Android entry point calling `android_main` | 0.5 days |
| Remove or guard fixed `resolution` / `resize_constraints` for Android | 0.25 days |
| Pass `AndroidApp::internal_data_path()` to a startup resource | 0.5 days |
### Build / Toolchain
| Change | Effort |
|---|---|
| Install cargo-mobile2, Android NDK r25c/r26, `aarch64-linux-android` target | 1 day |
| Run `cargo mobile init`, configure `android/` Gradle project | 0.5 days |
| Get a first build compiling (resolve linker / NDK issues) | 12 days |
---
## 8. Estimated Effort
| Phase | Description | Days |
|---|---|---|
| Toolchain setup | NDK, cargo-mobile2, first compile | 23 |
| `solitaire_data` Android adaptations | keyring fallback, data dir | 3 |
| `solitaire_app` Android entry point | cdylib, AndroidApp wiring | 1 |
| `solitaire_engine` guards and fixes | cursor, fullscreen, audio verify | 23 |
| Touch UX improvements | double-tap, action bar, threshold tuning | 45 |
| Testing on real device / emulator | iteration, lifecycle edge cases | 23 |
| **Total** | | **1417 days** |
This produces a playable, functionally complete Android build. It does not include Play Store preparation (signing keys, metadata, icon set, permissions manifest tuning) which would add 12 more days.
---
## 9. Recommended First Step
**Get the workspace to compile for `aarch64-linux-android` without running.**
This surfaces all the real linker and dependency errors before writing any gameplay code:
```bash
# Install toolchain
rustup target add aarch64-linux-android
cargo install --locked cargo-mobile2
# In the workspace root:
cargo mobile init # generates android/ directory
# Attempt a library build targeting Android
cargo build -p solitaire_app --target aarch64-linux-android 2>&1 | head -60
```
The first build will fail on `keyring` (no Android backend) and likely on `dirs`. Fixing those two in `solitaire_data` — gate `keyring` behind `cfg(not(target_os = "android"))` and stub the data directory — will probably get the workspace to a clean compile. From there, the path to a running APK is incremental.
Do not attempt to resolve audio or touch UX until the build compiles cleanly. Compile errors are the only true blockers; the rest are feature gaps.
@@ -0,0 +1,172 @@
# Phase 3 — Bevy Rendering & Interaction
> Status: In progress (started 2026-04-23)
> Crate: `solitaire_engine`
> Depends on: `solitaire_core` (complete), `bevy = 0.15` (includes `bevy::ui`), `kira = 0.9` (audio — Phase 3F+)
---
## Scope
Make the game playable with a graphical interface. This phase takes `solitaire_engine` from an empty stub to a full Bevy rendering + input layer wired to `solitaire_core::GameState`.
Out of scope (later phases):
- Persistence (`StatsSnapshot`, file I/O) — Phase 4
- Achievements toast content — Phase 5
- Audio — Phase 7
- Sync — Phase 8
---
## Sub-phases
### 3A — Plumbing & event wiring
**Modules under `solitaire_engine/src/`:**
- `lib.rs` — re-exports plugins, types
- `resources.rs`
- `GameStateResource(pub GameState)` — wraps `solitaire_core::GameState` directly (no `solitaire_data` layer yet)
- `DragState { cards: Vec<u32>, origin_pile: PileType, cursor_offset: Vec2, origin_z: f32 }` (starts empty)
- `SyncStatusResource(pub SyncStatus)` where `SyncStatus` is `Idle|Syncing|LastSynced(DateTime<Utc>)|Error(String)`
- `events.rs`
- `MoveRequestEvent { from: PileType, to: PileType, count: usize }`
- `DrawRequestEvent`
- `UndoRequestEvent`
- `NewGameRequestEvent { seed: Option<u64> }`
- `StateChangedEvent`
- `GameWonEvent { score: i32, time_seconds: u64 }`
- `CardFlippedEvent(pub u32)`
- `AchievementUnlockedEvent(pub AchievementRecord)` — placeholder, unused until Phase 5
- `game_plugin.rs``GamePlugin`:
- On `Startup`: init `GameStateResource::new(system_time_seed, DrawMode::DrawOne)`
- Systems: `handle_draw`, `handle_move`, `handle_undo`, `handle_new_game`
- Each fires `StateChangedEvent` on success; `GameWonEvent` when `check_win()` flips to true
- Errors: log via `tracing`, do not panic
- Register in [solitaire_app/src/main.rs](../../../solitaire_app/src/main.rs)
**Tests:** event-routing unit tests that drive `GamePlugin` in a headless `App::new()` and verify resource mutations.
**Exit:** `cargo test --workspace` green, `cargo clippy --workspace -- -D warnings` clean. Running the app still shows a blank window (no rendering yet), but pressing nothing crashes anything.
Commit: `feat(engine): add resources, events, and GamePlugin event routing`
---
### 3B — Layout + TablePlugin
**Modules:**
- `layout.rs` — pure function `compute_layout(window: Vec2) -> Layout`
- `Layout { card_size: Vec2, pile_positions: HashMap<PileType, Vec2> }`
- card_width = window.x / 9.0
- card_height = card_width * 1.4
- Row 1: stock, waste, [gap], 4 foundations
- Row 2: 7 tableau columns below
- `LayoutResource(pub Layout)` — a Bevy resource
- `table_plugin.rs``TablePlugin`:
- Spawns background rectangle (dark green `#0f5132`)
- Spawns 13 `PileMarker` sprite entities for empty-pile placeholders
- System `on_window_resized`: recompute `LayoutResource`, reposition pile markers
**Tests:** `compute_layout` at 800×600, 1280×800, 1920×1080 — all 13 piles within bounds, non-overlapping.
**Exit:** Window shows a green table with 13 translucent pile outlines that resize with the window.
Commit: `feat(engine): add layout, LayoutResource, and TablePlugin`
---
### 3C — CardPlugin rendering (procedural)
**Decision:** Phase 3 uses procedural cards (rounded white rectangle + rank/suit text). Real PNG assets can be slotted in later by replacing the sprite setup; API shape stays stable.
**Modules:**
- `card_plugin.rs``CardPlugin`:
- Component `CardEntity { card_id: u32 }`
- `StateChangedEvent` handler: sync entities with `GameStateResource` — spawn missing, despawn removed, reposition all
- Position: `LayoutResource.pile_positions[pile] + Vec3::Z * stack_index`
- Face-up: white rect + text of rank+suit glyph (red for hearts/diamonds, black for clubs/spades)
- Face-down: blue rect with a subtle pattern overlay
- No assets loaded — text uses Bevy's default font (or shipped system font if needed)
**Exit:** A freshly dealt game renders — stock (24 cards face-down), 7 tableau columns in standard 1/2/3/.../7 face-down + 1 face-up, empty foundations.
Commit: `feat(engine): add CardPlugin with procedural card rendering`
---
### 3D — Keyboard input & click-to-draw
**Modules:**
- `input_plugin.rs``InputPlugin`:
- Keyboard system: `KeyCode::KeyU``UndoRequestEvent`, `KeyN``NewGameRequestEvent{seed: None}`, `KeyD``DrawRequestEvent`, `Escape` → pause-stub event
- Mouse system: on left-click, if cursor over stock pile → `DrawRequestEvent`
**Exit:** Pressing D cycles stock↔waste on-screen; N deals a new game; U undoes.
Commit: `feat(engine): add InputPlugin with keyboard and stock-click`
---
### 3E — Drag & drop
**Modules:**
- Extend `input_plugin.rs` with drag systems:
- `start_drag`: on left mouse-down, ray-hit the top card (or run of face-up cards) of a pile; populate `DragState`; elevate z
- `follow_cursor`: while `DragState.cards` non-empty, move those entities to cursor position + per-card stack offset
- `end_drag`: on mouse-up, determine target pile; early-validate with `can_place_on_tableau` / `can_place_on_foundation`; fire `MoveRequestEvent` (backend also validates)
- On `MoveError` via `StateChangedEvent` non-emission: snap cards back with a short lerp (uses `CardAnim` from 3F)
- Multi-card tableau drag: grabbing card N pulls N..=top if all face-up
**Exit:** Full game playable with mouse. `GameWonEvent` fires on a win. No animations yet on invalid drop (just snap back instantly in 3E, smooth in 3F).
Commit: `feat(engine): add drag-and-drop input with multi-card tableau support`
---
### 3F — AnimationPlugin (polish)
**Modules:**
- `animation_plugin.rs``AnimationPlugin`:
- Component `CardAnim { start: Vec3, target: Vec3, elapsed: f32, duration: f32 }` — linear lerp 0.15s for moves
- Flip: `CardFlip { elapsed: f32, duration: f32, flips_to_face_up: bool }` — scale-X 1→0→1 over 0.2s, toggle `face_up` at midpoint, fire `CardFlippedEvent`
- Win cascade: on `GameWonEvent`, iterate foundation cards and schedule `CardAnim` to random off-screen targets with staggered 0.05s starts
- Toast component scaffold: bevy_ui `Node`/`Text` overlay, wired to `AchievementUnlockedEvent` (no content yet)
**Exit:** Valid moves animate smoothly; flipping a tableau card shows a flip; winning plays a cascade.
Commit: `feat(engine): add AnimationPlugin with slide, flip, and win cascade`
---
## Cross-cutting rules
- `solitaire_core` and `solitaire_sync` gain NO new dependencies.
- No `unwrap()` / `panic!()` in new Bevy systems — log errors via `tracing::warn!` and continue.
- `cargo test --workspace` and `cargo clippy --workspace -- -D warnings` green after EVERY sub-phase.
- Every commit follows `type(scope): description` convention.
- One `Plugin` per responsibility; cross-system communication is Events only.
---
## Open questions resolved
- **Procedural vs. sourced card art**: procedural for Phase 3.
- **`GameStateResource` layer**: wraps `solitaire_core::GameState` directly.
- **Phases 48 plugins** (Audio/UI/Achievement/Sync): not in Phase 3.
- **New-game seed**: system time when `None`, explicit when `Some(u64)`.
- **Commit cadence**: one per sub-phase.
---
## Risks
- Bevy 0.15 API drift from older tutorials — verify each API call as written.
- Procedural card text depends on Bevy's default font; if rendering is unreadable, embed a `.ttf` via `include_bytes!()` as a follow-up (still Phase 3, not 3F).
- `kira` audio API is async-friendly but requires careful thread management — initialise the `AudioManager` once at startup and store it in a Bevy `NonSend` resource.
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More