Compare commits

..

19 Commits

Author SHA1 Message Date
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
131 changed files with 2047 additions and 421 deletions
+19 -14
View File
@@ -51,6 +51,7 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
- **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.
--- ---
@@ -67,11 +68,11 @@ 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/ # Assets embedded at compile time via include_bytes!() ├── assets/ # Loaded at runtime via AssetServer (audio is embedded via include_bytes!())
│ ├── cards/ │ ├── cards/
│ │ ├── faces/{rank}_{suit}.png # 52 individual card faces (120×168, generated by solitaire_assetgen) │ │ ├── faces/{RANK}{SUIT}.png # 52 card faces — xCards @2x artwork (LGPL-3.0)
│ │ └── backs/back_0.png back_4.png # placeholder patterns │ │ └── backs/back_0.png back_4.png # back_0 = xCards bicycle_blue; back_14 are generated patterns
│ ├── backgrounds/bg_0.png bg_4.png # placeholder textures │ ├── backgrounds/bg_0.png bg_4.png # generated textures
│ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL) │ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
│ └── audio/ │ └── audio/
│ ├── card_deal.wav │ ├── card_deal.wav
@@ -144,7 +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 (embedded via `include_bytes!()` — no `AssetServer` dependency) - 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`.
@@ -235,11 +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` | — | Embeds FiraMono-Medium font at compile time; exposes `FontResource` handle | | `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 |
@@ -248,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 |
@@ -296,7 +299,7 @@ struct CardImageSet {
backs: [Handle<Image>; 5], // indexed by selected_card_back setting backs: [Handle<Image>; 5], // indexed by selected_card_back setting
} }
// Project-wide font handle (FiraMono-Medium embedded at compile time) // Project-wide font handle (FiraMono-Medium loaded via AssetServer at startup)
struct FontResource(Handle<Font>); struct FontResource(Handle<Font>);
// Pre-loaded background PNG handles // Pre-loaded background PNG handles
@@ -772,11 +775,13 @@ Audio systems listen for Bevy events and never block the game thread.
### Rendering approach ### Rendering approach
Cards are Bevy `Sprite` entities with `Handle<Image>` from `CardImageSet`. Face-up cards use one of 52 individual face PNGs selected by `faces[suit][rank]` — rank and suit are baked into each image and no `Text2d` overlay is spawned. Face-down cards use `backs/back_N.png` indexed by `settings.selected_card_back`. `Text2d` labels are only used as a fallback when `CardImageSet` is absent (e.g. tests with `MinimalPlugins`). `CardImageSet` is populated at startup from `include_bytes!()` — no `AssetServer`. 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()`.
Backgrounds are Bevy `Sprite` entities with `Handle<Image>` from `BackgroundImageSet`. `BackgroundImageSet` is populated at startup from `include_bytes!()`. Backgrounds are Bevy `Sprite` entities with `Handle<Image>` from `BackgroundImageSet`. `BackgroundImageSet` is populated at startup by `table_plugin::load_background_images` via `AssetServer::load()`.
The font `FiraMono-Medium` is embedded via `include_bytes!()` at startup by `FontPlugin` and exposed as `FontResource` for use by all UI and text systems. 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.
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.
The `assets/` directory layout: The `assets/` directory layout:
@@ -1004,5 +1009,5 @@ Using `axum::test` and an in-memory SQLite database:
| `SyncProvider` trait, not `SyncBackend` match arms | `SyncPlugin` stays backend-agnostic and testable; new backends can be added without touching the plugin | 2026-04-20 | | `SyncProvider` trait, not `SyncBackend` match arms | `SyncPlugin` stays backend-agnostic and testable; new backends can be added without touching the plugin | 2026-04-20 |
| Dropped WebDAV backend | Redundant once the self-hosted server exists; removing it reduces surface area and simplifies settings UI | 2026-04-20 | | Dropped WebDAV backend | Redundant once the self-hosted server exists; removing it reduces surface area and simplifies settings UI | 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 | | Dropped GPGS backend | Redundant with the self-hosted server; adds JNI complexity for no user-visible benefit on the target platforms | 2026-04-28 |
| PNG assets embedded via `include_bytes!()` | Using `Image::from_buffer()` in startup systems rather than `AssetServer::load()` keeps the binary self-contained and eliminates runtime file-not-found errors | 2026-04-29 | | 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 |
| FiraMono-Medium font embedded via `include_bytes!()` | Exposed through `FontResource`; avoids runtime font loading errors on headless systems and ensures consistent text rendering across all platforms | 2026-04-29 | | 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 -1
View File
@@ -47,7 +47,9 @@ 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>`.
- Audio assets are embedded at compile time using `include_bytes!()` in `audio_plugin.rs`. Cards and backgrounds are rendered procedurally (colored `Sprite` entities + text) — no image files are used and no `AssetServer` is needed. - 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.
@@ -75,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.
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 2.3 MiB

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.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 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.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 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.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 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.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 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.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 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.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 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.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 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.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 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.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 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.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

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