Compare commits

..

66 Commits

Author SHA1 Message Date
funman300 534870a68a docs: expand SESSION_HANDOFF resume prompt with release-readiness scope
CI / Test & Lint (push) Failing after 15s
CI / Release Build (push) Has been skipped
Replaces the 3-line resume prompt with a senior-Rust-developer framing
plus seven concrete next-direction tracks (A–G) covering the Home modal
decision, window/release polish, sound, sync, achievements, release
backlog, and UI/UX professional polish. Each track names specific
files/tokens so the next session can act on a chosen direction without
re-discovery.
2026-04-30 05:00:41 +00:00
funman300 0066ca6205 docs: mark UX overhaul Phase 3 complete in SESSION_HANDOFF
CI / Test & Lint (push) Failing after 30s
CI / Release Build (push) Has been skipped
All 10 steps landed; commit list, smoke-test checklist, and open
follow-ups updated for the next session.
2026-04-30 04:48:33 +00:00
funman300 54e024c1b0 chore(engine): final literal-to-token sweep
Migrates the last remaining colour, spacing, font-size, and z-index
literals in animation_plugin (toasts), hud_plugin (action bar +
Modes/Menu popovers), and win_summary_plugin (full win modal restyle)
onto the ui_theme token system established in step 1. Win summary now
uses SCRIM/BG_ELEVATED/ACCENT_PRIMARY/STATE_* with a yellow Play Again
button. Sprite-tinted gameplay art (cards, felt, drop-zone hints,
pile markers) and sub-rung pixel sizes (1px borders, fixed cell
widths) are intentionally left untouched.

cargo build / clippy --workspace -- -D warnings / test --workspace
all green (819 passed, 0 failed, 8 ignored).
2026-04-30 04:47:20 +00:00
funman300 3a01318fbd feat(engine): upgrade animations — curves, scoped settle, deal jitter, cascade rotation
Slide animations now interpolate through MotionCurve::SmoothSnap via
sample_curve() at the call site (no struct field added). Slide and
cascade durations route through ui_theme::scaled_duration with
MOTION_SLIDE_SECS / MOTION_CASCADE_STAGGER_SECS / MOTION_CASCADE_SLIDE_SECS.

Settle bounce in feedback_anim_plugin scoped to MoveRequestEvent and
DrawRequestEvent receivers — only the top `count` cards of the
destination pile (or top of waste) bounce; undo and other state
changes no longer trigger a global all-tops settle.

Deal stagger gains a deterministic ±10% jitter via DefaultHasher on
card_id (no rand dep). Per-card stagger = base * (1.0 + jitter).

Win cascade switched from CardAnim to CardAnimation with
MotionCurve::Expressive and a deterministic ±15° per-card Z-rotation
via Fibonacci hash. Win screen shake routes through
MOTION_WIN_SHAKE_SECS / MOTION_WIN_SHAKE_AMPLITUDE; ScreenShakeResource
gained a `total` field so decay computes correctly under Fast / Instant.

cargo build / clippy --workspace -- -D warnings / test --workspace
all green (819 passed, 0 failed, 8 ignored).
2026-04-30 04:38:59 +00:00
funman300 79d391724e chore(data): derive Copy on AnimSpeed
AnimSpeed is a fieldless enum; adding Copy lets `scaled_duration`
and other helpers take it by value through `&AnimSpeed` deref
without requiring a `.clone()` at every call site. Prerequisite
for the upcoming animation token-routing work.
2026-04-30 04:26:57 +00:00
funman300 ba019c0ba7 feat(engine): convert SettingsPanel to modal scaffold + Done button
Replace the bespoke side-panel with the ui_modal scaffold. Layout
collapses into four sections: Audio (SFX / Music volume), Gameplay
(Draw Mode / Anim Speed), Cosmetic (Theme / Color-blind / Card Back
/ Background), and Sync (status + manual Sync Now).

Body lives in a scrollable child of the modal card with
max_height: Vh(60.0) so tall content stays reachable on short
windows. Done is a primary button outside the scroll so it's always
one click away regardless of scroll offset.

All colours, spacing, typography, and z-index from ui_theme tokens.
Two file-local sub-rung sizes (SWATCH_PX = 40, ICON_BUTTON_PX = 28)
remain as documented literals — they're smaller than SPACE_2 (8 px)
which is the smallest rung.

Existing systems (handle_settings_buttons, update_*_text,
scroll_settings_panel, persistence) untouched; the SettingsPanel /
SettingsPanelScrollable / SettingsScrollNode markers and every
button marker carry over so all existing tests and click handlers
keep working.

cargo build / cargo clippy --workspace -- -D warnings / cargo test
-p solitaire_engine all green (444 passed, 0 failed).
2026-04-30 04:13:20 +00:00
funman300 18d7c121a3 feat(engine): convert OnboardingPlugin to 3-slide modal flow
Replace the single-screen first-run banner with a 3-slide flow built
on the ui_modal scaffold:

  1. Welcome
  2. How to play (drag-and-drop / double-click / right-click hints)
  3. Keyboard shortcuts (8 rows mirroring help_plugin's canonical list)

Navigation: primary Next button (advances; final slide reads
"Start playing" and writes first_run_complete), secondary Back button
(slide >0), tertiary Skip on slide 0. Arrow / Enter / Esc keep
working as accelerators.

OnboardingSlideIndex resource persists across despawn/respawn so the
rebuild system always knows which slide to show next.

All colours, spacing, typography come from ui_theme tokens; no
literals in the new code.

cargo build / cargo clippy --workspace -- -D warnings / cargo test
--workspace all green (813 passed, 0 failed, 8 ignored).
2026-04-30 03:24:32 +00:00
funman300 cb93bd9265 fix(engine): pin modals via GlobalZIndex and surface forfeit-no-op toast
CI / Test & Lint (push) Failing after 28s
CI / Release Build (push) Has been skipped
Two fixes the smoke test surfaced:

1. The forfeit-confirm modal at `Z_PAUSE_DIALOG` (225) was invisible
   behind the pause card at `Z_PAUSE` (220). In Bevy 0.18, root-level
   UI nodes don't reliably sort across stacking contexts via plain
   `ZIndex` alone, so `spawn_modal` now adds `GlobalZIndex(z_panel)`
   alongside the existing `ZIndex(z_panel)`. Every overlay built on
   `ui_modal` (pause, forfeit-confirm, confirm-new-game, help, home,
   leaderboard, profile, achievements, stats, game-over) inherits the
   fix.

2. `handle_forfeit_request` no longer silently drops the request when
   `move_count == 0` — pressing G or clicking the pause modal's
   Forfeit button on a freshly-dealt game now opens the confirm modal,
   and the only short-circuit is "game is already won", which now
   fires an `InfoToastEvent` ("No game to forfeit") so the player
   gets feedback. The `move_count > 0` half of the gate was the
   reason a fresh-deal G press appeared to do nothing.

The G-key gate in `handle_keyboard_forfeit` is simplified to just
"not paused"; the rest of the forfeit-eligibility check moves into
`handle_forfeit_request` so it can surface the toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 02:35:52 +00:00
funman300 6723416a55 feat(engine): convert PauseScreen to modal + add ForfeitConfirmScreen
CI / Test & Lint (push) Failing after 25s
CI / Release Build (push) Has been skipped
Pause overlay drops its bespoke full-screen layout and is rebuilt on
the standard `ui_modal` scaffold: uniform scrim, centred card, real
Resume (Primary, Esc) and Forfeit (Tertiary, G) action buttons. The
Draw Mode row stays inline in the body so the existing toggle still
fires `SettingsChangedEvent`.

The G-key double-press toast countdown is replaced with a real
modal: `G` (or clicking Forfeit on Pause) fires the new
`ForfeitRequestEvent`, which `PausePlugin` answers by spawning
`ForfeitConfirmScreen` at `Z_PAUSE_DIALOG` (above pause). The modal
exposes Cancel + "Yes, forfeit" buttons plus Y/Enter/N/Esc
accelerators; confirmation despawns both modals, clears
`PausedResource`, and fires `ForfeitEvent` for `StatsPlugin`.

`toggle_pause` now early-returns when a forfeit modal is visible (and
runs `.before(handle_forfeit_keyboard)`) so an Esc that closes the
forfeit modal doesn't also re-open pause in the same frame.

The legacy `forfeit_countdown` field, `FORFEIT_CONFIRM_WINDOW`
constant, and the six pure-function countdown tests are removed; new
tests cover the modal-spawn / confirm / cancel paths and the active-
game predicate that still gates the G hotkey.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 02:15:47 +00:00
funman300 afb08799e8 docs: add SESSION_HANDOFF.md mid-overhaul checkpoint
CI / Test & Lint (push) Failing after 23s
CI / Release Build (push) Has been skipped
Pause point in the UX overhaul Phase 3. Documents:
- The 11 commits landed this session (foundation + HUD + 8 overlay
  conversions).
- Test status (797 / 797 pass, clippy clean).
- The 5 steps still pending (Pause + Forfeit, Settings, Onboarding,
  animation upgrades, final literal sweep) and what each touches.
- A smoke-test checklist for the user to run on the current state.
- A copy-pasteable kickoff prompt for the next session.
- An open question about whether to keep, repurpose, or drop the
  Home overlay (now that the action bar + Help cover its surface).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:46:35 +00:00
funman300 3b619b8950 feat(engine): convert HomeScreen to modal scaffold + Done button
Phase 3 step 5f of the UX overhaul. Closes the per-overlay
conversion phase: every read-only overlay (Help, Stats, Achievements,
Profile, Leaderboard, and now Home) sits inside the same ui_modal
scaffold, picks colours from ui_theme, and dismisses via a real
"Done" primary button alongside its keyboard accelerator.

Home modal:
- Header: "Solitaire Quest"
- Mode badge: "Current mode: <mode>" in ACCENT_PRIMARY (yellow)
- Two sections (Game Controls / Screens), each rendering keyboard
  shortcuts as kbd-chip rows — the same pattern Help uses, so the
  two reference screens read consistently. Section titles use
  STATE_INFO.
- "L" leaderboard row added so the screens list is now complete.
- Actions: primary Done button with the M hotkey chip.
- handle_home_close_button is the click counterpart to M.

Home overlap with Help is intentional during the overhaul — both
exist as hotkey references for now. A future commit can repurpose
Home as a true mode launcher (the proposal called for this) or
remove it entirely if Help is sufficient. Either path is easier with
both screens already in the consistent shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:44:33 +00:00
funman300 37681cf33e feat(engine): convert LeaderboardScreen to modal scaffold + Done button
Phase 3 step 5e of the UX overhaul. Wraps the leaderboard list inside
the standard ui_modal scaffold; converts the Opt In / Opt Out buttons
to use spawn_modal_button (so they pick up the shared hover / press
paint system); replaces "Press L to close" prose with a primary Done
button.

Changes:
- spawn_leaderboard_screen now goes through spawn_modal(LeaderboardScreen,
  Z_MODAL_PANEL, ...). The bespoke 0.82-alpha scrim and hand-rolled
  card surface are gone — same visual contract as every other overlay.
- Opt In becomes a Secondary modal button; Opt Out becomes Tertiary.
  Both fire the same fetch tasks they did before.
- Header / data cells switch to ui_theme tokens. The top-3 podium
  effect now uses ACCENT_PRIMARY (yellow) for #1 and TEXT_PRIMARY for
  #2/#3 instead of metallic-coloured srgb literals; #4+ use
  TEXT_SECONDARY.
- Header-cell and data-cell helpers now take a `&TextFont` so all
  three sizes (HEADLINE / BODY_LG / BODY / CAPTION) come from the
  shared scale instead of inline 13px / 15px sizes.
- "Fetching\u{2026}" loading state uses STATE_INFO; empty-state copy
  uses TEXT_SECONDARY.
- handle_leaderboard_close_button is the click counterpart to L; it
  also sets ClosedThisFrame so update_leaderboard_panel doesn't
  immediately respawn the modal when a fetch completes in the same
  frame.

The sort-by-score code is replaced with `sort_by_key(Reverse(...))`
to satisfy clippy's unnecessary_sort_by lint that surfaced once the
file was otherwise warning-free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:40:59 +00:00
funman300 99064ce808 feat(engine): convert ProfileScreen to modal scaffold + Done button
Phase 3 step 5d of the UX overhaul. Wraps the profile sections (Sync,
Progression, Achievements, Statistics Summary) in the standard modal
scaffold; replaces every inline colour with a ui_theme token; adds an
explicit "Sync" section header so the four sections all read in the
same shape; replaces the "Press P to close" prose hint with a primary
Done button.

The previous bare full-screen scrim + inline-text approach was on the
audit's "feels like a debug panel" list — same fix as Stats /
Achievements / Help.

Section headers now use STATE_INFO at TYPE_BODY_LG, body lines use
TEXT_PRIMARY at TYPE_BODY, secondary lines (sync status, "no
achievements yet") use TEXT_SECONDARY. The achievement-count line
adopts ACCENT_PRIMARY (yellow) and unlocked-achievement entries use
STATE_SUCCESS (green) — same colour vocabulary the Achievements
overlay uses.

The unused `spawn_spacer` helper now takes a `Val` so callers can
pass spacing-token constants directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:36:33 +00:00
funman300 de4dba6f98 feat(engine): convert AchievementsScreen to modal scaffold + Done button
Phase 3 step 5c of the UX overhaul. Wraps the achievements list in
the standard ui_modal scaffold, recolours every line via tokens, and
replaces the "Press A to close" caption with a primary Done button.

The achievements list itself keeps its previous shape (unlocked
first then alphabetical, secret achievements hidden until unlocked,
each row showing name + description + reward + unlock date). The
visual changes:

- Headline now comes from spawn_modal_header (TYPE_HEADLINE,
  TEXT_PRIMARY) — was bespoke 26px white.
- Unlocked names use ACCENT_PRIMARY (yellow); descriptions in
  TEXT_PRIMARY at TYPE_BODY.
- Locked names and descriptions use TEXT_DISABLED so they read as
  "future content" without disappearing.
- Reward lines use STATE_SUCCESS (green) at TYPE_CAPTION.
- Unlock dates use TEXT_SECONDARY at TYPE_CAPTION.
- A subtle BORDER_SUBTLE separator follows each row instead of one
  big separator under the header — easier to scan a long list.
- The "✓" / "○" status glyphs stay; their colours come from the
  per-state tokens.

handle_achievements_close_button is the click counterpart to the A
key. font_res threaded through toggle_achievements_screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:32:45 +00:00
funman300 75fc3aa3d6 feat(engine): convert StatsScreen to modal scaffold + Done button
Phase 3 step 5b of the UX overhaul. Wraps the existing 8-cell stats
grid + progression / weekly-goals / time-attack sections inside the
standard modal scaffold. The cell layout (the audit's pick for
"best layout in the codebase") is preserved.

Changes:
- spawn_stats_screen now calls spawn_modal(StatsScreen, ...) and
  populates the card with the same content as before, retoned to
  ui_theme: stat values are TYPE_HEADLINE in ACCENT_PRIMARY (yellow
  numbers pop against the midnight-purple card), labels are TYPE_BODY
  in TEXT_SECONDARY.
- Stat cells lose their 6%-alpha-white fill (clashed with the new
  card surface) and gain a BORDER_SUBTLE outline at RADIUS_SM
  instead — same visual purpose, fits the new palette.
- Section headers ("Progression", "Weekly Goals") use STATE_INFO and
  TEXT_SECONDARY respectively at TYPE_BODY_LG.
- Time Attack callout uses STATE_WARNING.
- "Press S to close" prose hint replaced by a primary "Done" button
  carrying its "S" hotkey chip.

A new handle_stats_close_button system mirrors the keyboard `S`
toggle for clicks. font_res threaded through toggle_stats_screen so
the modal scaffold can pick up FiraMono.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:12:55 +00:00
funman300 deb034c5fb feat(engine): convert HelpScreen to real-button modal with kbd-chip rows
Phase 3 step 5a of the UX overhaul. Replaces the old monospace
text-dump (a flat vertical column of "  D            Draw from
stock"-style lines) with a proper modal layout: section titles,
two-column rows where each shortcut renders inside a small
border-outlined chip alongside its description.

Modal contents:
- Header: "Controls"
- Body: three sections (Gameplay / New Game / Overlays), each with a
  section title in TEXT_SECONDARY plus a row per shortcut.
- Each row: a 64 px-min-width chip (caption font, border, radius-sm)
  carrying the key name, then the description in TEXT_PRIMARY at
  TYPE_BODY.
- Actions: a primary "Close" button (hotkey hint "F1").

CONTROL_SECTIONS is a static const-data table of `ControlRow`
records grouped into `ControlSection`s — easier to maintain than the
prior `Vec<String>` of free-form text and easier to extend.

handle_help_close_button is the click counterpart to F1; it
despawns the modal when the player clicks Close.

The audit identified the prior layout as the worst of the
"feels like a 2010 monospace debug dump" overlays. This
restructure is the largest visual upgrade so far in the overhaul.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:06:00 +00:00
funman300 242b5fef21 feat(engine): convert GameOverScreen to real-button modal
Phase 3 step 4b of the UX overhaul. Same shape as the Confirm modal
conversion (3f922ed): replace plain "Press N for new game" /
"Press G to forfeit" text hints with real Button entities, hover
and press feedback included.

The audit flagged the Game Over overlay as the second instance of
the "feels like a debug panel" problem. Players had to know the
hotkeys to escape the screen — there was no clickable affordance.

Modal contents:
- Header: "No more moves available"
- Body:   "Final score: {N}" (TYPE_BODY_LG, TEXT_PRIMARY)
- Actions:
    Undo (Secondary, hotkey "U")        — left
    New Game (Primary yellow, hotkey "N") — right

The G/forfeit hint is dropped from the modal because:
1. Forfeit is handled globally by `input_plugin::handle_forfeit`
   (which works whether the modal is up or not).
2. The proposal calls for replacing the toast-countdown forfeit
   flow with its own modal in step 4c (next commit).

A new `handle_game_over_button_input` system mirrors the keyboard
handler for clicks. Existing N/Esc and U accelerators continue to
work via the original `handle_game_over_input`.

The `game_over_screen_text_content` test is updated to assert the
new button-label / hotkey-chip strings instead of the prior prose
hints. All 797 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:01:39 +00:00
funman300 3f922ede28 feat(engine): convert ConfirmNewGameScreen to real-button modal
CI / Test & Lint (push) Failing after 21s
CI / Release Build (push) Has been skipped
Phase 3 step 4a of the UX overhaul. Closes the player's #2 smoke-test
complaint head-on: the abandon-current-game prompt previously rendered
"Yes (Y)" and "No (N)" as plain `Text` entities — not real `Button`s.
Clicks did nothing, hover/press feedback was absent, and the only path
through the modal was the keyboard.

Replace the bespoke 60-line spawn function with a 30-line call to the
ui_modal primitive:

- spawn_modal(ConfirmNewGameScreen, Z_MODAL_PANEL, ...) — uniform
  scrim + centred card with header / body / actions slots.
- Header: "Abandon current game?" (TYPE_HEADLINE, TEXT_PRIMARY).
- Body: "Your progress will be lost." (TYPE_BODY_LG, TEXT_SECONDARY).
- Actions row:
    Cancel (Secondary variant, hotkey "Esc") — left
    Yes, abandon (Primary yellow CTA, hotkey "Y") — right

The ConfirmNewGameScreen marker rides on the scrim entity per
ui_modal's contract; OriginalNewGameRequest is attached to the same
entity after spawn so handle_confirm_input / handle_confirm_button_input
can read it.

A new handle_confirm_button_input system mirrors the keyboard handler
for clicks: it queries `Changed<Interaction>` on `ConfirmYesButton` /
`ConfirmNoButton` and dispatches the same despawn + new-game-fire
logic. Keyboard accelerators (Y/Enter, N/Esc) still work; both paths
reach the same code through the existing `confirmed: true` flag on
NewGameRequestEvent (62cd1cf).

UiModalPlugin's paint_modal_buttons system (8da62bd) handles
hover/press recolouring automatically; no per-modal paint logic
needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:51:28 +00:00
funman300 8da62bd05f feat(engine): add ui_modal primitive (scaffold + button variants)
Phase 3 step 3 of the UX overhaul. Adds a reusable modal helper that
the next 6 commits use to convert each overlay screen. The audit found
11 overlays using 3 different visual styles with scrim alpha drift
between 0.60 and 0.92; this primitive collapses all of that into one
consistent shape.

API surface:
- spawn_modal(commands, plugin_marker, z, build_card)  — full-screen
  scrim (uniform SCRIM token) + centred card (BG_ELEVATED, RADIUS_LG,
  BORDER_STRONG outline, max-width 720, min-width 360, padding
  SPACE_5).  Returns the scrim entity for one-call despawn.
- spawn_modal_header(parent, title, font_res)          — TYPE_HEADLINE
  + TEXT_PRIMARY, the canonical overlay heading.
- spawn_modal_body_text(parent, text, color, font_res) — TYPE_BODY_LG
  paragraph; pass TEXT_PRIMARY or TEXT_SECONDARY.
- spawn_modal_actions(parent, build_buttons)           — flex-row
  justify-end with margin-top.
- spawn_modal_button(parent, marker, label, hotkey,
                     variant, font_res)                — real Button
  entity with optional TYPE_CAPTION hotkey-hint chip.

ButtonVariant enum drives colour:
  Primary    idle ACCENT_PRIMARY      hover ACCENT_PRIMARY_HOVER
             pressed ACCENT_SECONDARY (yellow → pink press flash)
  Secondary  idle BG_ELEVATED_HI      hover BG_ELEVATED_TOP
             pressed BG_ELEVATED
  Tertiary   idle BG_ELEVATED         hover BG_ELEVATED_HI
             pressed BG_ELEVATED_PRESSED

A new BG_ELEVATED_TOP token plus ACCENT_PRIMARY_HOVER cover the new
hover/press combinations cleanly.

UiModalPlugin registers paint_modal_buttons so every ModalButton gets
hover and press feedback automatically — overlay plugins don't add
their own paint systems. Plugin registered in solitaire_app.

A self-test asserts each variant's idle / hover / pressed colours are
all distinct; another verifies the plugin builds under MinimalPlugins.

This commit is purely additive — no overlay calls the new helpers
yet. The next commits convert each overlay to use them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:43:14 +00:00
funman300 73cad7e205 feat(engine): restructure HUD into 4-tier layout, adopt design tokens
Phase 3 step 2 of the UX overhaul. Closes the player's #1 complaint
("HUD too cluttered") by regrouping the 10 readouts that previously
sat in a single dense horizontal row.

HUD structure (top → bottom):
- Tier 1 (always on)        Score · Moves · Timer
                            Score uses TYPE_HEADLINE so it's the
                            visual protagonist; Moves/Timer use
                            TYPE_BODY_LG with TEXT_SECONDARY tone.
- Tier 2 (mode context)     Mode · Daily-challenge constraint ·
                            Draw-cycle indicator. Each cell is
                            empty when not relevant — the row
                            collapses visually if all are empty.
- Tier 3 (penalty / bonus)  Undos · Recycles · Auto-complete badge.
                            Both penalty counters now share
                            STATE_WARNING — the audit found Undos
                            were amber but Recycles were white,
                            making the "you took a penalty" signal
                            inconsistent.
- Tier 4 (selection chip)   keyboard-driven pile selector.

Action bar polish:
- Each button gains a TYPE_CAPTION hotkey-hint chip (Undo · U,
  Pause · Esc, Help · F1, New Game · N). Menu and Modes get no
  chip because each row in their popovers carries its own hotkey.
- Buttons recoloured to BG_ELEVATED / BG_ELEVATED_HI /
  BG_ELEVATED_PRESSED — the bright-blue palette stood out from
  the rest of the (still-to-come) midnight purple chrome.
- Buttons gain a BORDER_SUBTLE outline so the boundary reads even
  when hovered over the felt.

Other migrations in this commit:
- Popover panels (Menu, Modes) now use BG_ELEVATED instead of an
  ad-hoc dark grey.
- challenge_time_color now returns STATE_DANGER / STATE_WARNING /
  STATE_INFO tokens instead of literal hexes; tests updated.
- The Undos in-place colour toggle uses TEXT_PRIMARY / STATE_WARNING.

The four `ui_theme` self-tests plus all existing 791 tests stay
green (795 total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:30:42 +00:00
funman300 e14852c093 feat(engine): add ui_theme.rs design-token module
Phase 3 step 1 of the UX overhaul. Centralises every UI design token —
colours, typography, spacing, border-radius, z-index, and motion
durations — so subsequent overhaul commits read from one source of
truth instead of scattering hex codes and magic numbers across plugin
files.

The audit (2026-04-30) found:
- 40+ hardcoded Color::srgb literals across UI surfaces.
- 12 distinct font sizes (14/15/16/17/18/22/26/28/30/32/40/48 px)
  with no scale.
- 8+ z-index magic numbers across overlay plugins (200, 210, 220,
  230, 250, 300, 400) with no documented hierarchy.
- Motion durations only partially honouring AnimSpeed — slide and
  cascade did, but toast / shake / settle / deal were hardcoded.

ui_theme.rs collapses these into:
- Midnight Purple base (BG_BASE / BG_ELEVATED / BG_ELEVATED_HI /
  BG_ELEVATED_PRESSED) + Balatro-yellow ACCENT_PRIMARY + warm
  magenta ACCENT_SECONDARY + state colours (success/warning/danger/
  info) + text tiers (primary/secondary/disabled) + a uniform SCRIM.
- 5-rung typography scale (display 40 / headline 26 / body-lg 18 /
  body 14 / caption 11).
- 4-multiple spacing scale (4/8/12/16/24/32/48), with VAL_SPACE_*
  Val::Px convenience constants.
- 3 border-radius rungs (sm 4 / md 8 / lg 16).
- Documented monotonically-increasing z-index hierarchy enforced
  by a unit test.
- All MOTION_* duration constants funnelled through scaled_duration()
  so AnimSpeed (Normal/Fast/Instant) applies to every animation,
  not just slide and cascade.

This commit is purely additive — no call sites change yet.
Subsequent commits in the overhaul migrate plugins to the tokens
one region at a time (HUD restructure, modal primitive, then per-
overlay conversions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:20:19 +00:00
funman300 6240156fee feat(engine): add Menu dropdown for Stats/Achievements/Profile/Settings/Leaderboard
CI / Test & Lint (push) Failing after 20s
CI / Release Build (push) Has been skipped
Continues the UI-first pass. The five informational overlays were
each behind a single-key shortcut (S/A/P/O/L) with no visible UI
affordance. Add a "Menu ▾" button to the action bar that toggles a
popover with one row per overlay. Each row dispatches the same code
path the keyboard accelerator uses by writing a new
`Toggle*RequestEvent`:

- Stats        → ToggleStatsRequestEvent
- Achievements → ToggleAchievementsRequestEvent
- Profile      → ToggleProfileRequestEvent
- Settings     → ToggleSettingsRequestEvent
- Leaderboard  → ToggleLeaderboardRequestEvent

Each plugin's existing toggle handler now reads either its key or
the matching request event so the spawn / despawn / fetch logic stays
in the owning plugin (the popover never duplicates that behaviour).

Action bar order is now (left → right):
  Menu ▾   Undo   Pause   Help   Modes ▾   New Game

Menu sits on the far left because it's a navigation aggregator;
New Game stays on the far right as the most consequential action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:55:43 +00:00
funman300 1d9fb1884a feat(engine): add Modes dropdown with Classic/Daily/Zen/Challenge/Time Attack
Continues the UI-first pass. The five game modes were each behind a
keyboard shortcut (N/Z/X/T/C) with no visible UI affordance, three of
them additionally gated by an unlock level the player has to discover
themselves.

Add a "Modes ▾" button to the action bar that toggles a popover panel
beneath. Each row dispatches the same code path the keyboard
accelerator uses by writing a new `Start*RequestEvent` (or
`NewGameRequestEvent` for Classic):

- Classic        → NewGameRequestEvent::default()
- Daily Challenge → StartDailyChallengeRequestEvent
- Zen            → StartZenRequestEvent
- Challenge      → StartChallengeRequestEvent
- Time Attack    → StartTimeAttackRequestEvent

The existing keyboard handlers in input_plugin (Z), challenge_plugin
(X), time_attack_plugin (T), and daily_challenge_plugin (C) now read
either their key or the matching request event, so level gates,
TimeAttackResource setup, daily seed lookup, and toast feedback for
locked modes all stay in their owning plugins — the popover never
duplicates that logic.

The popover only lists modes available to the player: Classic always
shows, Daily Challenge shows when DailyChallengeResource is loaded,
and Zen/Challenge/Time Attack show once the player reaches level 5
(the existing CHALLENGE_UNLOCK_LEVEL).

Click handler despawns the popover after dispatch; clicking the
Modes button again toggles it shut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:49:40 +00:00
funman300 97f38085e3 feat(engine): add Undo, Pause, Help UI buttons in HUD action bar
Continues the UI-first pass started by the New Game button. Per the
design principle in CLAUDE.md / ARCHITECTURE.md §1, every player action
must be reachable from a visible UI control with the keyboard shortcut
as an optional accelerator. Refactor the single New Game button into a
flex-row "action bar" anchored top-right with four buttons: Undo,
Pause, Help, New Game (left → right; New Game rightmost as the most
consequential action).

Plumbing:
- New `PauseRequestEvent` and `HelpRequestEvent` in events.rs.
- pause_plugin::toggle_pause reads either Esc or PauseRequestEvent so
  the button and the keyboard accelerator drive the same code path
  (with the existing drag / game-over / selection guards).
- help_plugin::toggle_help_screen reads either F1 or HelpRequestEvent;
  also fix the stale module-doc claim that H toggles help (it's F1 —
  H is bound to hint cycle in input_plugin).
- hud_plugin now spawns four ActionButton-marked buttons via a
  ChildSpawnerCommands helper, with one click handler per button
  firing its respective request event. A single
  paint_action_buttons system covers hover/pressed colour for all of
  them via the shared ActionButton marker. The click handlers
  defensively re-register their request events so the plugin works in
  isolation under MinimalPlugins (tests). add_message is idempotent.
- ARCHITECTURE.md HudPlugin row updated to call out the action bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:38:54 +00:00
funman300 62cd1cf924 fix(engine): start new game when player confirms abandon-current-game modal
CI / Test & Lint (push) Failing after 19s
CI / Release Build (push) Has been skipped
Reported during 2026-04-29 smoke test: pressing Y on the
ConfirmNewGameScreen modal closed nothing and didn't start a new game.

Trace:
  Frame N: handle_confirm_input despawns the modal entity (deferred),
           writes NewGameRequestEvent.
  End of N: command flush — modal gone.
  Frame N+1: handle_new_game reads the event. needs_confirm is still
             true (game state unchanged). confirm_already_open is now
             false (modal flushed). Condition matches → spawn_confirm_
             dialog runs again, the modal reappears, and the new game
             never starts.

Add a `confirmed: bool` field to NewGameRequestEvent. handle_confirm_
input writes it as true on Y/Enter so handle_new_game's dialog-spawn
guard short-circuits and the existing despawn-and-start branch runs.
All other writers (button click, N hotkey, mode hotkeys, daily/
challenge/time-attack auto-deal, tests) stay at `confirmed: false`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:28:48 +00:00
funman300 b10e1a5a87 fix(engine): resize cards along with the rest of the layout
CI / Test & Lint (push) Failing after 24s
CI / Release Build (push) Has been skipped
The first resize-jitter fix (366fd6d) only snapped card transforms,
not the Sprite::custom_size. Cards stayed at the old size after a
window resize until the next StateChangedEvent (move, draw, undo,
new-game) refreshed them via sync_cards_on_change. Reported during
smoke testing: "the placeholder grey boxes change size but the cards
do not until I make an update to the window".

Replace the manual transform-only loop in snap_cards_on_window_resize
with a call to sync_cards(slide_secs = 0.0). update_card_entity
unconditionally inserts a fresh Sprite via card_sprite() with the
current layout.card_size, so cards now visibly resize. With
slide_secs=0 it also takes the snap branch (no CardAnim slide), so
the underlying jitter fix from 366fd6d is preserved.

apply_stock_empty_indicator is still called separately because
sync_cards doesn't touch the stock-empty "↺" label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:52:16 +00:00
funman300 366fd6d127 fix(engine): snap cards directly on window resize
CI / Test & Lint (push) Failing after 19s
CI / Release Build (push) Has been skipped
on_window_resized was firing StateChangedEvent on every WindowResized
event. That ran sync_cards_on_change → update_card_entity, which
inserts a CardAnim slide tween for every card whose target moves >1
unit. During a corner drag the resize fires every frame, retargeting
the slide each time from the cards' current mid-tween positions, so
cards never reach steady state — the visible "snap back and forth"
jitter reported during the 2026-04-29 smoke test.

Replace the StateChangedEvent emit with a direct snap path:

- Add LayoutSystem::UpdateOnResize SystemSet in layout.rs so cross-
  plugin ordering is explicit (Bevy's automatic conflict-based order
  only forces non-parallel execution, not a particular order).
- table_plugin::on_window_resized: drop the StateChangedEvent emit;
  mark the system in_set(LayoutSystem::UpdateOnResize). It already
  snaps backgrounds and pile markers directly, so this aligns cards
  with the same instant-snap policy.
- card_plugin: new snap_cards_on_window_resize system listens for
  WindowResized, runs .after(LayoutSystem::UpdateOnResize), writes
  fresh transforms via the existing card_positions() helper, and
  removes any in-flight CardAnim. It also reapplies the stock-empty
  indicator so the "↺" label's font_size (derived from
  layout.card_size.x) still rescales on resize.

Other StateChangedEvent listeners — start_settle_anim,
detect_auto_complete, clear_selection_on_state_change, check_no_moves,
reset_hint_cycle_on_state_change, clear_right_click_highlights — no
longer fire spuriously on resize. They should not fire on a layout
change anyway; that was a pre-existing minor bug masked by the
jitter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:44:08 +00:00
funman300 7a77c66f6d fix(engine): restore card to origin slot after rejected drop
When a drag was rejected, ShakeAnim was inserted on each dragged card
with origin_x = transform.translation.x — the drop-location X, not the
origin pile slot's X. tick_shake_anim restores translation.x to
origin_x at the end of the 0.3s shake, which fights the sync_cards
slide that StateChangedEvent triggers and pins the card at the drop
location. The visible symptom (reported during the 2026-04-29 smoke
test) was "the card returns to the slot beside the pile".

Compute the target X using the existing card_position() helper
against the origin pile and the card's stack_index, then save that as
ShakeAnim::origin_x. The shake now ends with the card at its correct
resting slot. Apply the same fix to both the mouse path (end_drag)
and the touch path (touch_end_drag), and update the existing Task #57
test to reflect the new contract (origin_x = origin slot X, not
drop-location X).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:29:20 +00:00
funman300 adece12cf1 feat(engine): add New Game UI button in HUD
CI / Test & Lint (push) Failing after 21s
CI / Release Build (push) Has been skipped
Per the UI-first design principle (CLAUDE.md, ARCHITECTURE.md §1),
every player action must be reachable from a visible UI control with
the keyboard shortcut as an optional accelerator. Add a top-right
"New Game" button that fires NewGameRequestEvent on click; the
existing ConfirmNewGameScreen modal in GamePlugin handles the abandon-
current-game confirmation flow when a game is already in progress.

- NewGameButton marker component, BackgroundColor-styled with idle /
  hover / pressed states.
- spawn_new_game_button startup system anchors the button at the top
  right of the window using absolute positioning.
- handle_new_game_button reads Changed<Interaction> on Pressed and
  writes NewGameRequestEvent::default(); paint_new_game_button
  applies the colour for the current state.

The N key still works as an accelerator. The legacy
NewGameConfirmEvent toast / countdown machinery in InputPlugin is
left in place for now — the button gives players a discoverable
path that bypasses the toast/modal collision reported during the
2026-04-29 smoke test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:06:12 +00:00
funman300 2cfbc32715 docs: add UI-first design principle
Every player-triggered action (new game, undo, draw, pause, open any
overlay, switch mode, etc.) must be reachable from a visible UI
control. Keyboard shortcuts are optional accelerators only — never
the sole entry point. New gameplay features ship with the UI control
alongside the system that backs it.

- ARCHITECTURE.md §1 (Design Principles): add UI-first bullet.
- ARCHITECTURE.md §5 plugin table: rename "Key" column to
  "Shortcut" and add a note that the column lists optional
  accelerators, not primary entry points.
- CLAUDE.md (Bevy Conventions): add a matching hard rule.

Surfaced during smoke testing: the N+N "press again to confirm"
toast collides with the ConfirmNewGameScreen modal because the
keyboard flow is the only entry point. Adding a visible New Game
button (next commit) makes the modal the single source of truth for
the confirm flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:59:38 +00:00
funman300 56b37fc653 fix(app): point AssetPlugin at workspace assets dir
CI / Test & Lint (push) Failing after 30s
CI / Release Build (push) Has been skipped
Bevy resolves AssetPlugin::file_path relative to the binary's
CARGO_MANIFEST_DIR (solitaire_app/), but the assets/ directory lives at
the workspace root. After the switch to AssetServer in fbe984c, every
card face, back, background, and font load failed with "Path not found:
.../solitaire_app/assets/..." and the renderer fell back to Text2d
rank+suit placeholders.

Override file_path to "../assets" so cargo run -p solitaire_app from
anywhere finds the real artwork at <workspace>/assets/. Shipping a
release binary will need to either set the override differently or copy
assets/ next to the binary; that is left for whoever ships first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:40:13 +00:00
funman300 3ffde038c5 docs: switch asset pipeline notes to AssetServer model
CI / Test & Lint (push) Failing after 23s
CI / Release Build (push) Has been skipped
Card faces, card backs, board backgrounds, and the UI font are loaded
via Bevy's AssetServer at startup (see commit fbe984c). The CLAUDE.md
hard rule still claimed cards/backgrounds were rendered procedurally
with no AssetServer, and ARCHITECTURE.md §14 / §20 still described
PNGs and TTFs as embedded via include_bytes!(). Update both docs:

- CLAUDE.md hard rule lists which assets ship in assets/ and notes the
  Option<Res<AssetServer>> fallback used under MinimalPlugins (tests).
- ARCHITECTURE.md §2/§3/§5/§14 rewritten to describe the AssetServer
  loaders for CardImageSet, BackgroundImageSet, and FontResource, and
  the Text2d / solid-colour fallbacks.
- ARCHITECTURE.md §20 decision log replaces the two reversed
  embed-via-include_bytes!() entries with a single entry covering the
  switch to AssetServer plus a note that audio remains embedded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:14:48 +00:00
funman300 ece2a55ffb chore(engine): re-export BackgroundImageSet from engine lib
The resource is defined in table_plugin and used by the rest of the
engine, but it was the only one of the prominent table_plugin types not
re-exported from lib.rs. Add it next to PileMarker / TableBackground so
downstream binaries can reference it without reaching into the module
path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:14:34 +00:00
funman300 abda354562 feat(engine): emit SyncCompleteEvent on pull resolve
ARCHITECTURE.md §5 lists SyncCompleteEvent(Result<SyncResponse, String>) as
a cross-system event, but it was never declared or fired. Add the message
to events.rs, register it in SyncPlugin, and emit it from poll_pull_result
on both the success path (carrying the merged payload + conflicts as
SyncResponse) and the failure path (carrying the user-facing error
message). UI/persistence systems can now react to sync completion without
polling SyncStatusResource.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:14:21 +00:00
funman300 fbe984cf64 feat(engine): switch asset loading to AssetServer with xCards artwork
CI / Test & Lint (push) Failing after 19s
CI / Release Build (push) Has been skipped
Replace compile-time include_bytes!() embedding for card faces, backgrounds,
and font with runtime AssetServer::load() calls. Swap in 52 xCards @2x PNGs
(LGPL-3.0) as card face assets and xCards bicycle_blue as back_0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:06:02 +00:00
funman300 efec6f22d5 fix(engine): resolve StatsUpdate system-set scheduling cycle
CI / Test & Lint (push) Failing after 16s
CI / Release Build (push) Has been skipped
update_stats_on_new_game and handle_forfeit ran .before(GameMutation)
while being inside StatsUpdate.  win_summary_plugin constrains
cache_win_data.before(StatsUpdate), which forces the entire StatsUpdate
set to run after GameMutation — creating an unsolvable cycle that panicked
Bevy 0.18's schedule solver at startup.

Only update_stats_on_win (post-GameMutation) belongs in StatsUpdate.
The pre-GameMutation systems still run before GameMutation but outside
the set, so external .before(StatsUpdate)/.after(StatsUpdate) constraints
remain consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 04:05:26 +00:00
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
162 changed files with 16256 additions and 4626 deletions
+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
+1
View File
@@ -5,3 +5,4 @@
.env .env
*.tmp *.tmp
data/ data/
.claude/
@@ -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"
}
+113 -188
View File
@@ -1,9 +1,9 @@
# Solitaire Quest — Architecture Document # Solitaire Quest — Architecture Document
> **Version:** 1.1 > **Version:** 1.1
> **Language:** Rust (Edition 2021) > **Language:** Rust (Edition 2024)
> **Engine:** Bevy (latest stable) > **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) 5. [Game Engine Architecture](#5-game-engine-architecture)
6. [Persistence & Sync Architecture](#6-persistence--sync-architecture) 6. [Persistence & Sync Architecture](#6-persistence--sync-architecture)
7. [Sync Server Architecture](#7-sync-server-architecture) 7. [Sync Server Architecture](#7-sync-server-architecture)
8. [Google Play Games Services (Android Future)](#8-google-play-games-services-android-future) 8. [Data Models](#8-data-models)
9. [Data Models](#9-data-models) 9. [API Reference](#9-api-reference)
10. [API Reference](#10-api-reference) 10. [Merge Strategy](#10-merge-strategy)
11. [Merge Strategy](#11-merge-strategy) 11. [Achievement System](#11-achievement-system)
12. [Achievement System](#12-achievement-system) 12. [Progression System](#12-progression-system)
13. [Progression System](#13-progression-system) 13. [Audio System](#13-audio-system)
14. [Audio System](#14-audio-system) 14. [Asset Pipeline](#14-asset-pipeline)
15. [Asset Pipeline](#15-asset-pipeline) 15. [Platform Targets](#15-platform-targets)
16. [Platform Targets](#16-platform-targets) 16. [Build & Development Guide](#16-build--development-guide)
17. [Build & Development Guide](#17-build--development-guide) 17. [Deployment Guide](#17-deployment-guide)
18. [Deployment Guide](#18-deployment-guide) 18. [Security Model](#18-security-model)
19. [Security Model](#19-security-model) 19. [Testing Strategy](#19-testing-strategy)
20. [Testing Strategy](#20-testing-strategy) 20. [Decision Log](#20-decision-log)
21. [Decision Log](#21-decision-log)
--- ---
## 1. Project Overview ## 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. 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.
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.
### Sync Backend by Platform ### 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 | | macOS | Self-hosted server | Full feature set |
| Windows | Self-hosted server | Full feature set | | Windows | Self-hosted server | Full feature set |
| Linux | 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 ### Design Principles
@@ -56,6 +51,7 @@ On Android (stretch goal), sync is enhanced with Google Play Games Services (GPG
- **No panics in game logic.** Every state transition returns `Result<_, MoveError>`. Panics are only acceptable in startup/configuration code. - **No panics in game logic.** Every state transition returns `Result<_, MoveError>`. Panics are only acceptable in startup/configuration code.
- **One language, one repo.** The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace. - **One language, one repo.** The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace.
- **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s. - **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s.
- **UI-first interaction.** Every player-triggered action — new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, etc. — must be reachable from a visible UI control. Keyboard shortcuts exist only as optional accelerators for power users; they are never the sole entry point. A player using only mouse or touch must be able to perform every action. New gameplay features ship with the UI control alongside the system that backs it.
--- ---
@@ -72,26 +68,25 @@ solitaire_quest/
├── Dockerfile # Multi-stage server build ├── Dockerfile # Multi-stage server build
├── docker-compose.yml # Server + Caddy reverse proxy ├── docker-compose.yml # Server + Caddy reverse proxy
├── assets/ # All runtime assets (loaded via Bevy AssetServer) ├── assets/ # Loaded at runtime via AssetServer (audio is embedded via include_bytes!())
│ ├── cards/ │ ├── cards/
│ │ ├── faces/ # Card face sprites (suit + rank) │ │ ├── faces/{RANK}{SUIT}.png # 52 card faces — xCards @2x artwork (LGPL-3.0)
│ │ └── backs/ # Card back designs (back_0.png … back_4.png) │ │ └── backs/back_0.png back_4.png # back_0 = xCards bicycle_blue; back_14 are generated patterns
│ ├── backgrounds/ # Table backgrounds (bg_0.png bg_4.png) │ ├── backgrounds/bg_0.png bg_4.png # generated textures
│ ├── fonts/ # .ttf font files │ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
│ └── audio/ │ └── audio/
│ ├── card_deal.ogg │ ├── card_deal.wav
│ ├── card_flip.ogg │ ├── card_flip.wav
│ ├── card_place.ogg │ ├── card_place.wav
│ ├── card_invalid.ogg │ ├── card_invalid.wav
│ ├── win_fanfare.ogg │ ├── win_fanfare.wav
│ └── ambient_loop.ogg │ └── ambient_loop.wav
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde ├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
├── solitaire_sync/ # Shared API types — used by client and server ├── solitaire_sync/ # Shared API types — used by client and server
├── solitaire_data/ # Persistence, sync client, settings ├── solitaire_data/ # Persistence, sync client, settings
├── solitaire_engine/ # Bevy ECS systems, components, plugins ├── solitaire_engine/ # Bevy ECS systems, components, plugins
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite) ├── 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 └── solitaire_app/ # Main binary entry point
``` ```
@@ -135,22 +130,7 @@ Owns:
- `SyncBackend` enum and backend selection - `SyncBackend` enum and backend selection
- Solitaire Server sync client (JWT auth, auto-refresh) - Solitaire Server sync client (JWT auth, auto-refresh)
- OS keychain integration (`keyring`) - OS keychain integration (`keyring`)
- `SyncProvider` trait — implemented by both `SolitaireServerClient` and `GpgsClient` (Android) - `SyncProvider` trait — implemented by `SolitaireServerClient`
### `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.
### `solitaire_engine` ### `solitaire_engine`
**Dependencies:** `bevy`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`. **Dependencies:** `bevy`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`.
@@ -165,6 +145,7 @@ Owns:
- All Bevy UI screens (Home, Stats, Achievements, Settings, Profile) - All Bevy UI screens (Home, Stats, Achievements, Settings, Profile)
- Audio playback systems - Audio playback systems
- Sync status display - Sync status display
- Card, background, and font asset loading via Bevy `AssetServer` (audio is the lone exception — embedded via `include_bytes!()` in `audio_plugin.rs`)
### `solitaire_server` ### `solitaire_server`
**Dependencies:** `solitaire_sync`, `axum`, `sqlx`, `jsonwebtoken`, `bcrypt`, `tower-governor`, `tracing`, `tokio`, `dotenvy`. **Dependencies:** `solitaire_sync`, `axum`, `sqlx`, `jsonwebtoken`, `bcrypt`, `tower-governor`, `tracing`, `tokio`, `dotenvy`.
@@ -223,8 +204,7 @@ SyncPlugin::on_startup()
│ spawns AsyncComputeTask │ spawns AsyncComputeTask
solitaire_data::sync_pull() ← dispatches to active SyncProvider solitaire_data::sync_pull() ← dispatches to active SyncProvider
│ SolitaireServerClient (desktop / iOS) │ SolitaireServerClient
│ GpgsClient (Android, future)
solitaire_sync::merge(local, remote) solitaire_sync::merge(local, remote)
@@ -245,7 +225,7 @@ SyncPlugin::on_exit()
│ blocking push (acceptable on exit, not on main loop) │ blocking push (acceptable on exit, not on main loop)
active SyncProvider::push(local) active SyncProvider::push(local)
│ POST to server — or — GPGS Saved Games PUT (Android) │ POST to server
Done Done
``` ```
@@ -256,10 +236,13 @@ Done
### Bevy Plugins ### Bevy Plugins
| Plugin | Key | Responsibility | The "Shortcut" column lists optional keyboard accelerators. Every action in this table must also be reachable from a visible UI control (button, menu item, on-screen affordance) per the UI-first design principle in §1; the shortcut is a power-user convenience, not the sole entry point.
| Plugin | Shortcut | Responsibility |
|---|---|---| |---|---|---|
| `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop | | `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop |
| `TablePlugin` | — | Pile markers, background, layout calculation | | `TablePlugin` | — | Pile markers, background, layout calculation |
| `FontPlugin` | — | Loads FiraMono-Medium via `AssetServer` at startup; exposes `FontResource` handle |
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations | | `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations | | `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit | | `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
@@ -268,7 +251,7 @@ Done
| `CursorPlugin` | — | Custom cursor sprite during drag | | `CursorPlugin` | — | Custom cursor sprite during drag |
| `SelectionPlugin` | — | Keyboard-driven card selection | | `SelectionPlugin` | — | Keyboard-driven card selection |
| `GamePlugin` | N | Core game state resource, new-game flow, win/game-over overlays | | `GamePlugin` | N | Core game state resource, new-game flow, win/game-over overlays |
| `HudPlugin` | — | Score, move counter, timer, auto-complete badge | | `HudPlugin` | — | Score, move counter, timer, auto-complete badge, and the top-right action button bar (Undo / Pause / Help / New Game). Each button fires the same request event the corresponding hotkey does. |
| `StatsPlugin` | S | Stats overlay and persistence | | `StatsPlugin` | S | Stats overlay and persistence |
| `ProgressPlugin` | — | XP/level system, persistence | | `ProgressPlugin` | — | XP/level system, persistence |
| `AchievementPlugin` | A | Unlock evaluation, toast events, persistence | | `AchievementPlugin` | A | Unlock evaluation, toast events, persistence |
@@ -309,6 +292,20 @@ struct StatsResource(StatsSnapshot);
struct ProgressResource(PlayerProgress); struct ProgressResource(PlayerProgress);
struct AchievementsResource(Vec<AchievementRecord>); struct AchievementsResource(Vec<AchievementRecord>);
struct SettingsResource(Settings); 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 loaded via AssetServer at startup)
struct FontResource(Handle<Font>);
// Pre-loaded background PNG handles
struct BackgroundImageSet {
handles: Vec<Handle<Image>>, // indices 04 match selected_background setting
}
``` ```
### Key Bevy Events ### Key Bevy Events
@@ -382,7 +379,6 @@ Implementations:
|---|---|---| |---|---|---|
| `LocalOnlyProvider` | No-op (default) | All | | `LocalOnlyProvider` | No-op (default) | All |
| `SolitaireServerClient` | Self-hosted server | 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. Sync always runs on `bevy::tasks::AsyncComputeTaskPool` — the game thread is never blocked.
@@ -397,9 +393,6 @@ pub enum SyncBackend {
// JWT access + refresh tokens stored in OS keychain // JWT access + refresh tokens stored in OS keychain
// key: "solitaire_quest_server_{username}" // 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
} }
``` ```
@@ -411,10 +404,6 @@ On exit: `POST /api/sync/push` with payload
On 401: automatically attempt `POST /api/auth/refresh`, retry once, then surface error to user. 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. 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 ## 7. Sync Server Architecture
@@ -501,89 +490,7 @@ This ensures all players worldwide get the same challenge for a given date, rega
--- ---
## 8. Google Play Games Services (Android Future) ## 8. Data Models
> **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
### Core Game Models (`solitaire_core`) ### Core Game Models (`solitaire_core`)
@@ -677,14 +584,14 @@ pub struct Settings {
pub music_volume: f32, pub music_volume: f32,
pub animation_speed: AnimSpeed, pub animation_speed: AnimSpeed,
pub theme: Theme, pub theme: Theme,
pub sync_backend: SyncBackend, // Local | SolitaireServer | GooglePlayGames pub sync_backend: SyncBackend, // Local | SolitaireServer
pub first_run_complete: bool, 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`). All endpoints are under the base URL configured by the user (e.g., `https://solitaire.example.com`).
@@ -727,9 +634,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 ```rust
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload { pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload {
@@ -769,7 +676,7 @@ pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload {
--- ---
## 12. Achievement System ## 11. Achievement System
### Definition Structure ### Definition Structure
@@ -814,13 +721,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. 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 ### XP Sources
@@ -849,18 +752,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 | | File | Trigger |
|---|---| |---|---|
| `card_deal.ogg` | New game deal animation | | `card_deal.wav` | New game deal animation |
| `card_flip.ogg` | Card flips face-up | | `card_flip.wav` | Card flips face-up |
| `card_place.ogg` | Valid card placement | | `card_place.wav` | Valid card placement |
| `card_invalid.ogg` | Invalid move attempt | | `card_invalid.wav` | Invalid move attempt |
| `win_fanfare.ogg` | Game won | | `win_fanfare.wav` | Game won |
| `ambient_loop.ogg` | Looping background music (restarts seamlessly) | | `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. 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.
@@ -868,43 +771,66 @@ 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 by `card_plugin::load_card_images` via `AssetServer::load()`.
Card faces can be either: Backgrounds are Bevy `Sprite` entities with `Handle<Image>` from `BackgroundImageSet`. `BackgroundImageSet` is populated at startup by `table_plugin::load_background_images` via `AssetServer::load()`.
- 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
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 loaded via `AssetServer::load("fonts/main.ttf")` at startup by `FontPlugin` and exposed as `FontResource` for use by all UI and text systems.
### Backgrounds All three loaders take `Option<Res<AssetServer>>` so they degrade cleanly under `MinimalPlugins` in tests: when the server is absent, `CardImageSet`/`BackgroundImageSet` are inserted with empty handle slots and the plugins fall back to `Text2d` rank+suit overlays and solid-colour board backgrounds. The `assets/` directory must ship alongside the binary.
`assets/backgrounds/bg_0.png` through `bg_4.png`. Same unlock gating as card backs. The `assets/` directory layout:
### Fonts ```
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
```
`assets/fonts/main.ttf` — used for card rank/suit text in Bevy UI. ### Audio
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 | | Platform | Status | Primary Sync | Notes |
|---|---|---|---| |---|---|---|---|
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) | | 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+ | | 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 | | Android | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input; GPGS unavailable on iOS | | 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`. 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 ### Prerequisites
@@ -965,7 +891,7 @@ Add `--features bevy/dynamic_linking` during development to dramatically reduce
--- ---
## 18. Deployment Guide ## 17. Deployment Guide
### Docker Compose (Recommended) ### Docker Compose (Recommended)
@@ -1010,7 +936,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
--- ---
## 19. Security Model ## 18. Security Model
| Concern | Mitigation | | Concern | Mitigation |
|---|---| |---|---|
@@ -1026,7 +952,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
--- ---
## 20. Testing Strategy ## 19. Testing Strategy
### Unit Tests (`solitaire_core`) ### Unit Tests (`solitaire_core`)
@@ -1065,12 +991,10 @@ Using `axum::test` and an in-memory SQLite database:
- [ ] Achievement toast appears and dismisses - [ ] Achievement toast appears and dismisses
- [ ] Server sync: register, login, push, pull on second machine - [ ] Server sync: register, login, push, pull on second machine
- [ ] Server sync: JWT refresh on 401 works transparently - [ ] 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 | | Decision | Rationale | Date |
|---|---|---| |---|---|---|
@@ -1082,7 +1006,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 | | 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 | | 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 | | 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 | | `SyncProvider` trait, not `SyncBackend` match arms | `SyncPlugin` stays backend-agnostic and testable; new backends can be added without touching the plugin | 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 |
| Dropped WebDAV backend | Redundant once the self-hosted server exists; removing it reduces surface area and simplifies settings UI | 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 |
| Card, background, and font assets loaded via `AssetServer` | Reverses the earlier embed-via-`include_bytes!()` decision: PNGs and TTFs are loaded at runtime so artwork can be swapped (e.g. xCards @2x faces, alternate card backs, themed backgrounds) without a recompile, and binary size stays small. Loaders take `Option<Res<AssetServer>>` and fall back gracefully under `MinimalPlugins`. The `assets/` directory must ship alongside the binary. | 2026-04-29 |
| Audio assets remain embedded via `include_bytes!()` | Audio files are small, change rarely, and the embedded path eliminates a class of runtime-load errors during gameplay; the asset-pipeline reversal does not extend to audio | 2026-04-29 |
+4 -3
View File
@@ -12,7 +12,6 @@ solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
solitaire_data/ # Persistence + SyncProvider trait + server client solitaire_data/ # Persistence + SyncProvider trait + server client
solitaire_engine/ # Bevy ECS systems, components, plugins solitaire_engine/ # Bevy ECS systems, components, plugins
solitaire_server/ # Axum sync server binary solitaire_server/ # Axum sync server binary
solitaire_gpgs/ # Google Play Games bridge — STUB ONLY until Android phase
solitaire_app/ # Thin binary entry point solitaire_app/ # Thin binary entry point
assets/ # Source assets — embedded at compile time via include_bytes!() assets/ # Source assets — embedded at compile time via include_bytes!()
``` ```
@@ -48,12 +47,13 @@ cargo clippy -p solitaire_core -- -D warnings
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies. - `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 `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
- Assets are embedded at compile time using `include_bytes!()`. No runtime asset loading via `AssetServer`. - Audio assets are embedded at compile time using `include_bytes!()` in `audio_plugin.rs`.
- Card faces (52 PNGs in `assets/cards/faces/`), card backs (`assets/cards/backs/back_N.png`), board backgrounds (`assets/backgrounds/bg_N.png`), and the UI font (`assets/fonts/main.ttf`) are loaded at runtime via `AssetServer::load()` and stored as `Handle<Image>`/`Handle<Font>` in the `CardImageSet`, `BackgroundImageSet`, and `FontResource` resources. The `assets/` directory must ship alongside the binary.
- Asset-loading systems take `Option<Res<AssetServer>>` so they degrade cleanly under `MinimalPlugins` (tests). When `CardImageSet` is absent, `card_plugin` falls back to a `Text2d` rank+suit overlay; when `BackgroundImageSet` is absent, the board falls back to a solid colour.
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`. - 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. - 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. - 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. - 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 clippy --workspace -- -D warnings` must pass clean after every change.
- `cargo test --workspace` must pass after every change. - `cargo test --workspace` must pass after every change.
@@ -77,6 +77,7 @@ cargo clippy -p solitaire_core -- -D warnings
- Resources own shared state. Events communicate between systems. Components own per-entity data. - Resources own shared state. Events communicate between systems. Components own per-entity data.
- All UI screens are built with Bevy UI (`bevy::ui`). Never mix UI layout and game 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. - Layout is recomputed on `WindowResized` — never assume a fixed window size.
- **UI-first.** Every player-triggered action (new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, switch mode, etc.) must be reachable from a visible UI control. Keyboard shortcuts are optional accelerators — never the sole entry point. New gameplay features ship with the UI control alongside the system that backs it; do not merge a feature that is keyboard-only.
--- ---
Generated
+3871 -1264
View File
File diff suppressed because it is too large Load Diff
+15 -13
View File
@@ -5,42 +5,44 @@ members = [
"solitaire_data", "solitaire_data",
"solitaire_engine", "solitaire_engine",
"solitaire_server", "solitaire_server",
"solitaire_gpgs",
"solitaire_app", "solitaire_app",
"solitaire_assetgen", "solitaire_assetgen",
] ]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
edition = "2021" edition = "2024"
version = "0.1.0" version = "0.1.0"
license = "MIT"
rust-version = "1.95"
[workspace.dependencies] [workspace.dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
thiserror = "1" thiserror = "2"
rand = "0.8" rand = "0.9"
async-trait = "0.1" async-trait = "0.1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
dirs = "5" dirs = "6"
keyring = "2" keyring = "4"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
solitaire_core = { path = "solitaire_core" } solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" } solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" } solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" } solitaire_engine = { path = "solitaire_engine" }
bevy = "0.15" bevy = "0.18"
kira = "0.9" kira = "0.12"
axum = "0.7" axum = "0.8"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
jsonwebtoken = "9" jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
bcrypt = "0.15" bcrypt = "0.19"
tower_governor = "0.4" tower_governor = "0.8"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15" dotenvy = "0.15"
+2 -6
View File
@@ -6,10 +6,6 @@ FROM rust:slim AS builder
WORKDIR /app WORKDIR /app
RUN apt-get update \
&& apt-get install -y pkg-config libssl-dev \
&& rm -rf /var/lib/apt/lists/*
COPY . . COPY . .
# Tell sqlx to use the cached query metadata instead of a live database. # Tell sqlx to use the cached query metadata instead of a live database.
@@ -22,11 +18,11 @@ RUN cargo build --release -p solitaire_server
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y libssl3 ca-certificates \ && apt-get install -y ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/solitaire_server /usr/local/bin/solitaire_server COPY --from=builder /app/target/release/solitaire_server /usr/local/bin/solitaire_server
EXPOSE 8080 EXPOSE ${SERVER_PORT:-8080}
ENTRYPOINT ["/usr/local/bin/solitaire_server"] 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).
+212
View File
@@ -0,0 +1,212 @@
# Solitaire Quest — UX Overhaul Session Handoff
**Last updated:** 2026-04-30 — Phase 3 complete. All 10 steps landed; ready for full smoke-test.
## Where we are
Phase 3 of the UX overhaul brief is **done**. The whole engine has been migrated to the `ui_theme` design-token system + `ui_modal` scaffold. Animation system upgraded. Final literal sweep landed. The work spans 17 commits this session, from the foundation (`e14852c`) through to the final sweep (`54e024c`).
### Design direction (already saved as project memory)
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
- **Palette:** Midnight Purple base (`BG_BASE` `#1A0F2E``BG_ELEVATED` `#2D1B69``BG_ELEVATED_HI` `#3A2580``BG_ELEVATED_TOP` `#482F97`) + Balatro yellow primary accent (`ACCENT_PRIMARY` `#FFD23F`) + warm magenta secondary (`ACCENT_SECONDARY` `#FF6B9D`).
- See [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for the full direction.
### Top complaints from the original smoke test — all closed
1. **HUD too cluttered.** ✅ Closed by `73cad7e` — readouts now sit in a 4-tier vertical stack with progressive disclosure of penalty/bonus tiers.
2. **Y/N keyboard prompts feel like debug panels.** ✅ Closed across Confirm, GameOver, Pause, Forfeit, and Settings modals — every prompt now has real Primary/Secondary/Tertiary buttons with hover/press feedback.
## Foundation (done)
- **`solitaire_engine/src/ui_theme.rs`** — every design token: colours, 5-rung typography scale, 4-multiple spacing scale, three radius rungs, monotonically-ordered z-index hierarchy, motion durations with `scaled_duration(speed)` helper.
- **`solitaire_engine/src/ui_modal.rs`** — `spawn_modal` scaffold + `spawn_modal_header` / `spawn_modal_body_text` / `spawn_modal_actions` / `spawn_modal_button` helpers + `ButtonVariant` enum (Primary / Secondary / Tertiary) + `paint_modal_buttons` system. `UiModalPlugin` registered in `solitaire_app/src/main.rs`.
## Commits this session (Phase 3, latest first)
```
54e024c chore(engine): final literal-to-token sweep
3a01318 feat(engine): upgrade animations — curves, scoped settle, deal jitter, cascade rotation
79d3917 chore(data): derive Copy on AnimSpeed
ba019c0 feat(engine): convert SettingsPanel to modal scaffold + Done button
18d7c12 feat(engine): convert OnboardingPlugin to 3-slide modal flow
cb93bd9 fix(engine): pin modals via GlobalZIndex and surface forfeit-no-op toast
6723416 feat(engine): convert PauseScreen to modal + add ForfeitConfirmScreen
afb0879 docs: add SESSION_HANDOFF.md mid-overhaul checkpoint
3b619b8 feat(engine): convert HomeScreen to modal scaffold + Done button
37681cf feat(engine): convert LeaderboardScreen to modal scaffold + Done button
99064ce feat(engine): convert ProfileScreen to modal scaffold + Done button
de4dba6 feat(engine): convert AchievementsScreen to modal scaffold + Done button
75fc3aa feat(engine): convert StatsScreen to modal scaffold + Done button
deb034c feat(engine): convert HelpScreen to real-button modal with kbd-chip rows
242b5fe feat(engine): convert GameOverScreen to real-button modal
3f922ed feat(engine): convert ConfirmNewGameScreen to real-button modal
8da62bd feat(engine): add ui_modal primitive (scaffold + button variants)
73cad7e feat(engine): restructure HUD into 4-tier layout, adopt design tokens
e14852c feat(engine): add ui_theme.rs design-token module
```
**Test status:** `cargo build --workspace` clean, `cargo clippy --workspace -- -D warnings` clean, **819 tests pass / 0 failed / 8 ignored**.
## Smoke-test checklist
The whole overhaul is on disk. Worth running through once end-to-end:
1. **Run the game.** `cargo run -p solitaire_app --features bevy/dynamic_linking`.
2. **HUD layout** reads as 4 stacked tiers (Score / Mode / Penalty / Selection) with the new midnight-purple palette.
3. **Open every overlay**`S` (Stats), `A` (Achievements), `P` (Profile), `O` (Settings), `L` (Leaderboard), `M` (Home), `F1` (Help). Each is a centred card on a uniform scrim with a yellow `Done` / `Close` primary button. Hover/press states on every button.
4. **Settings.** Four sections (Audio / Gameplay / Cosmetic / Sync). Body scrolls within the modal on small windows; `Done` button stays fixed at the bottom regardless of scroll. Card-back / Background pickers tint the selected swatch with `STATE_SUCCESS`.
5. **Confirm flow.** Click `New Game` while a game is in progress — the abandon-current-game modal has real Cancel/Confirm buttons. `Y/Enter` and the yellow primary button start a new game; `N/Esc` and the secondary button cancel.
6. **Pause + Forfeit.** Press `Esc` — pause modal shows real Resume / Forfeit buttons. Forfeit button opens a Cancel/Forfeit confirmation modal stacked above the pause modal (z-index ordered correctly via `GlobalZIndex`).
7. **First-run onboarding.** Delete `settings.json` (or set `first_run_complete = false`) — three-slide flow shows: Welcome → How to play → Keyboard shortcuts. Navigate with `Next` / `Back` buttons or `→` / `←` accelerators. `Esc` skips on slide 0.
8. **Animations.**
- Slide a card to a pile — motion curves through `SmoothSnap` (slight overshoot + settle), not linear lerp.
- Drop a card on a valid destination — only the moved cards bounce; the rest of the table stays still.
- Start a new game — deal stagger is no longer mechanically uniform; cards land with subtle ±10% timing variation.
- Win a game — cascade now uses `Expressive` curve with per-card ±15° Z-rotation, screen shake driven by the new `MOTION_WIN_SHAKE_*` tokens.
9. **Resize the window** — cards still snap, no "snap-back-and-forth" jitter.
10. **Win modal** — restyled with the design tokens: midnight-purple card, yellow `Play Again` button.
## Open follow-ups (not blockers)
- **Home / Help redundancy.** Home is still a kbd-reference modal that mostly duplicates Help. Three options: (1) keep as-is, (2) convert into a true mode launcher (Classic / Daily / Zen / Challenge / Time Attack cards, locked options visibly disabled below level 5), (3) drop entirely now that the action bar covers everything Home does. Worth asking the user which direction they want.
- **Forfeit countdown toast** is now superseded by the Forfeit modal (`6723416`). Confirm the toast path is no longer reachable when smoke-testing.
- **Sub-rung pixel sizes** (1 px borders, 64/80/110/150/160 px fixed widths, 28/36/50 px specific spacings) were intentionally left as literals during the step-10 sweep — they're below the smallest `SPACE_*` rung. If the design system grows a "fine" spacing tier in the future, those become candidates for migration.
## Resume prompt for the next session
```
You are a senior Rust + Bevy developer working toward a public release
of Solitaire Quest. Working directory: /home/manage/Rusty_Solitare.
Branch: master. Apply that lens to every decision: prefer shipping
quality (polish, packaging, defaults, credits, crash safety) over
greenfield features. If something is half-done, the question is
"finish for v1 or cut for v1?" not "what else can we add?".
State: HEAD=0066ca6. Phase 3 of the UX overhaul is shipped. cargo
build / clippy --workspace -- -D warnings / test --workspace all
green — 819 tests pass / 0 fail / 8 ignored.
READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — full state, smoke-test checklist, follow-ups
2. CLAUDE.md — hard rules (UI-first, no panics, etc.)
3. ARCHITECTURE.md §1, §15, §17 — design principles, platform
targets, deployment guide
4. ~/.claude/projects/-home-manage-Rusty-Solitare/memory/MEMORY.md
— saved feedback / project context
GATING SIGNAL — ASK FIRST, DON'T ASSUME:
Before proposing new work, ask: "Did the smoke-test (items 1-10 in
SESSION_HANDOFF.md) pass, or did anything regress?" If a regression
exists, fix it before opening any new thread.
LIKELY NEXT DIRECTIONS — surface for the user to choose, don't pick
unilaterally. All framed through "what does v1 release need?":
A. Home modal decision (open in SESSION_HANDOFF.md).
- keep as kbd-reference (duplicates Help — release-blocking
confusion?)
- repurpose as mode launcher (Classic / Daily / Zen / Challenge /
Time Attack cards, locked options below level 5)
- drop (action bar already covers every action)
B. Window + release polish — `solitaire_app/src/main.rs:34-48`
currently sets only title + resolution + min size. For public
release the window needs:
- app icon (taskbar / dock / alt-tab) — Bevy `Window::window_icon`
or platform `set_window_icon`; ship a .png/.ico asset.
- window class / app id (`Window::name`) so X11/Wayland and
Windows group taskbar entries correctly.
- persist size + position across launches (Settings already
saves to JSON; add `window_geometry` field).
- F11 (or a Settings toggle) wired to real fullscreen mode.
- centered default position on first launch (Bevy supports
`WindowPosition::Centered`).
- present_mode + vsync verification — make sure Linux/macOS
don't ship at uncapped 4000 fps.
- panic hook (`std::panic::set_hook`) that writes a crash
report next to the save files instead of silently exiting.
- macOS Info.plist / Windows .ico bundling — ARCHITECTURE.md
§17 currently only covers server deploy.
C. Sound-design audit. The scoped settle bounce (3a01318) means
audio_plugin.rs trigger sites may fire less often than before;
verify card_place / card_flip / card_invalid still feel right.
D. Sync flow end-to-end on a real second machine. Server
scaffolding exists but the register → push → pull → restore-on-
other-device round trip hasn't been exercised against the new
Settings sync section.
E. Achievement unlock completeness. ARCHITECTURE.md §11 lists 18.
The three hidden ones (speed_and_skill, comeback, zen_winner)
are most likely to be untested. For release, every advertised
achievement needs to actually fire.
F. Release-readiness backlog:
- README / store-page copy / screenshots
- LICENSE + third-party credits (xCards art, FiraMono, Bevy)
- SemVer + a v0.1.0 git tag
- itch.io / Steam packaging per platform (ARCHITECTURE.md §15)
- App signing — macOS notarization, Windows Authenticode,
Linux AppImage
- Telemetry / crash reporting — opt-in, off by default; or
confirm we ship without and rely on player reports
G. UI/UX professional polish — Phase 3 shipped the design system;
v1 wants the difference between "consistent" and "feels
intentional":
- Microcopy pass: every button label, empty state, error
message, and onboarding line reviewed for voice + clarity.
Pick one verb per concept ("Done" vs "Close" vs "OK") and
apply it everywhere.
- Empty / loading / error states: Leaderboard before any
scores, Stats before any games, Sync UI before login.
Today these are likely blank panels.
- Modal open/close animation: `MOTION_MODAL_SECS` token exists
in `ui_theme.rs:255` but isn't wired up — modals
appear/disappear instantly. Add scale-from-0.96 + scrim fade
per the token's doc comment.
- Tooltips on HUD readouts and settings labels. Bevy has no
built-in tooltip; build a small one. Hover a number to learn
what it counts.
- Accessibility: verify the AAA-contrast claim on
`ACCENT_PRIMARY` over `BG_BASE` (ui_theme.rs:65). Confirm
`AnimSpeed::Instant` disables every new animation (slide
curve, scoped settle, deal jitter, cascade rotation). Add
focus rings on `Button` entities for keyboard navigation.
- Typography choice: FiraMono is one weight, monospace for
everything. Consider shipping a second proportional face for
body + headings, keep mono for numerics (HUD score, timer).
Or commit to mono and lean into the "calm coder" feel — pick
deliberately and document the decision.
- Onboarding artwork: the 3 slides are text + buttons. For
release, stylised illustrations (or simple animated card
props on each slide) elevate the first-launch feel.
- Score-change feedback: floating "+N" numbers when score
jumps; pulse on the readout when value crosses a milestone.
`MOTION_SCORE_PULSE_SECS` is already a token.
- Splash / loading screen: today the window goes straight to
gameplay. A 1-2 second branded splash signals "real game"
vs "rust prototype".
- Hit-target audit: every interactive element ≥ 32 px on
desktop. Settings has 28 px icon buttons (`ICON_BUTTON_PX`
in settings_plugin.rs); revisit.
- Win-moment design: the cascade is good; consider a score-
breakdown reveal, streak callout, "share your time"
affordance for v1.
WORKFLOW NOTES:
- Commits use:
git -c user.name=funman300 -c user.email=root@vscode.infinity commit -m "..."
- Sub-agents can Edit/Write but CANNOT `git commit`. Brief them to
stage + verify only; orchestrator commits on their behalf.
See memory/feedback_agent_commit_limit.md.
- Remote push needs interactive credentials on git.aleshym.co; the
user runs `git push origin master` themselves.
- Every commit must pass build / clippy / test. Pause-and-verify
is the user's preferred cadence — one feature per commit.
OPEN AT THE START: ask (1) did smoke-test pass, (2) which of AG to
pursue first. Do not assume.
```
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: 2.3 MiB

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: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.
+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.
+318
View File
@@ -0,0 +1,318 @@
# Sync Subsystem Manual Test Runbook
**Version:** 1.0
**Last Updated:** 2026-04-28
**Scope:** Cross-machine sync, JWT refresh, conflict resolution, account deletion
---
## Prerequisites
### Infrastructure
- Two machines (or VMs) referred to as **Machine A** and **Machine B** throughout this runbook. Both must be able to reach the sync server over the network.
- A running Solitaire Quest sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup.
- Verify the server is live before starting:
```bash
curl -s https://solitaire.example.com/health
# Expected: {"status":"ok","version":"..."}
```
### Accounts
- You will register two separate accounts (`alice` and `bob`) during the tests. You do not need to create them in advance.
### Tooling
- `curl` or a REST client (Insomnia/Postman) for manual API calls.
- `sqlite3` CLI if you need to inspect the server database directly.
- The game binary built in release mode on both machines:
```bash
cargo build -p solitaire_app --release
```
### Baseline: Clear local data on both machines
Before starting, delete any existing local save files to ensure a clean state:
```
# Linux
rm -rf ~/.local/share/solitaire_quest/
# macOS
rm -rf ~/Library/Application\ Support/solitaire_quest/
# Windows
rmdir /s %APPDATA%\solitaire_quest\
```
---
## Test 1 — Full Sync Round-Trip (register, play, push, verify on second machine)
**Goal:** Confirm that stats played on Machine A appear on Machine B after sync.
### Step 1 — Register on Machine A
1. Launch the game on Machine A.
2. Open **Settings** (key: `O`) and locate the **Sync** section.
3. Enter the server URL and choose a username: `alice`.
4. Choose a password (at least 12 characters).
5. Tap **Register** (or **Login** if the account already exists).
6. The Settings screen should show **Status: syncing…** briefly, then **Status: last synced at HH:MM**.
7. Close the game.
Verify the registration succeeded directly:
```bash
curl -s -X POST https://solitaire.example.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"<your-password>"}' | jq .
# Expected: {"access_token":"...","refresh_token":"..."}
```
### Step 2 — Play games on Machine A
1. Launch the game on Machine A.
2. Win at least **three games** (Draw One or Draw Three — note which mode).
3. Check the Stats overlay (key: `S`) and note:
- `games_played`
- `games_won`
- `win_streak_current`
- `fastest_win_seconds`
4. Close the game normally (this triggers the push-on-exit path).
### Step 3 — Verify the push reached the server
```bash
# Log in to get a fresh token
TOKEN=$(curl -s -X POST https://solitaire.example.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"<your-password>"}' | jq -r .access_token)
# Pull the server's stored state
curl -s -H "Authorization: Bearer $TOKEN" \
https://solitaire.example.com/api/sync/pull | jq .merged.stats
```
Confirm `games_won` matches what you recorded in Step 2.
### Step 4 — Pull on Machine B
1. Launch the game on **Machine B** (clean local data).
2. Open **Settings**, enter the same server URL, and log in as `alice` with the same password.
3. The plugin will pull on startup. Wait for **Status: last synced at HH:MM**.
4. Open the Stats overlay (key: `S`) and confirm the numbers from Step 2 are present.
**Pass criterion:** `games_won`, `games_played`, and `fastest_win_seconds` on Machine B match Machine A.
---
## Test 2 — JWT Refresh on 401
**Goal:** Confirm that an expired access token is refreshed transparently without user interaction.
### Step 1 — Shorten the access token TTL on the server (test environment only)
Edit the server `.env` and set a short expiry, then restart:
```
JWT_ACCESS_EXPIRY_SECS=5
```
> If you cannot modify the server config, skip to the manual token corruption method in Step 1b.
### Step 1b (alternative) — Corrupt the stored access token directly
On the machine where you want to test (Linux example):
```bash
# List keychain entries (uses secret-tool on GNOME)
secret-tool search service solitaire_quest_server
# Overwrite alice's access token with a deliberately invalid value
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "invalid.token.value"
```
### Step 2 — Trigger a sync with the expired/invalid token
1. Launch the game.
2. Either wait for the startup pull (for the short-TTL method), or open **Settings** and tap **Sync Now**.
3. Observe the **Status** field.
**Pass criterion (transparent refresh):** Status briefly shows "syncing…" and then shows "last synced at HH:MM" — no auth error is displayed. The access token in the keychain has been silently replaced.
**Verify the new token is valid:**
```bash
# Extract the new token from the keychain
secret-tool lookup service solitaire_quest_server account alice_access | head -c 50
# Should look like a valid JWT (three base64 segments separated by dots)
```
### Step 3 — Test failed refresh (both tokens expired)
1. Corrupt both the access token and the refresh token in the keychain:
```bash
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "bad"
secret-tool store --label="alice_refresh" service solitaire_quest_server account alice_refresh <<< "bad"
```
2. Launch the game and trigger a sync.
**Pass criterion:** The Settings screen shows an error message matching: "Login expired — tap Sync Now after re-logging in". The game must not crash. No data must be lost (local files are untouched).
3. Restore: log in again via Settings to get fresh tokens.
---
## Test 3 — Conflict Scenario (offline play on both machines, then sync)
**Goal:** Confirm that progress made on both devices offline is merged correctly, with no data silently discarded.
### Step 1 — Take both machines offline
Disable network on both Machine A and Machine B (e.g. airplane mode, or block the server URL in `/etc/hosts`).
### Step 2 — Play on Machine A (offline)
1. Win 5 games. Note the resulting streak and `games_won`.
2. Close the game.
### Step 3 — Play on Machine B (offline)
1. Win 3 different games. Note the resulting streak and `games_won`.
2. Close the game.
At this point Machine A and Machine B have divergent state.
### Step 4 — Re-enable network, sync Machine A first
1. Restore network.
2. Launch the game on Machine A. The push-on-exit from Step 2 did not reach the server, so:
- Open Settings, tap **Sync Now** to force a pull.
- Close the game (triggers push-on-exit).
3. Verify the server has Machine A's state:
```bash
curl -s -H "Authorization: Bearer $TOKEN" \
https://solitaire.example.com/api/sync/pull | jq '.merged.stats.games_won'
```
### Step 5 — Sync Machine B
1. Launch the game on Machine B.
2. The startup pull fetches the server's merged state (which now contains Machine A's wins).
3. Open Settings — wait for **Status: last synced at HH:MM**.
4. Open the Stats overlay.
**Pass criteria:**
- `games_won` = max(Machine A wins, Machine B wins) — at minimum the higher of the two counts.
- No games are lost — both machines' win counts contribute.
- If the two machines had different `win_streak_current` values, a conflict should be recorded (visible if you inspect the server response directly):
```bash
curl -s -H "Authorization: Bearer $TOKEN" \
https://solitaire.example.com/api/sync/pull | jq '.conflicts'
```
- The `win_streak_current` conflict entry will show `local_value` and `remote_value`. The higher value is used as the best-effort resolution.
---
## Test 4 — Account Deletion
**Goal:** Confirm that `DELETE /api/account` removes all server-side data and that a subsequent authenticated request is rejected.
### Step 1 — Confirm data exists before deletion
```bash
curl -s -H "Authorization: Bearer $TOKEN" \
https://solitaire.example.com/api/sync/pull | jq '.merged.stats.games_played'
# Expected: a non-zero number
```
### Step 2 — Delete the account via the API
```bash
curl -s -X DELETE \
-H "Authorization: Bearer $TOKEN" \
https://solitaire.example.com/api/account | jq .
# Expected: {"ok":true}
```
### Step 3 — Verify all data is gone from the server
```bash
# Try to pull with the (now-invalid) token
curl -s -H "Authorization: Bearer $TOKEN" \
https://solitaire.example.com/api/sync/pull
# Expected: HTTP 401 Unauthorized
# Try to log in again with the same credentials
curl -s -X POST https://solitaire.example.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"<your-password>"}' | jq .
# Expected: HTTP 401 or error body indicating invalid credentials
```
### Step 4 — Verify local data is NOT deleted
1. Open the game. The local files (`stats.json`, `progress.json`, etc.) must still be present and intact — account deletion only affects the server.
2. Check the Stats overlay and confirm local game history is visible.
3. The Settings screen may show an auth error on next sync attempt, which is expected.
### Step 5 — Re-register with the same username (optional)
```bash
curl -s -X POST https://solitaire.example.com/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"<new-password>"}' | jq .
# Expected: {"access_token":"...","refresh_token":"..."} — fresh empty account
```
**Pass criterion:** Re-registration succeeds, and a subsequent pull returns a payload with all-zero stats (completely fresh account, no residual data from the deleted account).
---
## Test 5 — Server Errors Do Not Show "Login Expired"
**Goal:** Verify that a 500 Internal Server Error or 429 Too Many Requests shows a network error, not an auth error, to the user.
### Step 1 — Simulate a 500 with a reverse proxy rule
Add a temporary nginx/Caddy rule to return 500 for `/api/sync/*`:
```nginx
location /api/sync/ {
return 500;
}
```
Or use a local proxy like `mitmproxy` to intercept and rewrite responses.
### Step 2 — Trigger a sync
Open Settings and tap **Sync Now**.
**Pass criterion:** The Status field shows "Can't reach server — check your connection" (network error message), NOT "Login expired — tap Sync Now after re-logging in" (auth error message).
Remove the nginx rule after this test.
---
## Regression Checklist
After running all tests above, confirm:
- [ ] No crash occurred during any test on either machine.
- [ ] Local save files (`stats.json`, `progress.json`, `achievements.json`) are present and valid JSON after all tests.
- [ ] The game launches and plays normally after all sync operations (sync is additive — never blocks gameplay).
- [ ] The Stats overlay shows correct numbers on both machines after a successful sync round-trip.
- [ ] An expired token is refreshed transparently without the user having to log in again.
- [ ] A doubly-expired token surfaces a clear error message to the user.
- [ ] Account deletion removes all server data; local data is preserved.
- [ ] HTTP 5xx and 429 responses show a network error, not an auth error.
+63
View File
@@ -0,0 +1,63 @@
# Maintainer: funman300 <funman300@gmail.com>
pkgname=solitaire-quest-server
pkgver=0.1.0
pkgrel=1
pkgdesc='Self-hosted sync server for Solitaire Quest (stats, achievements, leaderboards)'
url='https://github.com/funman300/solitaire-quest'
license=('MIT')
arch=('x86_64')
makedepends=('cargo' 'rust')
depends=(
'gcc-libs'
'glibc'
)
backup=('etc/solitaire-quest-server/server.env')
# Build from the local workspace (two levels above this PKGBUILD).
_srcdir="$startdir/../.."
source=(
'solitaire-quest-server.service'
'server.env'
)
b2sums=('SKIP'
'SKIP')
prepare() {
export RUSTUP_TOOLCHAIN=stable
cd "$_srcdir"
cargo fetch --locked --target "$(rustc -Vv | grep host | cut -d' ' -f2)"
}
build() {
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cd "$_srcdir"
cargo build --frozen --release -p solitaire_server
}
check() {
export RUSTUP_TOOLCHAIN=stable
cd "$_srcdir"
cargo test --frozen -p solitaire_server -p solitaire_sync
}
package() {
cd "$_srcdir"
# Binary
install -Dm0755 -t "$pkgdir/usr/bin/" "target/release/solitaire_server"
# systemd service
install -Dm0644 "$srcdir/solitaire-quest-server.service" \
"$pkgdir/usr/lib/systemd/system/solitaire-quest-server.service"
# Environment file (contains JWT_SECRET, DATABASE_URL, SERVER_PORT)
install -Dm0640 "$srcdir/server.env" \
"$pkgdir/etc/solitaire-quest-server/server.env"
# License and docs
install -Dm0644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm0644 README_SERVER.md \
"$pkgdir/usr/share/doc/$pkgname/README_SERVER.md"
}
+15
View File
@@ -0,0 +1,15 @@
# Solitaire Quest Server — environment configuration
# This file is installed to /etc/solitaire-quest-server/server.env (mode 0640).
# Edit these values before starting the service.
# Path to the SQLite database file.
# The directory must be writable by the solitaire-quest service user.
DATABASE_URL=sqlite:///var/lib/solitaire-quest-server/solitaire.db
# HS256 signing secret for JWT tokens.
# Generate a strong secret with: openssl rand -hex 32
# REQUIRED — server will refuse to start if unset.
JWT_SECRET=changeme_generate_with_openssl_rand_hex_32
# TCP port the server listens on.
SERVER_PORT=8080
@@ -0,0 +1,23 @@
[Unit]
Description=Solitaire Quest Sync Server
Documentation=https://github.com/funman300/solitaire-quest/blob/main/README_SERVER.md
After=network.target
[Service]
Type=simple
User=solitaire-quest
Group=solitaire-quest
EnvironmentFile=/etc/solitaire-quest-server/server.env
ExecStart=/usr/bin/solitaire_server
Restart=on-failure
RestartSec=5s
# Harden the service
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/solitaire-quest-server
[Install]
WantedBy=multi-user.target
+48
View File
@@ -0,0 +1,48 @@
# Maintainer: funman300 <funman300@gmail.com>
pkgname=solitaire-quest
pkgver=0.1.0
pkgrel=1
pkgdesc='Cross-platform Klondike Solitaire with progression, achievements, and optional sync'
url='https://github.com/funman300/solitaire-quest'
license=('MIT')
arch=('x86_64')
makedepends=('cargo' 'rust')
depends=(
'gcc-libs'
'glibc'
'alsa-lib'
'libxkbcommon'
'systemd-libs' # libudev.so — required by Bevy input
)
# Build from the local workspace (two levels above this PKGBUILD).
_srcdir="$startdir/../.."
source=()
b2sums=()
prepare() {
export RUSTUP_TOOLCHAIN=stable
cd "$_srcdir"
cargo fetch --locked --target "$(rustc -Vv | grep host | cut -d' ' -f2)"
}
build() {
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cd "$_srcdir"
cargo build --frozen --release -p solitaire_app
}
check() {
export RUSTUP_TOOLCHAIN=stable
cd "$_srcdir"
# Only test non-Bevy crates — Bevy integration tests require a GPU/display.
cargo test --frozen -p solitaire_core -p solitaire_sync
}
package() {
cd "$_srcdir"
install -Dm0755 -t "$pkgdir/usr/bin/" "target/release/solitaire_app"
install -Dm0644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+2
View File
@@ -0,0 +1,2 @@
[toolchain]
channel = "stable"
+2
View File
@@ -1,6 +1,7 @@
[package] [package]
name = "solitaire_app" name = "solitaire_app"
version.workspace = true version.workspace = true
license.workspace = true
edition.workspace = true edition.workspace = true
[[bin]] [[bin]]
@@ -11,3 +12,4 @@ path = "src/main.rs"
bevy = { workspace = true } bevy = { workspace = true }
solitaire_engine = { workspace = true } solitaire_engine = { workspace = true }
solitaire_data = { workspace = true } solitaire_data = { workspace = true }
keyring = { workspace = true }
+44 -11
View File
@@ -1,14 +1,27 @@
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings}; use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{ use solitaire_engine::{
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin, AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, GamePlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
PausePlugin, ProfilePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin, OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiModalPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
}; };
fn main() { fn main() {
// Initialise the platform keyring store before any token operations.
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
// macOS it uses the Keychain; on Windows it uses the Credential store.
// If the platform has no OS keyring (e.g. a headless CI box), keyring
// operations will fail gracefully with TokenError::KeychainUnavailable.
if let Err(e) = keyring::use_native_store(true) {
eprintln!(
"warn: could not initialise OS keyring ({e}); \
server sync login will be unavailable"
);
}
// Load settings before building the app so we can construct the right // Load settings before building the app so we can construct the right
// sync provider. Falls back to defaults if no settings file exists yet. // sync provider. Falls back to defaults if no settings file exists yet.
let settings: Settings = settings_file_path() let settings: Settings = settings_file_path()
@@ -18,22 +31,40 @@ fn main() {
App::new() App::new()
.add_plugins( .add_plugins(
DefaultPlugins.set(WindowPlugin { DefaultPlugins
primary_window: Some(Window { .set(WindowPlugin {
title: "Solitaire Quest".into(), primary_window: Some(Window {
resolution: (1280.0, 800.0).into(), title: "Solitaire Quest".into(),
resolution: (1280u32, 800u32).into(),
resize_constraints: bevy::window::WindowResizeConstraints {
min_width: 800.0,
min_height: 600.0,
..default()
},
..default()
}),
..default()
})
// The `assets/` directory lives at the workspace root, but
// Bevy resolves `AssetPlugin::file_path` relative to the
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
// Point one level up so `cargo run -p solitaire_app` finds
// card faces, backs, backgrounds, and the UI font.
.set(bevy::asset::AssetPlugin {
file_path: "../assets".to_string(),
..default() ..default()
}), }),
..default()
}),
) )
.add_plugins(FontPlugin)
.add_plugins(GamePlugin) .add_plugins(GamePlugin)
.add_plugins(TablePlugin) .add_plugins(TablePlugin)
.add_plugins(CardPlugin) .add_plugins(CardPlugin)
.add_plugins(CursorPlugin) .add_plugins(CursorPlugin)
.add_plugins(InputPlugin) .add_plugins(InputPlugin)
.add_plugins(SelectionPlugin)
.add_plugins(AnimationPlugin) .add_plugins(AnimationPlugin)
.add_plugins(FeedbackAnimPlugin) .add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin) .add_plugins(AutoCompletePlugin)
.add_plugins(StatsPlugin::default()) .add_plugins(StatsPlugin::default())
.add_plugins(ProgressPlugin::default()) .add_plugins(ProgressPlugin::default())
@@ -52,5 +83,7 @@ fn main() {
.add_plugins(OnboardingPlugin) .add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider)) .add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(LeaderboardPlugin) .add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin)
.run(); .run();
} }
+11 -1
View File
@@ -1,12 +1,22 @@
[package] [package]
name = "solitaire_assetgen" name = "solitaire_assetgen"
version.workspace = true version.workspace = true
license.workspace = true
edition.workspace = true edition.workspace = true
publish = false publish = false
# Dev-only utility: synthesizes placeholder SFX WAV files into `assets/audio/`. # Dev-only utility: synthesizes placeholder SFX WAV files into `assets/audio/`
# and placeholder PNG images into `assets/cards/` and `assets/backgrounds/`.
# Not depended on by any other workspace crate. # Not depended on by any other workspace crate.
[dependencies]
png = "0.17"
ab_glyph = "0.2"
[[bin]] [[bin]]
name = "gen_sfx" name = "gen_sfx"
path = "src/bin/gen_sfx.rs" path = "src/bin/gen_sfx.rs"
[[bin]]
name = "gen_art"
path = "src/bin/gen_art.rs"
+713
View File
@@ -0,0 +1,713 @@
//! Generates PNG assets for Solitaire Quest.
//!
//! Produces:
//! - 52 card face PNGs (120×168) — one per card, with rank, suit symbol, and
//! pip or face-letter layout baked in.
//! - 5 card back PNGs (120×168) with distinctive coloured patterns.
//! - 5 background PNGs (120×168) with textured felt/wood patterns.
//!
//! Run with: `cargo run -p solitaire_assetgen --bin gen_art`
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
use std::fs::File;
use std::io::BufWriter;
use std::path::Path;
// ---------------------------------------------------------------------------
// Card dimensions and palette
// ---------------------------------------------------------------------------
const W: u32 = 120;
const H: u32 = 168;
const BG: [u8; 4] = [0xFE, 0xFE, 0xF2, 0xFF];
const BORDER: [u8; 4] = [0x99, 0x99, 0x99, 0xFF];
const RED: [u8; 4] = [0xCC, 0x11, 0x11, 0xFF];
const DARK: [u8; 4] = [0x11, 0x11, 0x11, 0xFF];
fn suit_color(suit: u8) -> [u8; 4] {
if suit == 1 || suit == 2 { RED } else { DARK }
}
fn rank_str(rank: u8) -> &'static str {
["A","2","3","4","5","6","7","8","9","10","J","Q","K"][rank as usize]
}
// ---------------------------------------------------------------------------
// Pixel canvas (120×168 RGBA)
// ---------------------------------------------------------------------------
struct Canvas {
data: Vec<u8>,
}
impl Canvas {
fn new() -> Self {
let mut data = vec![0u8; (W * H * 4) as usize];
for i in 0..(W * H) as usize {
data[i * 4..i * 4 + 4].copy_from_slice(&BG);
}
Self { data }
}
/// Fill every pixel with a solid colour, erasing whatever was there before.
fn fill_solid(&mut self, c: [u8; 4]) {
for i in 0..(W * H) as usize {
self.data[i * 4..i * 4 + 4].copy_from_slice(&c);
}
}
/// Draw a 1-pixel-wide axis-aligned horizontal line.
fn hline(&mut self, y: i32, x0: i32, x1: i32, c: [u8; 4]) {
for x in x0..=x1 {
self.set(x, y, c);
}
}
/// Draw a 1-pixel-wide axis-aligned vertical line.
fn vline(&mut self, x: i32, y0: i32, y1: i32, c: [u8; 4]) {
for y in y0..=y1 {
self.set(x, y, c);
}
}
/// Draw a filled diamond outline (ring) of given half-extents and line thickness.
fn diamond_ring(&mut self, cx: f32, cy: f32, rx: f32, ry: f32, thickness: f32, c: [u8; 4]) {
for y in (cy - ry - 2.0) as i32..=(cy + ry + 2.0) as i32 {
for x in (cx - rx - 2.0) as i32..=(cx + rx + 2.0) as i32 {
let nx = (x as f32 - cx).abs() / rx;
let ny = (y as f32 - cy).abs() / ry;
let dist = nx + ny;
if dist <= 1.0 && dist >= 1.0 - (thickness / rx.min(ry)) {
self.set(x, y, c);
}
}
}
}
fn set(&mut self, x: i32, y: i32, c: [u8; 4]) {
if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 { return; }
let i = (y as u32 * W + x as u32) as usize * 4;
let a = c[3] as f32 / 255.0;
if a >= 0.99 {
self.data[i..i + 4].copy_from_slice(&c);
} else if a > 0.01 {
self.data[i] = (self.data[i] as f32 * (1.0 - a) + c[0] as f32 * a) as u8;
self.data[i + 1] = (self.data[i + 1] as f32 * (1.0 - a) + c[1] as f32 * a) as u8;
self.data[i + 2] = (self.data[i + 2] as f32 * (1.0 - a) + c[2] as f32 * a) as u8;
self.data[i + 3] = 255;
}
}
fn circle(&mut self, cx: f32, cy: f32, r: f32, c: [u8; 4]) {
for y in (cy - r - 1.0) as i32..=(cy + r + 1.0) as i32 {
for x in (cx - r - 1.0) as i32..=(cx + r + 1.0) as i32 {
if (x as f32 - cx).powi(2) + (y as f32 - cy).powi(2) <= r * r {
self.set(x, y, c);
}
}
}
}
fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, c: [u8; 4]) {
for ry in y..y + h {
for rx in x..x + w {
self.set(rx, ry, c);
}
}
}
fn triangle(&mut self, pts: [(f32, f32); 3], c: [u8; 4]) {
let min_x = pts.iter().map(|p| p.0).fold(f32::INFINITY, f32::min) as i32;
let max_x = pts.iter().map(|p| p.0).fold(f32::NEG_INFINITY, f32::max) as i32;
let min_y = pts.iter().map(|p| p.1).fold(f32::INFINITY, f32::min) as i32;
let max_y = pts.iter().map(|p| p.1).fold(f32::NEG_INFINITY, f32::max) as i32;
let (ax, ay) = pts[0];
let (bx, by) = pts[1];
let (ex, ey) = pts[2];
for y in min_y..=max_y {
for x in min_x..=max_x {
let px = x as f32 + 0.5;
let py = y as f32 + 0.5;
let d0 = (bx - ax) * (py - ay) - (by - ay) * (px - ax);
let d1 = (ex - bx) * (py - by) - (ey - by) * (px - bx);
let d2 = (ax - ex) * (py - ey) - (ay - ey) * (px - ex);
let neg = d0 < 0.0 || d1 < 0.0 || d2 < 0.0;
let pos = d0 > 0.0 || d1 > 0.0 || d2 > 0.0;
if !(neg && pos) {
self.set(x, y, c);
}
}
}
}
fn diamond(&mut self, cx: f32, cy: f32, rx: f32, ry: f32, c: [u8; 4]) {
for y in (cy - ry - 1.0) as i32..=(cy + ry + 1.0) as i32 {
for x in (cx - rx - 1.0) as i32..=(cx + rx + 1.0) as i32 {
let nx = (x as f32 - cx).abs() / rx;
let ny = (y as f32 - cy).abs() / ry;
if nx + ny <= 1.0 {
self.set(x, y, c);
}
}
}
}
}
// ---------------------------------------------------------------------------
// Suit symbol drawing
// ---------------------------------------------------------------------------
fn draw_suit(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, suit: u8, c: [u8; 4]) {
match suit {
0 => draw_club(cv, cx, cy, sz, c),
1 => draw_diamond_sym(cv, cx, cy, sz, c),
2 => draw_heart(cv, cx, cy, sz, c),
_ => draw_spade(cv, cx, cy, sz, c),
}
}
fn draw_heart(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
let r = sz * 0.33;
let oy = cy - sz * 0.04;
cv.circle(cx - sz * 0.22, oy, r, c);
cv.circle(cx + sz * 0.22, oy, r, c);
cv.triangle([
(cx - sz * 0.52, oy + r * 0.4),
(cx + sz * 0.52, oy + r * 0.4),
(cx, cy + sz * 0.52),
], c);
}
fn draw_spade(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
cv.triangle([
(cx, cy - sz * 0.52),
(cx - sz * 0.52, cy + sz * 0.1),
(cx + sz * 0.52, cy + sz * 0.1),
], c);
cv.circle(cx - sz * 0.22, cy + sz * 0.06, sz * 0.3, c);
cv.circle(cx + sz * 0.22, cy + sz * 0.06, sz * 0.3, c);
// stem + base
cv.triangle([
(cx, cy + sz * 0.12),
(cx - sz * 0.13, cy + sz * 0.5),
(cx + sz * 0.13, cy + sz * 0.5),
], c);
cv.fill_rect(
(cx - sz * 0.26) as i32,
(cy + sz * 0.43) as i32,
(sz * 0.52) as i32,
(sz * 0.1) as i32,
c,
);
}
fn draw_diamond_sym(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
cv.diamond(cx, cy, sz * 0.44, sz * 0.57, c);
}
fn draw_club(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
let r = sz * 0.29;
cv.circle(cx, cy - sz * 0.22, r, c);
cv.circle(cx - sz * 0.28, cy + sz * 0.1, r, c);
cv.circle(cx + sz * 0.28, cy + sz * 0.1, r, c);
cv.fill_rect(
(cx - sz * 0.08) as i32,
(cy + sz * 0.22) as i32,
(sz * 0.16) as i32 + 1,
(sz * 0.27) as i32,
c,
);
cv.fill_rect(
(cx - sz * 0.26) as i32,
(cy + sz * 0.45) as i32,
(sz * 0.52) as i32,
(sz * 0.09) as i32,
c,
);
}
// ---------------------------------------------------------------------------
// Text rendering via ab_glyph
// ---------------------------------------------------------------------------
fn draw_text(cv: &mut Canvas, font: &FontRef<'_>, text: &str, px: f32, left: f32, top: f32, c: [u8; 4]) {
let scale = PxScale::from(px);
let baseline = top + font.as_scaled(scale).ascent();
let mut x = left;
for ch in text.chars() {
let gid = font.glyph_id(ch);
let glyph = gid.with_scale_and_position(scale, ab_glyph::point(x, baseline));
let adv = font.as_scaled(scale).h_advance(gid);
if let Some(outlined) = font.outline_glyph(glyph) {
let bounds = outlined.px_bounds();
outlined.draw(|gx, gy, cov| {
if cov > 0.02 {
let alpha = (cov * c[3] as f32) as u8;
cv.set(
(bounds.min.x + gx as f32) as i32,
(bounds.min.y + gy as f32) as i32,
[c[0], c[1], c[2], alpha],
);
}
});
}
x += adv;
}
}
fn text_w(font: &FontRef<'_>, text: &str, px: f32) -> f32 {
let scale = PxScale::from(px);
let sf = font.as_scaled(scale);
text.chars().map(|c| sf.h_advance(font.glyph_id(c))).sum()
}
fn text_h(font: &FontRef<'_>, px: f32) -> f32 {
let scale = PxScale::from(px);
let sf = font.as_scaled(scale);
sf.ascent() - sf.descent()
}
// ---------------------------------------------------------------------------
// Pip layout (rank 0=Ace … 9=Ten; rank 10-12 are face cards)
// ---------------------------------------------------------------------------
fn pip_positions(rank: u8) -> &'static [(f32, f32)] {
match rank {
0 => &[(0.5, 0.5)],
1 => &[(0.5, 0.2), (0.5, 0.8)],
2 => &[(0.5, 0.12), (0.5, 0.5), (0.5, 0.88)],
3 => &[(0.25, 0.18), (0.75, 0.18), (0.25, 0.82), (0.75, 0.82)],
4 => &[(0.25, 0.18), (0.75, 0.18), (0.5, 0.5), (0.25, 0.82), (0.75, 0.82)],
5 => &[(0.25, 0.12), (0.75, 0.12), (0.25, 0.5), (0.75, 0.5), (0.25, 0.88), (0.75, 0.88)],
6 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.31), (0.25, 0.5), (0.75, 0.5), (0.25, 0.9), (0.75, 0.9)],
7 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.28), (0.25, 0.48), (0.75, 0.48), (0.5, 0.70), (0.25, 0.9), (0.75, 0.9)],
8 => &[(0.25, 0.1), (0.75, 0.1), (0.25, 0.35), (0.75, 0.35), (0.5, 0.5), (0.25, 0.65), (0.75, 0.65), (0.25, 0.9), (0.75, 0.9)],
9 => &[(0.25, 0.09), (0.75, 0.09), (0.5, 0.27), (0.25, 0.44), (0.75, 0.44), (0.25, 0.56), (0.75, 0.56), (0.5, 0.73), (0.25, 0.91), (0.75, 0.91)],
_ => &[],
}
}
// Pip area within the card (avoids the corner labels).
const PIP_X: f32 = 22.0;
const PIP_Y: f32 = 46.0;
const PIP_W: f32 = 76.0;
const PIP_H: f32 = 80.0;
// ---------------------------------------------------------------------------
// Card face generation
// ---------------------------------------------------------------------------
fn make_card_face(font: &FontRef<'_>, rank: u8, suit: u8) -> Canvas {
let mut cv = Canvas::new();
let sc = suit_color(suit);
// Border (2 px)
for x in 0..W as i32 {
cv.set(x, 0, BORDER);
cv.set(x, 1, BORDER);
cv.set(x, H as i32 - 2, BORDER);
cv.set(x, H as i32 - 1, BORDER);
}
for y in 0..H as i32 {
cv.set(0, y, BORDER);
cv.set(1, y, BORDER);
cv.set(W as i32 - 2, y, BORDER);
cv.set(W as i32 - 1, y, BORDER);
}
let rank_s = rank_str(rank);
let rank_px = 18.0f32;
let suit_sz = 11.0f32;
let rh = text_h(font, rank_px);
let rw = text_w(font, rank_s, rank_px);
let corner_h = rh + 2.0 + suit_sz * 1.5;
// Top-left corner
let tl_x = 6.0f32;
let tl_y = 5.0f32;
draw_text(&mut cv, font, rank_s, rank_px, tl_x, tl_y, sc);
draw_suit(&mut cv, tl_x + suit_sz * 0.62, tl_y + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc);
// Bottom-right corner (right-aligned rank, suit above it)
let br_rx = W as f32 - 6.0;
let br_by = H as f32 - 5.0;
let br_ty = br_by - corner_h;
draw_text(&mut cv, font, rank_s, rank_px, br_rx - rw, br_ty, sc);
draw_suit(&mut cv, br_rx - suit_sz * 0.62, br_ty + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc);
// Center content
if rank >= 10 {
// Face cards: large rank letter + suit symbol below
let big_px = 52.0f32;
let big_w = text_w(font, rank_s, big_px);
let big_h = text_h(font, big_px);
let big_x = (W as f32 - big_w) / 2.0;
let big_y = H as f32 * 0.28;
draw_text(&mut cv, font, rank_s, big_px, big_x, big_y, sc);
let sym_sz = 22.0f32;
draw_suit(&mut cv, W as f32 * 0.5, big_y + big_h + sym_sz * 1.0, sym_sz, suit, sc);
} else {
// Pip cards
let pip_sz = if rank == 0 {
24.0f32 // Ace: large single pip
} else if rank <= 5 {
14.0
} else {
12.0
};
for &(nx, ny) in pip_positions(rank) {
let cx = PIP_X + nx * PIP_W;
let cy = PIP_Y + ny * PIP_H;
draw_suit(&mut cv, cx, cy, pip_sz, suit, sc);
}
}
cv
}
// ---------------------------------------------------------------------------
// PNG encoding helpers
// ---------------------------------------------------------------------------
fn save_card_png(path: &Path, cv: &Canvas) {
save_png_wh(path, &cv.data, W, H);
}
fn save_png_wh(path: &Path, data: &[u8], w: u32, h: u32) {
let file = File::create(path)
.unwrap_or_else(|e| panic!("cannot create {}: {e}", path.display()));
let mut bw = BufWriter::new(file);
let mut enc = png::Encoder::new(&mut bw, w, h);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut writer = enc.write_header()
.unwrap_or_else(|e| panic!("png header error for {}: {e}", path.display()));
writer.write_image_data(data)
.unwrap_or_else(|e| panic!("png data error for {}: {e}", path.display()));
}
// ---------------------------------------------------------------------------
// Card backs (120×168 with distinctive patterns)
// ---------------------------------------------------------------------------
/// back_0 blue: repeating diamond grid pattern
fn make_back_0() -> Canvas {
const BASE: [u8; 4] = [0x26, 0x4D, 0x8C, 0xFF];
const LIGHT: [u8; 4] = [0x5A, 0x80, 0xBF, 0xFF];
const HIGHLIGHT: [u8; 4] = [0xA0, 0xC0, 0xFF, 0xB0];
let mut cv = Canvas::new();
cv.fill_solid(BASE);
// 2-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, LIGHT); cv.set(x, H as i32 - 1 - t, LIGHT); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, LIGHT); cv.set(W as i32 - 1 - t, y, LIGHT); } }
// Diamond grid: row/col spacing
let gx = 18.0f32;
let gy = 18.0f32;
let rx = gx * 0.45;
let ry = gy * 0.45;
let mut row = 0;
let mut cy = 6.0f32 + gy * 0.5;
while cy < H as f32 - 4.0 {
let offset = if row % 2 == 0 { 0.0 } else { gx * 0.5 };
let mut cx = 6.0f32 + gx * 0.5 + offset;
while cx < W as f32 - 4.0 {
cv.diamond_ring(cx, cy, rx, ry, 1.5, LIGHT);
// tiny highlight dot at centre of each diamond
cv.circle(cx, cy, 1.5, HIGHLIGHT);
cx += gx;
}
cy += gy;
row += 1;
}
cv
}
/// back_1 red: diagonal crosshatch
fn make_back_1() -> Canvas {
const BASE: [u8; 4] = [0x8C, 0x1A, 0x1A, 0xFF];
const LINE: [u8; 4] = [0xCC, 0x55, 0x55, 0xC0];
const BORDER: [u8; 4] = [0xDD, 0x88, 0x88, 0xFF];
let mut cv = Canvas::new();
cv.fill_solid(BASE);
// Diagonal lines every 12 px (NW→SE)
let spacing = 12i32;
for k in (-(H as i32)..W as i32).step_by(spacing as usize) {
for t in 0..W as i32 {
let y = t + k;
cv.set(t, y, LINE);
// 1 px thick — also set neighbour for slightly bolder line
cv.set(t, y + 1, LINE);
}
}
// Diagonal lines (NE→SW)
for k in (0..(W as i32 + H as i32)).step_by(spacing as usize) {
for t in 0..W as i32 {
let y = k - t;
cv.set(t, y, LINE);
cv.set(t, y + 1, LINE);
}
}
// 4-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
cv
}
/// back_2 green: evenly spaced small circle array
fn make_back_2() -> Canvas {
const BASE: [u8; 4] = [0x0D, 0x66, 0x1A, 0xFF];
const DOT: [u8; 4] = [0x40, 0xCC, 0x55, 0xE0];
const BORDER: [u8; 4] = [0x55, 0xDD, 0x66, 0xFF];
let mut cv = Canvas::new();
cv.fill_solid(BASE);
// 4-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
// Circle array (staggered rows)
let gx = 16.0f32;
let gy = 16.0f32;
let r = 3.5f32;
let mut row = 0;
let mut cy = 8.0f32 + gy * 0.5;
while cy < H as f32 - 6.0 {
let offset = if row % 2 == 0 { 0.0 } else { gx * 0.5 };
let mut cx = 8.0f32 + gx * 0.5 + offset;
while cx < W as f32 - 6.0 {
cv.circle(cx, cy, r, DOT);
cx += gx;
}
cy += gy;
row += 1;
}
cv
}
/// back_3 purple: concentric diamond outlines
fn make_back_3() -> Canvas {
const BASE: [u8; 4] = [0x59, 0x14, 0x85, 0xFF];
const RING: [u8; 4] = [0xA0, 0x60, 0xDD, 0xD0];
const BORDER: [u8; 4] = [0xBB, 0x77, 0xFF, 0xFF];
let mut cv = Canvas::new();
cv.fill_solid(BASE);
// Concentric diamonds from centre
let cx = W as f32 * 0.5;
let cy = H as f32 * 0.5;
let mut rx = 8.0f32;
let step = 12.0f32;
while rx < (W as f32).max(H as f32) {
let ry = rx * (H as f32 / W as f32);
cv.diamond_ring(cx, cy, rx, ry, 1.5, RING);
rx += step;
}
// 4-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
cv
}
/// back_4 teal: horizontal stripes with thin decorative lines
fn make_back_4() -> Canvas {
const BASE: [u8; 4] = [0x0D, 0x66, 0x6B, 0xFF];
const STRIPE: [u8; 4] = [0x1A, 0x99, 0xA0, 0x90];
const DECO: [u8; 4] = [0x55, 0xCC, 0xD4, 0xA0];
const BORDER: [u8; 4] = [0x44, 0xBB, 0xC4, 0xFF];
let mut cv = Canvas::new();
cv.fill_solid(BASE);
// Horizontal stripes every 10 px (2 px wide)
let mut y = 6i32;
while y < H as i32 - 4 {
cv.hline(y, 5, W as i32 - 6, STRIPE);
cv.hline(y + 1, 5, W as i32 - 6, STRIPE);
y += 10;
}
// Thin decorative horizontal lines between stripes
let mut y = 10i32;
while y < H as i32 - 4 {
cv.hline(y, 14, W as i32 - 15, DECO);
y += 10;
}
// 4-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
cv
}
// ---------------------------------------------------------------------------
// Backgrounds (120×168 textured patterns)
// ---------------------------------------------------------------------------
/// bg_0 dark green felt: subtle grid of faint lines giving a woven texture
fn make_bg_0() -> Canvas {
const BASE: [u8; 4] = [0x1A, 0x4D, 0x1A, 0xFF];
const WARP: [u8; 4] = [0x22, 0x60, 0x22, 0x90]; // slightly lighter horizontal threads
const WEFT: [u8; 4] = [0x15, 0x40, 0x15, 0x90]; // slightly darker vertical threads
let mut cv = Canvas::new();
cv.fill_solid(BASE);
// Horizontal warp lines every 4 px
for y in (0..H as i32).step_by(4) {
cv.hline(y, 0, W as i32 - 1, WARP);
}
// Vertical weft lines every 4 px
for x in (0..W as i32).step_by(4) {
cv.vline(x, 0, H as i32 - 1, WEFT);
}
cv
}
/// bg_1 wood brown: horizontal planks with grain lines
fn make_bg_1() -> Canvas {
const BASE: [u8; 4] = [0x40, 0x2D, 0x1A, 0xFF];
const PLANK_EDGE: [u8; 4] = [0x28, 0x1A, 0x0A, 0xFF]; // dark plank separator
const GRAIN: [u8; 4] = [0x55, 0x3D, 0x28, 0xA0]; // lighter grain streak
let mut cv = Canvas::new();
cv.fill_solid(BASE);
// Horizontal plank edges every 24 px
for y in (0..H as i32).step_by(24) {
cv.hline(y, 0, W as i32 - 1, PLANK_EDGE);
cv.hline(y + 1, 0, W as i32 - 1, PLANK_EDGE);
}
// Grain lines within each plank (every 3 px between plank edges)
for y in (0..H as i32).step_by(3) {
// Skip the plank edge rows
if y % 24 < 2 { continue; }
cv.hline(y, 2, W as i32 - 3, GRAIN);
}
cv
}
/// bg_2 navy: star-field dots scattered in a regular grid
fn make_bg_2() -> Canvas {
const BASE: [u8; 4] = [0x0D, 0x14, 0x38, 0xFF];
const STAR_A: [u8; 4] = [0xCC, 0xDD, 0xFF, 0xD0];
const STAR_B: [u8; 4] = [0x80, 0xA0, 0xDD, 0x80];
let mut cv = Canvas::new();
cv.fill_solid(BASE);
// Bright small stars on a staggered grid
let gx = 14.0f32;
let gy = 16.0f32;
let mut row = 0u32;
let mut cy = gy * 0.5;
while cy < H as f32 {
let offset = if row.is_multiple_of(2) { 0.0 } else { gx * 0.5 };
let mut cx = gx * 0.5 + offset;
while cx < W as f32 {
// alternate bright/dim to give depth
let c = if (row + (cx / gx) as u32).is_multiple_of(3) { STAR_A } else { STAR_B };
cv.circle(cx, cy, 1.0, c);
cx += gx;
}
cy += gy;
row += 1;
}
cv
}
/// bg_3 burgundy: diagonal tile pattern
fn make_bg_3() -> Canvas {
const BASE: [u8; 4] = [0x4D, 0x0D, 0x14, 0xFF];
const LINE: [u8; 4] = [0x77, 0x22, 0x30, 0xB0];
const ACCENT: [u8; 4] = [0x99, 0x33, 0x44, 0x80];
let mut cv = Canvas::new();
cv.fill_solid(BASE);
// Diagonal lines in one direction every 16 px
let spacing = 16i32;
for k in (-(H as i32)..W as i32 + H as i32).step_by(spacing as usize) {
for t in 0..W as i32 {
let y = t + k;
cv.set(t, y, LINE);
}
}
// Diagonal lines in the other direction every 16 px (accent colour)
for k in (0..W as i32 + H as i32).step_by(spacing as usize) {
for t in 0..W as i32 {
let y = k - t;
cv.set(t, y, ACCENT);
}
}
cv
}
/// bg_4 charcoal: subtle checkerboard texture
fn make_bg_4() -> Canvas {
const DARK: [u8; 4] = [0x1F, 0x1F, 0x24, 0xFF];
const LIGHT: [u8; 4] = [0x2C, 0x2C, 0x33, 0xFF];
let mut cv = Canvas::new();
cv.fill_solid(DARK);
// 4×4 checkerboard
for y in 0..H as i32 {
for x in 0..W as i32 {
if ((x / 4) + (y / 4)) % 2 == 0 {
cv.set(x, y, LIGHT);
}
}
}
cv
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
fn workspace_root() -> std::path::PathBuf {
let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
crate_dir.parent().unwrap().to_path_buf()
}
fn main() {
let root = workspace_root();
std::fs::create_dir_all(root.join("assets/cards/faces")).unwrap();
std::fs::create_dir_all(root.join("assets/cards/backs")).unwrap();
std::fs::create_dir_all(root.join("assets/backgrounds")).unwrap();
// Load font from disk (dev tool — runtime load is fine here).
let font_path = root.join("assets/fonts/main.ttf");
let font_bytes = std::fs::read(&font_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", font_path.display()));
let font = FontRef::try_from_slice(&font_bytes)
.expect("failed to parse assets/fonts/main.ttf");
// 52 card faces
let suits = ["c", "d", "h", "s"];
let ranks = ["a","2","3","4","5","6","7","8","9","10","j","q","k"];
for suit in 0u8..4 {
for rank in 0u8..13 {
let cv = make_card_face(&font, rank, suit);
let name = format!("{}_{}.png", ranks[rank as usize], suits[suit as usize]);
let path = root.join("assets/cards/faces").join(&name);
save_card_png(&path, &cv);
println!("wrote {}", path.display());
}
}
// Card backs
for (i, cv) in [make_back_0(), make_back_1(), make_back_2(), make_back_3(), make_back_4()].iter().enumerate() {
let path = root.join(format!("assets/cards/backs/back_{i}.png"));
save_card_png(&path, cv);
println!("wrote {}", path.display());
}
// Backgrounds
for (i, cv) in [make_bg_0(), make_bg_1(), make_bg_2(), make_bg_3(), make_bg_4()].iter().enumerate() {
let path = root.join(format!("assets/backgrounds/bg_{i}.png"));
save_card_png(&path, cv);
println!("wrote {}", path.display());
}
println!("gen_art: all assets generated successfully.");
}
+62 -3
View File
@@ -16,16 +16,17 @@ fn main() -> io::Result<()> {
let out_dir = workspace_root().join("assets").join("audio"); let out_dir = workspace_root().join("assets").join("audio");
fs::create_dir_all(&out_dir)?; fs::create_dir_all(&out_dir)?;
let effects: [(&str, Generator); 5] = [ let effects: [(&str, Generator); 6] = [
("card_flip.wav", card_flip), ("card_flip.wav", card_flip),
("card_place.wav", card_place), ("card_place.wav", card_place),
("card_deal.wav", card_deal), ("card_deal.wav", card_deal),
("card_invalid.wav", card_invalid), ("card_invalid.wav", card_invalid),
("win_fanfare.wav", win_fanfare), ("win_fanfare.wav", win_fanfare),
("ambient_loop.wav", ambient_loop),
]; ];
for (name, gen) in &effects { for (name, make) in &effects {
let samples = gen(); let samples = make();
let path = out_dir.join(name); let path = out_dir.join(name);
write_wav_mono_pcm16(&path, SAMPLE_RATE, &samples)?; write_wav_mono_pcm16(&path, SAMPLE_RATE, &samples)?;
println!("wrote {} ({} samples)", path.display(), samples.len()); println!("wrote {} ({} samples)", path.display(), samples.len());
@@ -169,6 +170,64 @@ fn win_fanfare() -> Vec<i16> {
out out
} }
/// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz
/// mono 16-bit PCM).
///
/// Design:
/// - Fundamental: 55 Hz (low A) sine wave.
/// - Harmonics: 110 Hz at 40% and 165 Hz at 20% for warmth.
/// - Amplitude LFO at 0.1 Hz creates a slow breath / pad swell.
/// - The loop length is chosen so both the fundamental and LFO complete an
/// integer number of cycles — guaranteeing a phase-continuous seamless loop.
/// - Peak amplitude is kept low (0.18) so it sits quietly under SFX.
fn ambient_loop() -> Vec<i16> {
use std::f32::consts::PI;
// LFO period = 10 s; fundamental period ≈ 18.18 ms.
// We want a loop that is an exact integer multiple of both, so both
// complete a whole number of cycles with no phase discontinuity.
//
// LCM approach: fundamental @ 55 Hz repeats every 1/55 s. The LFO @ 0.1 Hz
// repeats every 10 s. 10 s is already a multiple of 1/55 s (10 * 55 = 550
// cycles), so a 10-second buffer loops perfectly. We halve it to 5 s for
// a smaller file — 5 * 55 = 275 (integer), 5 * 0.1 = 0.5 (half-cycle of
// LFO). To keep a full LFO cycle we use 10 s but write only the first 5 s
// of the waveform, which is within the 48 s budget and still a seamless
// loop because the LFO amplitude is symmetric about its midpoint at t=5 s.
//
// Simpler explanation: at exactly 5 s, both the 55 Hz tone and a slow
// 0.2 Hz (period=5 s) breath LFO complete an integer number of cycles.
// We use 0.2 Hz for the LFO instead of 0.1 Hz so the full envelope fits
// in one loop period.
let lfo_freq = 0.2_f32; // 1 full LFO cycle per 5-second loop
let loop_seconds = 1.0 / lfo_freq; // = 5.0 s
let n = (loop_seconds * SAMPLE_RATE as f32) as usize;
let f0 = 55.0_f32; // fundamental (Hz)
let f1 = 110.0_f32; // 2nd harmonic
let f2 = 165.0_f32; // 3rd harmonic
let mut out = Vec::with_capacity(n);
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
// LFO: smoothly oscillates between 0.4 and 1.0 amplitude.
// Using (1 - cos) / 2 instead of sin so the loop starts and ends at
// the same LFO phase (0.0 → both sin and cos are fully periodic).
let lfo = 0.7 + 0.3 * (2.0 * PI * lfo_freq * t).cos();
// Layered harmonics
let tone = (2.0 * PI * f0 * t).sin()
+ 0.4 * (2.0 * PI * f1 * t).sin()
+ 0.2 * (2.0 * PI * f2 * t).sin();
// Normalise the layered sum: max raw peak ≈ 1.6; keep final peak ≤ 0.18
let sample = tone / 1.6 * lfo * 0.18;
out.push(quantize(sample));
}
out
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Minimal WAV writer (mono 16-bit PCM) // Minimal WAV writer (mono 16-bit PCM)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1
View File
@@ -1,6 +1,7 @@
[package] [package]
name = "solitaire_core" name = "solitaire_core"
version.workspace = true version.workspace = true
license.workspace = true
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
+115 -3
View File
@@ -12,20 +12,25 @@
/// `StatsSnapshot`, the final `GameState`, and wall-clock time. /// `StatsSnapshot`, the final `GameState`, and wall-clock time.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AchievementContext { pub struct AchievementContext {
// Stats (after this win has been recorded). /// Total number of games played (after this win has been recorded).
pub games_played: u32, pub games_played: u32,
/// Total number of games won (after this win has been recorded).
pub games_won: u32, pub games_won: u32,
/// Current consecutive win streak (after this win has been recorded).
pub win_streak_current: u32, pub win_streak_current: u32,
/// Highest single-game score ever achieved.
pub best_single_score: u32, pub best_single_score: u32,
/// Cumulative score across all games ever played.
pub lifetime_score: u64, pub lifetime_score: u64,
/// Total wins completed in Draw 3 mode.
pub draw_three_wins: u32, pub draw_three_wins: u32,
// Progression.
/// Current daily-challenge completion streak (consecutive days). /// Current daily-challenge completion streak (consecutive days).
pub daily_challenge_streak: u32, pub daily_challenge_streak: u32,
// Last-win facts (GameWonEvent + GameState at win time). /// Score achieved in the just-won game.
pub last_win_score: i32, pub last_win_score: i32,
/// Elapsed seconds for the just-won game.
pub last_win_time_seconds: u64, pub last_win_time_seconds: u64,
/// `true` if `undo()` was called at least once during the won game. /// `true` if `undo()` was called at least once during the won game.
pub last_win_used_undo: bool, pub last_win_used_undo: bool,
@@ -55,13 +60,17 @@ pub enum Reward {
/// A single achievement's static metadata + unlock condition. /// A single achievement's static metadata + unlock condition.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct AchievementDef { pub struct AchievementDef {
/// Unique string identifier for this achievement (e.g. `"first_win"`).
pub id: &'static str, pub id: &'static str,
/// Human-readable display name shown in the achievements screen.
pub name: &'static str, pub name: &'static str,
/// Flavour text describing how to unlock the achievement.
pub description: &'static str, pub description: &'static str,
/// Hidden from the achievements screen until unlocked. /// Hidden from the achievements screen until unlocked.
pub secret: bool, pub secret: bool,
/// Reward granted on first unlock. `None` for cosmetic-only recognition. /// Reward granted on first unlock. `None` for cosmetic-only recognition.
pub reward: Option<Reward>, pub reward: Option<Reward>,
/// Predicate evaluated on every `GameWonEvent` and `StateChangedEvent`. Returns true when the achievement should unlock.
pub condition: fn(&AchievementContext) -> bool, pub condition: fn(&AchievementContext) -> bool,
} }
@@ -477,6 +486,109 @@ mod tests {
assert!(achievement_by_id("nonexistent").is_none()); assert!(achievement_by_id("nonexistent").is_none());
} }
// -----------------------------------------------------------------------
// Direct predicate tests via ctx_defaults()
// -----------------------------------------------------------------------
/// Baseline context representing a single clean one-minute win in Draw-One mode.
fn ctx_defaults() -> AchievementContext {
AchievementContext {
games_played: 1,
games_won: 1,
win_streak_current: 1,
best_single_score: 0,
lifetime_score: 0,
draw_three_wins: 0,
daily_challenge_streak: 0,
last_win_score: 0,
last_win_time_seconds: 600,
last_win_used_undo: false,
wall_clock_hour: Some(12),
last_win_recycle_count: 0,
last_win_is_zen: false,
}
}
#[test]
fn speed_demon_true_when_under_three_minutes() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 179;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"speed_demon"), "speed_demon should unlock at 179s");
}
#[test]
fn speed_demon_false_when_over_three_minutes() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 181;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"speed_demon"), "speed_demon must not unlock at 181s");
}
#[test]
fn lightning_true_when_under_90_seconds() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 89;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"lightning"), "lightning should unlock at 89s");
}
#[test]
fn lightning_false_at_exactly_90_seconds() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 90;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"lightning"), "lightning must not unlock at exactly 90s");
}
#[test]
fn no_undo_true_when_zero_undos() {
let mut c = ctx_defaults();
c.last_win_used_undo = false;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"no_undo"), "no_undo should unlock when undo was not used");
}
#[test]
fn no_undo_false_when_undo_used() {
let mut c = ctx_defaults();
c.last_win_used_undo = true;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"no_undo"), "no_undo must not unlock when undo was used");
}
#[test]
fn high_scorer_true_when_score_5000_or_more() {
let mut c = ctx_defaults();
c.best_single_score = 5_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"high_scorer"), "high_scorer should unlock at best_single_score=5000");
}
#[test]
fn high_scorer_false_when_below_5000() {
let mut c = ctx_defaults();
c.best_single_score = 4_999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"high_scorer"), "high_scorer must not unlock at best_single_score=4999");
}
#[test]
fn on_a_roll_true_at_streak_3() {
let mut c = ctx_defaults();
c.win_streak_current = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock at streak=3");
}
#[test]
fn comeback_true_when_three_or_more_recycles() {
let mut c = ctx_defaults();
c.last_win_recycle_count = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"comeback"), "comeback should unlock at last_win_recycle_count=3");
}
#[test] #[test]
fn on_a_roll_requires_streak_of_3() { fn on_a_roll_requires_streak_of_3() {
let mut c = ctx(); let mut c = ctx();
+4
View File
@@ -63,9 +63,13 @@ impl Rank {
/// A single playing card. /// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Card { pub struct Card {
/// Unique identifier for this card within the deal. Stable across moves and undo.
pub id: u32, pub id: u32,
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
pub suit: Suit, pub suit: Suit,
/// The card's rank (Ace through King).
pub rank: Rank, pub rank: Rank,
/// Whether the card is visible to the player. Face-down cards may not be moved.
pub face_up: bool, pub face_up: bool,
} }
+1
View File
@@ -12,6 +12,7 @@ const ALL_RANKS: [Rank; 13] = [
/// A standard 52-card deck. /// A standard 52-card deck.
pub struct Deck { pub struct Deck {
/// All 52 cards in the deck, in deal order.
pub cards: Vec<Card>, pub cards: Vec<Card>,
} }
+62 -3
View File
@@ -30,7 +30,9 @@ mod pile_map_serde {
/// Whether cards are drawn one at a time or three at a time from the stock. /// Whether cards are drawn one at a time or three at a time from the stock.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawMode { pub enum DrawMode {
/// Draw one card from stock per turn.
DrawOne, DrawOne,
/// Draw three cards from stock per turn; only the top is playable.
DrawThree, DrawThree,
} }
@@ -46,9 +48,13 @@ pub enum DrawMode {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum GameMode { pub enum GameMode {
#[default] #[default]
/// Standard Klondike rules with score and timer.
Classic, Classic,
/// No timer, no score display, ambient audio only.
Zen, Zen,
/// Fixed hard seeds, no undo, must win to advance.
Challenge, Challenge,
/// Play as many games as possible within 10 minutes.
TimeAttack, TimeAttack,
} }
@@ -64,18 +70,26 @@ struct StateSnapshot {
/// Full state of an in-progress Klondike Solitaire game. /// Full state of an in-progress Klondike Solitaire game.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GameState { pub struct GameState {
/// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles.
#[serde(with = "pile_map_serde")] #[serde(with = "pile_map_serde")]
pub piles: HashMap<PileType, Pile>, pub piles: HashMap<PileType, Pile>,
/// Whether the player draws one or three cards from the stock per turn.
pub draw_mode: DrawMode, pub draw_mode: DrawMode,
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards /// Top-level mode (Classic / Zen). Defaults to Classic for backwards
/// compatibility with older save files via `#[serde(default)]`. /// compatibility with older save files via `#[serde(default)]`.
#[serde(default)] #[serde(default)]
pub mode: GameMode, pub mode: GameMode,
/// Current game score. Can be negative (undo penalties subtract from score).
pub score: i32, pub score: i32,
/// Total moves made this game, including draws and stock recycles.
pub move_count: u32, pub move_count: u32,
/// Seconds elapsed since the game started, used for time-bonus scoring.
pub elapsed_seconds: u64, pub elapsed_seconds: u64,
/// RNG seed used to deal this game. Same seed always produces the same layout.
pub seed: u64, pub seed: u64,
/// True once all 52 cards are on the foundations. No further moves are accepted.
pub is_won: bool, pub is_won: bool,
/// True when the game can be completed without further input (all remaining cards are face-up and in order).
pub is_auto_completable: bool, pub is_auto_completable: bool,
/// Number of times `undo()` has been successfully invoked this game. /// Number of times `undo()` has been successfully invoked this game.
/// Used by achievement conditions like `no_undo`. /// Used by achievement conditions like `no_undo`.
@@ -173,6 +187,7 @@ impl GameState {
stock.cards.push(card); stock.cards.push(card);
} }
self.recycle_count = self.recycle_count.saturating_add(1); self.recycle_count = self.recycle_count.saturating_add(1);
self.move_count += 1;
return Ok(()); return Ok(());
} }
@@ -274,10 +289,9 @@ impl GameState {
.ok_or(MoveError::InvalidSource)? .ok_or(MoveError::InvalidSource)?
.cards .cards
.last_mut() .last_mut()
&& !top.face_up
{ {
if !top.face_up { top.face_up = true;
top.face_up = true;
}
} }
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved); self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
@@ -352,6 +366,15 @@ impl GameState {
/// Scans tableau piles 06 in order, returning the first top card that /// Scans tableau piles 06 in order, returning the first top card that
/// can be placed on any foundation pile. The scan order ensures Aces are /// can be placed on any foundation pile. The scan order ensures Aces are
/// resolved before higher ranks that depend on them. /// resolved before higher ranks that depend on them.
///
/// # Precondition
///
/// This function is only called when `is_auto_completable` is `true`.
/// Auto-completability requires the waste pile to be empty, as enforced by
/// [`check_auto_complete`](Self::check_auto_complete) — it returns `false`
/// whenever `piles[Waste]` is non-empty. Therefore, skipping the waste pile
/// in this scan is intentional and correct: by the time this function is
/// reached, there are guaranteed to be no cards there to move.
pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> { pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> {
if !self.is_auto_completable || self.is_won { if !self.is_auto_completable || self.is_won {
return None; return None;
@@ -562,6 +585,24 @@ mod tests {
assert_eq!(g.recycle_count, 2); assert_eq!(g.recycle_count, 2);
} }
#[test]
fn move_count_increments_on_recycle() {
let mut g = new_game();
// Drain stock to waste, recording how many draws it took.
let mut draws: u32 = 0;
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
draws += 1;
}
let before = g.move_count;
g.draw().unwrap(); // recycle
assert_eq!(
g.move_count,
before + 1,
"recycling waste back to stock must increment move_count (was {before}, draws={draws})"
);
}
#[test] #[test]
fn draw_from_empty_stock_and_waste_returns_error() { fn draw_from_empty_stock_and_waste_returns_error() {
// The only stop condition for draw() is: both stock AND waste are // The only stop condition for draw() is: both stock AND waste are
@@ -949,6 +990,24 @@ mod tests {
assert_eq!(g.compute_time_bonus(), 7000); assert_eq!(g.compute_time_bonus(), 7000);
} }
// --- EmptySource error path ---
#[test]
fn move_from_empty_pile_returns_empty_source() {
// Build a game state, clear a tableau pile entirely, then attempt to
// move from it. The source pile exists in `piles` (key is present) but
// contains no cards — exactly the code path that returns EmptySource.
let mut g = new_game();
// Tableau(0) starts with exactly 1 card; clear it to make the pile empty.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1);
assert_eq!(
result,
Err(MoveError::EmptySource),
"moving from an empty pile must return EmptySource"
);
}
// --- next_auto_complete_move --- // --- next_auto_complete_move ---
#[test] #[test]
+2
View File
@@ -17,7 +17,9 @@ pub enum PileType {
/// A named collection of cards in a specific board position. /// A named collection of cards in a specific board position.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pile { pub struct Pile {
/// Which pile this is (Stock, Waste, Foundation suit, or Tableau column).
pub pile_type: PileType, pub pile_type: PileType,
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
pub cards: Vec<Card>, pub cards: Vec<Card>,
} }
+1 -1
View File
@@ -91,6 +91,6 @@ mod tests {
fn time_bonus_is_capped_at_i32_max_for_huge_values() { fn time_bonus_is_capped_at_i32_max_for_huge_values() {
// Very short elapsed time would overflow without the .min() guard. // Very short elapsed time would overflow without the .min() guard.
let bonus = compute_time_bonus(1); let bonus = compute_time_bonus(1);
assert!(bonus <= i32::MAX, "time bonus must fit in i32"); assert!(bonus >= 0, "time bonus must be non-negative after u64→i32 cast");
} }
} }
+2 -1
View File
@@ -1,6 +1,7 @@
[package] [package]
name = "solitaire_data" name = "solitaire_data"
version.workspace = true version.workspace = true
license.workspace = true
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
@@ -12,6 +13,6 @@ chrono = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
keyring = { workspace = true } keyring-core = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
+16 -9
View File
@@ -8,9 +8,15 @@
//! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting //! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting
//! the user to log in again. //! the user to log in again.
//! //!
//! Before calling any function in this module the application must initialise
//! the default keyring store exactly once at startup by calling
//! `keyring::use_native_store` (e.g. in `solitaire_app::main` before building
//! the Bevy `App`). If no default store is set, all operations in this module
//! will return [`TokenError::KeychainUnavailable`].
//!
//! # Note: no unit tests — requires live OS keychain. //! # Note: no unit tests — requires live OS keychain.
use keyring::Entry; use keyring_core::Entry;
use thiserror::Error; use thiserror::Error;
/// Errors that can occur when reading or writing tokens in the OS keychain. /// Errors that can occur when reading or writing tokens in the OS keychain.
@@ -30,12 +36,13 @@ pub enum TokenError {
/// Service name used to namespace all keychain entries for this application. /// Service name used to namespace all keychain entries for this application.
const SERVICE: &str = "solitaire_quest_server"; const SERVICE: &str = "solitaire_quest_server";
/// Map a `keyring::Error` to the appropriate `TokenError`. /// Map a `keyring_core::Error` to the appropriate `TokenError`.
fn map_keyring_err(err: keyring::Error, username: &str) -> TokenError { fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
let msg = err.to_string(); let msg = err.to_string();
match err { match err {
keyring::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg), keyring_core::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
keyring::Error::NoEntry => TokenError::NotFound(username.to_string()), keyring_core::Error::NoDefaultStore => TokenError::KeychainUnavailable(msg),
keyring_core::Error::NoEntry => TokenError::NotFound(username.to_string()),
_ => TokenError::Keyring(msg), _ => TokenError::Keyring(msg),
} }
} }
@@ -88,17 +95,17 @@ pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
pub fn delete_tokens(username: &str) -> Result<(), TokenError> { pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
match Entry::new(SERVICE, &format!("{username}_access")) match Entry::new(SERVICE, &format!("{username}_access"))
.map_err(|e| map_keyring_err(e, username))? .map_err(|e| map_keyring_err(e, username))?
.delete_password() .delete_credential()
{ {
Ok(()) | Err(keyring::Error::NoEntry) => {} Ok(()) | Err(keyring_core::Error::NoEntry) => {}
Err(e) => return Err(map_keyring_err(e, username)), Err(e) => return Err(map_keyring_err(e, username)),
} }
match Entry::new(SERVICE, &format!("{username}_refresh")) match Entry::new(SERVICE, &format!("{username}_refresh"))
.map_err(|e| map_keyring_err(e, username))? .map_err(|e| map_keyring_err(e, username))?
.delete_password() .delete_credential()
{ {
Ok(()) | Err(keyring::Error::NoEntry) => {} Ok(()) | Err(keyring_core::Error::NoEntry) => {}
Err(e) => return Err(map_keyring_err(e, username)), Err(e) => return Err(map_keyring_err(e, username)),
} }
+1 -2
View File
@@ -148,8 +148,7 @@ mod tests {
#[test] #[test]
fn add_xp_saturates_on_overflow() { fn add_xp_saturates_on_overflow() {
let mut p = PlayerProgress::default(); let mut p = PlayerProgress { total_xp: u64::MAX - 5, ..Default::default() };
p.total_xp = u64::MAX - 5;
p.add_xp(100); p.add_xp(100);
assert_eq!(p.total_xp, u64::MAX); assert_eq!(p.total_xp, u64::MAX);
} }
+8 -16
View File
@@ -15,7 +15,7 @@ const APP_DIR_NAME: &str = "solitaire_quest";
const SETTINGS_FILE_NAME: &str = "settings.json"; const SETTINGS_FILE_NAME: &str = "settings.json";
/// Animation playback speed for card transitions. /// Animation playback speed for card transitions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AnimSpeed { pub enum AnimSpeed {
/// Standard animation timing (default). /// Standard animation timing (default).
#[default] #[default]
@@ -40,7 +40,8 @@ pub enum Theme {
/// Which sync backend the player has configured. /// Which sync backend the player has configured.
/// ///
/// JWT tokens for `SolitaireServer` are stored in the OS keychain via /// `Local` keeps all progress on-device. `SolitaireServer` syncs via the
/// self-hosted server. JWT tokens are stored in the OS keychain via
/// `solitaire_data::auth_tokens` — **never** in this struct. /// `solitaire_data::auth_tokens` — **never** in this struct.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub enum SyncBackend { pub enum SyncBackend {
@@ -57,10 +58,7 @@ pub enum SyncBackend {
username: String, username: String,
// JWT tokens are stored in the OS keychain — not here. // JWT tokens are stored in the OS keychain — not here.
}, },
/// Google Play Games Services (Android only). Selecting this on non-Android
/// platforms silently falls back to `Local` at runtime.
#[serde(rename = "google_play_games")]
GooglePlayGames,
} }
/// Persistent user settings. /// Persistent user settings.
@@ -207,8 +205,7 @@ mod tests {
#[test] #[test]
fn adjust_sfx_volume_clamps() { fn adjust_sfx_volume_clamps() {
let mut s = Settings::default(); let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
s.sfx_volume = 0.5;
assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6); assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6);
assert!((s.adjust_sfx_volume(0.5) - 1.0).abs() < 1e-6); assert!((s.adjust_sfx_volume(0.5) - 1.0).abs() < 1e-6);
assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6); assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6);
@@ -217,8 +214,7 @@ mod tests {
#[test] #[test]
fn adjust_music_volume_clamps() { fn adjust_music_volume_clamps() {
let mut s = Settings::default(); let mut s = Settings { music_volume: 0.5, ..Default::default() };
s.music_volume = 0.5;
assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6); assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6);
assert!((s.adjust_music_volume(0.5) - 1.0).abs() < 1e-6); assert!((s.adjust_music_volume(0.5) - 1.0).abs() < 1e-6);
assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6); assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6);
@@ -241,14 +237,10 @@ mod tests {
#[test] #[test]
fn sanitized_clamps_music_volume() { fn sanitized_clamps_music_volume() {
let mut s = Settings::default(); let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized();
s.music_volume = 2.0;
let s = s.sanitized();
assert_eq!(s.music_volume, 1.0); assert_eq!(s.music_volume, 1.0);
let mut s2 = Settings::default(); let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized();
s2.music_volume = -0.5;
let s2 = s2.sanitized();
assert_eq!(s2.music_volume, 0.0); assert_eq!(s2.music_volume, 0.0);
} }
+2 -3
View File
@@ -13,7 +13,7 @@ pub use solitaire_sync::StatsSnapshot;
/// ///
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`. /// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
pub trait StatsExt { pub trait StatsExt {
/// Record a completed win. Updates all relevant counters and rolling averages. /// Updates rolling statistics from a completed game win. Call once per `GameWonEvent`.
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode); fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
} }
@@ -173,8 +173,7 @@ mod tests {
#[test] #[test]
fn lifetime_score_saturates_at_u64_max() { fn lifetime_score_saturates_at_u64_max() {
let mut s = StatsSnapshot::default(); let mut s = StatsSnapshot { lifetime_score: u64::MAX - 100, ..Default::default() };
s.lifetime_score = u64::MAX - 100;
s.update_on_win(200, 60, &DrawMode::DrawOne); s.update_on_win(200, 60, &DrawMode::DrawOne);
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow"); assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
} }
+18 -15
View File
@@ -364,6 +364,10 @@ impl SyncProvider for SolitaireServerClient {
/// Deserialize a pull response body as [`SyncResponse`] and return its /// Deserialize a pull response body as [`SyncResponse`] and return its
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`]. /// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
///
/// Only HTTP 401 (Unauthorized) and 403 (Forbidden) are treated as
/// authentication errors. All other non-2xx statuses (5xx, 429, etc.) are
/// classified as network/transport errors so the UI shows the right message.
async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncError> { async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncError> {
let status = resp.status(); let status = resp.status();
if status.is_success() { if status.is_success() {
@@ -372,8 +376,12 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
.await .await
.map_err(|e| SyncError::Serialization(e.to_string()))?; .map_err(|e| SyncError::Serialization(e.to_string()))?;
Ok(sync_resp.merged) Ok(sync_resp.merged)
} else { } else if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
Err(SyncError::Auth(format!("server returned {status}"))) Err(SyncError::Auth(format!("server returned {status}")))
} else {
Err(SyncError::Network(format!("server returned {status}")))
} }
} }
@@ -391,14 +399,22 @@ async fn extract_leaderboard_body(resp: reqwest::Response) -> Result<Vec<Leaderb
/// Deserialize a push response body as [`SyncResponse`], or map non-200 /// Deserialize a push response body as [`SyncResponse`], or map non-200
/// statuses to the appropriate [`SyncError`]. /// statuses to the appropriate [`SyncError`].
///
/// Only HTTP 401 (Unauthorized) and 403 (Forbidden) are treated as
/// authentication errors. All other non-2xx statuses (5xx, 429, etc.) are
/// classified as network/transport errors so the UI shows the right message.
async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, SyncError> { async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, SyncError> {
let status = resp.status(); let status = resp.status();
if status.is_success() { if status.is_success() {
resp.json() resp.json()
.await .await
.map_err(|e| SyncError::Serialization(e.to_string())) .map_err(|e| SyncError::Serialization(e.to_string()))
} else { } else if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
Err(SyncError::Auth(format!("server returned {status}"))) Err(SyncError::Auth(format!("server returned {status}")))
} else {
Err(SyncError::Network(format!("server returned {status}")))
} }
} }
@@ -412,19 +428,12 @@ async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, Sync
/// This is the **one** place in the codebase that matches on [`SyncBackend`] /// This is the **one** place in the codebase that matches on [`SyncBackend`]
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>` /// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
/// and remains backend-agnostic. /// and remains backend-agnostic.
///
/// `GooglePlayGames` is Android-only; on desktop it silently falls back to
/// [`LocalOnlyProvider`].
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> { pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
match backend { match backend {
SyncBackend::Local => Box::new(LocalOnlyProvider), SyncBackend::Local => Box::new(LocalOnlyProvider),
SyncBackend::SolitaireServer { url, username } => { SyncBackend::SolitaireServer { url, username } => {
Box::new(SolitaireServerClient::new(url.clone(), username.clone())) Box::new(SolitaireServerClient::new(url.clone(), username.clone()))
} }
SyncBackend::GooglePlayGames => {
// GPGS is Android-only; fall back to no-op on desktop.
Box::new(LocalOnlyProvider)
}
} }
} }
@@ -470,12 +479,6 @@ mod tests {
assert_eq!(provider.backend_name(), "local"); assert_eq!(provider.backend_name(), "local");
} }
#[test]
fn factory_gpgs_falls_back_to_local() {
let provider = provider_for_backend(&SyncBackend::GooglePlayGames);
assert_eq!(provider.backend_name(), "local");
}
#[test] #[test]
fn factory_server_returns_server_client() { fn factory_server_returns_server_client() {
let provider = provider_for_backend(&SyncBackend::SolitaireServer { let provider = provider_for_backend(&SyncBackend::SolitaireServer {

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