aapt2 --version-code/--version-name only inject when the attribute is
absent — they silently no-op when the manifest already has a value.
Removed both attributes from AndroidManifest.xml so the CI flags take
effect. Local debug builds fall back to code=1 / name=0.0.0-dev.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add avatar_plugin: AvatarPlugin, AvatarResource, AvatarFetchEvent
- After AvatarFetchEvent fires, spawns an async reqwest download task
- On completion, decodes image bytes via image::load_from_memory →
Image::from_dynamic and inserts into Assets<Image>
- Expand auth task to also call fetch_me_with_token immediately after
login/register so avatar_url is available without a second round-trip
- poll_auth_task fires AvatarFetchEvent when avatar_url is Some, building
the full URL from base_url + relative avatar path
- Profile modal shows 48px circular avatar ImageNode when AvatarResource
is populated, or an initials disc (first letter of username) as fallback
- Add image = "0.25" and reqwest to solitaire_engine deps
- Add fetch_me_with_token helper to SolitaireServerClient for use when
the access token hasn't been persisted to keychain yet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cargo-apk 0.10 and its fork cargo-apk2 both failed to discover the
installed Android platform in this Gitea runner, despite ANDROID_HOME,
platforms;android-34, build-tools, and NDK all being present, readable,
and pointed at correctly. We never isolated whether the bug is in the
shared ndk-build crate's discovery logic or in the runner's env-var
propagation through cargo subcommand exec, so this commit stops fighting
either tool and assembles the APK from explicit toolchain steps instead:
cargo ndk -> per-ABI .so files
aapt2 compile/link -> manifest + resources -> base APK
zip -> bundle native libs into lib/<abi>/
zipalign -> 4-byte alignment
apksigner -> v2/v3 signing (debug keystore for CI, real for release)
The pipeline lives in scripts/build_android_apk.sh so it's reproducible
locally (same env vars, same commands). AndroidManifest.xml is now
checked in under solitaire_app/android/ and mirrors what cargo-apk would
have generated from [package.metadata.android] — keep them in sync if
either is changed. Local `cargo apk build` still works on developer
machines where cargo-apk is happy; CI just stops depending on it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace all display-name occurrences across web pages, Rust source,
docs, and Cargo metadata. Update localStorage token key from sq_token
to fs_token. Tagline "Klondike Solitaire" retained as genre descriptor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SyncSetupPlugin: a three-field (URL / Username / Password) modal
that handles both login and register flows via an async task on
AsyncComputeTaskPool wrapped in a Tokio single-thread runtime (same
pattern as the existing sync push/pull). On success, tokens are stored
to the OS keychain / Android Keystore and SyncProviderResource is
hot-swapped so subsequent pull/push use the new credentials immediately.
Settings sync section now shows Connect (when Local) or Sync Now +
Disconnect + username label (when SolitaireServer). SyncAuthResultEvent
stub registered for future re-auth prompt wiring.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P3 — App-icon density buckets:
- Created solitaire_app/res/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/
ic_launcher.png from assets/icon/ (48→mdpi, 64→hdpi, 128→xhdpi,
256→xxhdpi+xxxhdpi). aapt downscales oversized buckets; no quality loss.
- Added resources = "res" to [package.metadata.android] so cargo-apk/aapt
packages the mipmap tree into the APK.
- Added icon = "@mipmap/ic_launcher" to [package.metadata.android.application]
so the launcher references the density-bucketed icon instead of the
default grey system icon.
P3 — Density-aware card scaling: investigated, no code change required.
WindowResized fires with logical pixels; 256×384 card textures are
downscaled on all current phone targets (40dp logical → 120px physical
at 3× DPI). Upscaling only occurs on tablets wider than ~765dp at 3× DPI.
P4 — B0004 hierarchy warnings: investigated, no fix required.
.despawn() is recursive in Bevy 0.18; warnings are startup timing
artifacts (UI components propagating before parent initialises), not
gameplay bugs. No crashes or defects in 2+ min AVD runtime.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the final two P2 Android playability items:
1. HUD typography — new `update_hud_typography` system fires on
`WindowResized` and adjusts Tier-1 font sizes: below 480 logical px
Score drops HEADLINE(26)→BODY_LG(18) and Moves/Timer drop
BODY_LG(18)→CAPTION(11), so all three fit in the 180dp HUD column
on a 360dp phone without wrapping.
2. Orientation lock — `[package.metadata.android.application.activity]`
with `orientation = "portrait"` in solitaire_app/Cargo.toml; cargo-apk
maps this to `android:screenOrientation="portrait"` in the generated
AndroidManifest.xml.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
`AssetPlugin::file_path = "../assets"` was set unconditionally to
make `cargo run -p solitaire_app` find the workspace-root assets
directory from inside `solitaire_app/`. On Android cargo-apk packages
the same directory into the APK at `assets/`, and Bevy's
AndroidAssetReader is already rooted there — prepending `../` walked
the reader out of the APK assets root and every load failed silently.
The cascade: CardImageSet handles were inserted but pointed at
non-existent paths, so `card_sprite` saw `Some(set)` but the textures
never resolved. The face-down branch then rendered with `Color::WHITE`
over a missing texture — which on hardware showed as the
`card_back_colour(0)` solid-red brick fallback that's *supposed* to
only fire under MinimalPlugins in tests.
Gates the `file_path` override behind
`#[cfg(not(target_os = "android"))]` so Android picks up the default
empty path. Closes P0 #3 of docs/android/PLAYABILITY_TODO.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SafeAreaInsets resource + SafeAreaInsetsPlugin that report the
OS-reserved regions (status bar, gesture/nav bar, display cutout)
around the playable surface. Desktop reports all zeros; Android
queries WindowInsets.getInsets(systemBars()) via JNI on the decor
view, polling for up to 120 frames since getRootWindowInsets()
returns null until the view is laid out.
UI that should respect the top inset carries a SafeAreaAnchoredTop
{ base_top } marker. A change-detection system re-applies
`base_top + insets.top` whenever the resource changes, so late
inset arrival (frame 1-3 on Android) and future orientation
changes flow through without re-spawning entities.
Wires the three top-anchored HUD spawn sites — hud_band, hud
column, action button row — to the new pattern. Spawn systems
take Option<Res<SafeAreaInsets>> so HudPlugin still works
standalone in unit tests (mirrors the existing FontResource
pattern).
Closes P0 #1 of docs/android/PLAYABILITY_TODO.md. Resolves the
status-bar/HUD collision visible in the v0.22.3 hardware
screenshot.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These three bevy::window types are only referenced by
apply_smart_default_window_size, which is already cfg(not(android)).
The unconditional import triggered -D unused-imports on the Android
cross-compile. Split into a separate cfg-gated use statement.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both symbols are desktop-only: the variable feeds apply_smart_default_
window_size which is only registered inside a cfg(not(android)) block.
Without the matching cfg gate on the declaration / definition, the
Android cross-compile emits unused-variable and dead-code errors
(-D warnings turns them into hard failures).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds DifficultyLevel (Easy/Medium/Hard/Expert/Grandmaster/Random) to
solitaire_core::game_state alongside GameMode::Difficulty(DifficultyLevel).
Five seed catalogs (40 seeds each) are pre-verified by the new
gen_difficulty_seeds binary using tiered solver budgets (1K–200K moves).
DifficultyPlugin resolves StartDifficultyRequestEvent → catalog seed →
NewGameRequestEvent; Random uses a system-time seed and bypasses the
winnable-only filter. The home overlay gets an expandable Difficulty section
between Draw Mode and the mode grid; last-played tier persists in Settings.
Difficulty wins pool into Classic stats. 5 unit tests in difficulty_plugin.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a numeric-input modal (PlayBySeedPlugin) that lets the player type
a decimal seed and receive an instant solver-verified verdict before the
hand is dealt. A new HomeMode::PlayBySeed card surfaces it in the home
overlay, matched by the StartPlayBySeedRequestEvent carrier.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three changes to get the APK past the NativeActivity launch crash:
1. Export `android_main` — NativeActivity dlopen-s libsolitaire_app.so
and calls `android_main` as its entry point. Without the symbol the
app crashed immediately with UnsatisfiedLinkError. The function sets
bevy::android::ANDROID_APP (required by WinitPlugin) then delegates
to the existing `run()`.
2. Gate `resize_constraints` to non-Android — on Android max_width and
max_height default to 0.0; Bevy's clamp panicked with min=800 > max=0.
3. Gate `apply_smart_default_window_size` to non-Android — the system
calls `.clamp(800.0, logical_w)` which panics when the window surface
reports zero dimensions during early Android lifecycle events. Window
sizing is OS-controlled on Android so the system is irrelevant there.
Verified: app boots on x86_64 Android 14 emulator (Pixel_7 AVD,
SwiftShader Vulkan), runs for 2+ minutes without crashing. Desktop
build: clippy clean, 1282 tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
`NonSend<WinitWindows>` failed system-param validation on the
first few frames before `WinitWindows` was populated, panicking
the Update system before any logic could run. Bevy 0.18's
stricter validation panics rather than skips when a non-send
resource is absent, with an error message spelling out the fix:
*"wrap the parameter in `Option<T>` and handle `None` when it
happens."*
Wraps `winit_windows` as `Option<NonSend<WinitWindows>>` and
early-returns on `None`, mirroring the same lifecycle handling
already applied to `winit_windows.get_window(primary_entity)` —
both fail in the same window of frames before winit's `Resumed`
event fires.
Repro from the user's `cargo run` log:
```
thread 'Compute Task Pool (2)' panicked at .../bevy_ecs-0.18.1/src/error/handler.rs:125:1:
Encountered an error in system ...: Parameter ... failed validation:
Non-send resource does not exist
```
Workspace clippy + cargo test --workspace clean, 1185 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes Resume-prompt Option A (the post-v0.21.0 first option).
Half-day desktop work, no cert dependency.
Three deliverables:
1. **SVG-authored icon** (`solitaire_engine/src/assets/icon_svg.rs`)
— square Terminal mark: `#151515` background, brick-red
`#a54242` 1 px border, brick-red ▌ cursor block centered, "RS"
monogram in `#d0d0d0` foreground gray beneath. Same shape that
already lives on the splash boot screen and card-back monogram,
reused as the project's signature visual mark. Authored in a
64-unit logical box so it scales cleanly at every rasterisation
target.
2. **9-size PNG hierarchy** (16, 24, 32, 48, 64, 128, 256, 512, 1024
px) regenerated by `solitaire_engine/examples/icon_generator.rs`
into `assets/icon/icon_<size>.png`. Sizes cover Linux hicolor
(16, 24, 32, 48, 64, 128, 256, 512), Windows .ico targets (16,
32, 48, 256), and macOS .icns targets (16, 32, 64, 128, 256,
512, 1024). The runtime path uses just the 256 px slot; the
smaller sizes are pre-rendered for downstream packaging.
3. **Runtime `Window::icon` wiring** (`solitaire_app/src/lib.rs`).
Bevy 0.18 has no `Window::icon` field — the icon is set through
the underlying `winit::window::Window` via the `WinitWindows`
resource. `set_window_icon` runs each Update tick, retries
silently until `WinitWindows` is populated (typically frame 1
or 2), decodes the embedded 256 px PNG via `tiny_skia`, builds
a `winit::window::Icon`, and self-disables via `Local<bool>`.
Same one-shot pattern as `apply_smart_default_window_size`.
Desktop-only — Android draws its launcher icon from the APK
manifest, so the system is target-gated to
`cfg(not(target_os = "android"))`.
Dep changes (CLAUDE.md §8 user-confirmed):
- `winit = "0.30"` promoted from a transitive Bevy dep to a direct
dep on `solitaire_app` so `winit::window::Icon` is in scope —
bevy_winit 0.18 doesn't re-export it. Version pinned to whatever
Bevy uses; if Bevy bumps winit, this line bumps in lockstep.
- `tiny-skia` added as a direct dep on `solitaire_app` for PNG →
RGBA decode. Already in workspace deps for `solitaire_engine`;
no version drift risk.
- Both new deps target-gated to non-Android only.
Test infrastructure: `solitaire_engine/tests/icon_svg_pin.rs`
hashes the rasterised RGBA bytes at all 9 sizes via FNV-1a (same
shape as `card_face_svg_pin`). Bootstrap pattern (empty EXPECTED
→ panic with hashes formatted as Rust source → paste back in)
handles future intentional builder edits cleanly.
Workspace clippy + cargo test --workspace clean. 1185 passing
(+1 from v0.21.0's 1184 baseline — the icon pin's
`rasterised_icon_bytes_match_pinned_hashes`).
Out of scope for this commit: `.icns` / `.ico` bundling for
macOS / Windows app packaging. Both are packaging-time concerns
(set via bundle manifests, not runtime calls) and would need new
deps (`ico` and `icns` crates) — separate followup if/when the
project ships as a packaged macOS / Windows app rather than just
`cargo run`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the workspace through `cargo apk build`. After this commit
`cargo apk build -p solitaire_app --target x86_64-linux-android`
produces a debug-signed APK at `target/debug/apk/solitaire-quest.apk`
containing all assets and `lib/x86_64/libsolitaire_app.so` — runnable
on the AVD or a physical x86_64 device.
The five gating points discovered by iterating compile cycles:
1. solitaire_app split into bin + lib. cargo-apk needs a `cdylib`
to bundle as `libmain.so`; pure-bin crates panic with
"Bin is not compatible with Cdylib". `src/lib.rs` carries the
ECS bootstrap as `pub fn run`; `src/main.rs` is a 3-line shim
that delegates for the desktop `cargo run` path.
2. `[package.metadata.android]` pins target SDK 34 / min SDK 26
so cargo-apk doesn't probe for whatever default it ships
(which on this machine was an uninstalled API 30). `assets =
"../assets"` lets the same asset directory feed both desktop
and APK.
3. Workspace `bevy` features add `android-native-activity` (the
Bevy-side glue that pairs with cargo-apk's NativeActivity
wrapper). The feature is target-gated inside bevy_internal so
desktop builds compile it out.
4. `arboard` (clipboard, used by Stats's "Copy share link") has
no Android backend — `cargo apk build` fails with E0433 on
`platform::Clipboard` if unconditional. Target-gated to
`cfg(not(target_os = "android"))`; the system surfaces an
informational toast on Android until JNI ClipboardManager is
wired in the Phase-Android round.
5. `keyring` + `keyring-core` cannot compile for android — the
transitive `rpassword` uses `libc::__errno_location` which
bionic doesn't expose. Both crates target-gated; `auth_tokens`
ships a stub on Android that returns `KeychainUnavailable` for
every call, matching how callers already handle a Linux box
without Secret Service.
Cosmetic post-pass panic: cargo-apk panics AFTER the APK is signed
when it tries to also wrap the bin target. The APK on disk is
unaffected. Working around this with `cargo apk build --lib` is
the next small step.
What's verified:
- Desktop `cargo build`, `cargo clippy --workspace --all-targets`,
and `cargo test --workspace` all clean.
- `cargo apk build -p solitaire_app --target x86_64-linux-android`
produces 54 MB debug APK with libsolitaire_app.so + assets.
What's NOT yet verified:
- Whether the APK actually launches on the AVD / a phone (next
step: `adb install` + `adb logcat` against the bevy_test AVD).
- Whether `dirs::data_dir()` on Android returns a usable path
(sync / persistence will surface this if not).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Performance work for the upcoming Android port needs a numeric
baseline we can quote across desktop and mobile, instead of "feels
slow". DiagnosticsHudPlugin wraps Bevy's FrameTimeDiagnosticsPlugin
and renders a tiny corner readout the developer can toggle with F3.
- Hidden by default — production builds ship the plugin but the
overlay starts invisible.
- F3 reads ButtonInput<KeyCode> directly (not gated by pause /
modal state); diagnostics should always be reachable.
- Reads `smoothed()` FPS + frame_time so the cell isn't a jittery
per-frame scoreboard. Format: "FPS NN \u{2022} M.MM ms".
- Anchored top-right at z = Z_SPLASH + 100 so the readout sits
above every modal / toast / splash layer.
- Update system bails when hidden so we don't pay the
diagnostic-store lookup or text mutation when nobody's looking.
Next up on the perf track: get the Android build target wired so we
can put real numbers in this readout from a phone or emulator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
launch sizing
Players who specifically prefer the literal 1280×800 baseline on
every fresh-install launch had no way to opt out of the v0.19.0
smart-default sizer. Adds a Gameplay-section toggle (mirrors the
"Winnable deals only" pattern) so they can flip it off.
- New `Settings::disable_smart_default_size: bool` field with
`#[serde(default)]` so legacy `settings.json` files load to the
shipped behaviour (smart sizer enabled).
- Settings panel gains a "Smart window size" row with ON/OFF label
inverting the negative flag, and a tooltip clarifying that saved
window geometry always wins over both branches.
- `solitaire_app::main` reads the flag once at startup and skips
the `apply_smart_default_window_size` registration when it's set.
Mid-session changes apply on next launch (documented on the
field).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related platform-fit fixes for desktop launch:
1. Wayland session compatibility. The workspace Cargo.toml's
Bevy feature list previously enabled only `x11`, leaving
winit-on-Wayland to fall through to XWayland — the game
rendered inside an X11 frame stitched into the Wayland
compositor instead of as a native Wayland client. Adding
the `wayland` feature lets winit prefer Wayland when
WAYLAND_DISPLAY is set on the session, falling back to X11
when it isn't. Costs a few hundred KB of binary for the
libwayland-client bindings; comment in Cargo.toml explains
the trade.
2. Smart default window sizing. The fallback window size for
first launches (no saved geometry) was a fixed 1280x800. On
a 4K monitor that's a comparatively tiny window in one
corner; the game's cards then occupy a small physical area
even though the screen has plenty of room. New
`apply_smart_default_window_size` Update system queries
`Monitor` (with the `PrimaryMonitor` marker) and resizes the
primary window to ~70% of the monitor's *logical* size on
the first frame. Logical size already factors in the OS's
HiDPI scale factor, so:
- 1920x1080 / 1.0 scale → 1344x756 target
- 2560x1440 / 1.0 scale → 1792x1008 target
- 3840x2160 / 1.0 scale → 2688x1512 target
- 2880x1800 / 2.0 scale (Retina) → 1008x630 target
(same physical size as 1080p)
Clamped to the existing 800x600 minimum so old systems
don't get sub-minimum windows. Skipped entirely when saved
geometry was applied — the player's chosen size always
wins. Uses `Local<bool>` for one-shot semantics; the early-
exit per tick costs nothing once `*applied` is true.
Workspace: 1170 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Home (mode picker) was only reachable via M during gameplay, so
players who hadn't discovered the hotkey never saw the Daily / Zen /
Challenge / Time Attack entry points after the splash cleared.
- HomePlugin gains an `auto_show_on_launch` flag (default true) and a
matching `headless()` test constructor that disables it.
- spawn_home_on_launch flips a one-shot LaunchHomeShown flag once the
splash has cleared, gated on RestorePromptScreen / PendingRestoredGame
so the Welcome-back flow still takes precedence on machines with a
saved game.
- App entry uses HomePlugin::default(); both headless test fixtures
switch to HomePlugin::headless() so per-test worlds start clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes the Stats overlay's Watch Replay button from a stub
InfoToastEvent ("playback coming in a future build") to actually
starting in-engine playback via the new
replay_playback::start_replay_playback API. Pressing the button
when a replay exists resets the game to the recorded deal and the
ReplayOverlayPlugin's banner takes over.
The handler reads ReplayPlaybackState via Option<ResMut<...>> so
headless test fixtures that don't register ReplayPlaybackPlugin
keep compiling — they fall back to a descriptive "Replay ready"
toast. The "no replay yet" branch still surfaces the existing
"win a game first" toast.
Plugin registration in solitaire_app/src/main.rs picks up
ReplayPlaybackPlugin and ReplayOverlayPlugin alongside the existing
StatsPlugin. They run in any order — the playback plugin owns the
state resource, the overlay plugin spawns/despawns based on state
changes, and Stats's button handler dispatches into the playback
plugin's API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Power-user shortcut: hold right-click on a face-up card, a small
ring of icons appears around the cursor with one entry per legal
destination, release over an icon to fire MoveRequestEvent. Skips
the drag motion entirely while preserving the existing
RightClickHighlight tint on the actual pile markers.
A new RadialMenuPlugin owns the flow. RightClickRadialState is a
two-state enum (Idle / Active) carrying the source pile, lifted
cards, pre-computed legal destinations + their world anchors, the
ring centre, and the currently hovered icon index. Four chained
systems handle press → cursor track → release/cancel → redraw, in
that order so a single-tick test can't observe a half-state.
Mutual exclusion with the left-button mouse drag is implicit —
RadialMenuPlugin only listens to MouseButton::Right while the
existing drag pipeline only listens to Left. RightClickHighlight
co-exists at a lower z (50) than the radial overlay (Z_RADIAL_MENU
= 60), so the brief pile-marker tint reads as the same set of legal
destinations the radial offers.
Cancel paths: release the right button outside any icon, press Esc,
or press the left button. All three reset state to Idle without
dispatching a move.
Visual: a centre dot at the press location plus N icons at radius
80 px around it. For one destination the icon sits at 12 o'clock;
for N icons they spread evenly clockwise. Hovered icon scales to
1.15× and tints STATE_SUCCESS so the focused choice is unambiguous.
Twelve new tests pin the contract — five system-level (open on
press over face-up card, release over destination fires move event,
release in dead space cancels, Esc cancels, face-down doesn't
open), seven on the pure helpers (radial_anchor_for_index,
radial_hovered_index, legal_destinations_for_card). Tests inject
cursor positions through a RadialCursorOverride resource so they
work under MinimalPlugins where there's no PrimaryWindow or Camera.
help_plugin's controls reference gains a new "Mouse" section
covering double-click auto-move, right-click highlight, and the
new "Hold RMB" radial. Onboarding slide 3 is intentionally left
keyboard-only — the radial is a power-user discovery, not a
first-run teach.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the previous LogPlugin-filter approach (which suppresses the
warn message) with a fix at the source: a custom usvg FontResolver
that appends `sans-serif` and `serif` to every family-lookup query.
usvg's default selector queries fontdb with [SVG-requested families,
Serif] and emits `log::warn!("No match for '{family}'")` when the
query returns None. On systems without the SVG's named family (Arial
on Linux, etc.), every text node logs a warn even though the system
has perfectly good fonts available — the warn is a false negative
because fontdb's named-family lookup is exact-match only.
The new resolver appends both `Family::SansSerif` and `Family::Serif`
to the query, both resolved by fontdb (via fontconfig on Linux or
built-in defaults elsewhere) to whatever the system has installed.
The query now finds *some* face on any reasonably configured machine,
so `id.is_none()` is never true and the warn branch never fires. The
visible behaviour: SVGs that request unavailable named families now
silently use the system's default sans-serif font.
Reverts the LogPlugin filter from main.rs — silencing warns at the
log level was the wrong layer; fixing the lookup is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bundled hayeah card SVGs declare font-family="Arial" for rank/suit
text. usvg matches family names exactly, so on systems without Arial
installed (every Linux distro by default) every text node bridged a
log::warn! into our tracing output — 50+ lines per launch.
Two-part fix:
- svg_loader now populates a process-wide fontdb with system fonts
(lazy via OnceLock) so substitution actually has faces to fall
through to. usvg::Options::default() ships an empty fontdb, which
meant text glyphs had nothing to fall back on at all.
- LogPlugin extends DEFAULT_FILTER with usvg::text=error so the
residual "no match" warns drop. The substitution itself works; the
message is purely informational because Arial truly isn't installed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements Phase 6 of CARD_PLAN.md — discovers every available card
theme on startup so the future picker UI can list them.
solitaire_engine/src/theme/registry.rs
ThemeEntry { id, display_name, manifest_url, meta }
ThemeRegistry — Resource holding the entries; provides
find(id), iter(), len(), is_empty().
ThemeRegistryPlugin — Startup system that scans
user_theme_dir() and populates the registry.
build_registry(user_dir) — pure helper; takes the dir as a
parameter so tests use tempfile::tempdir() without touching
the global OnceLock-based user-theme path.
refresh_registry(&mut, user_dir) — replaces in-place; called
after a successful import_theme so a freshly-imported theme
appears in the picker without an app restart.
The bundled default entry is always inserted (id "default", served
from DEFAULT_THEME_MANIFEST_URL) so the picker has at least one
option even when no user themes exist.
Discovery is best-effort: a directory whose theme.ron is missing,
malformed, or fails ThemeMeta::validate is silently skipped — broken
themes don't poison the registry. Only the meta block is parsed
(via a derive(Deserialize) struct that ignores other manifest
fields), which keeps startup quick even with dozens of themes
installed.
Wired into solitaire_app/main.rs after ThemePlugin so the asset
sources are registered before discovery scans for theme.ron files.
10 new tests covering: empty user dir, nonexistent user dir, valid
user theme registers, full-manifest tolerance via meta-only parser,
malformed theme.ron skipped, invalid-meta theme skipped, directory
without theme.ron ignored, find() returns None for unknown id,
refresh_registry replaces stale entries, default-entry URL matches
the embedded constant.
cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
Implements Phase 4 of CARD_PLAN.md — the runtime hook that loads the
default theme on startup and refreshes the card-rendering pipeline
whenever the active theme changes.
solitaire_engine/src/theme/plugin.rs
ThemePlugin
init_asset::<CardTheme>, register_asset_loader for SvgLoader and
CardThemeLoader, Startup load_default_theme, and Update
sync_card_image_set_with_active_theme.
ActiveTheme(Handle<CardTheme>)
Resource pointing at the currently-loaded theme.
set_theme(commands, asset_server, theme_id)
Public API for switching themes — formats the URL as
`themes://<theme_id>/theme.ron` and updates the resource.
Integration approach: rather than refactor every `card_plugin.rs`
spawn site to read from `Assets<CardTheme>` directly, the sync system
writes the theme's face/back image handles into the existing
`CardImageSet` resource on `AssetEvent::LoadedWithDependencies` /
`Modified`, then fires `StateChangedEvent`. The existing
`sync_cards_on_change` pipeline rebuilds card sprites from the new
handles on the next tick — observable behaviour matches the plan's
intent (theme switches propagate immediately) while keeping
card_plugin's 1929-line surface area untouched.
Theme.back is mapped onto `CardImageSet.backs[0]` (the default-back
slot xCards previously occupied); `backs[1..=4]` are the
asset-generator patterns and remain user-selectable independent of
the active theme.
Added to solitaire_app/main.rs as `add_plugins(ThemePlugin)` after
`AssetSourcesPlugin` so the asset sources are registered before the
default-theme load is dispatched.
6 new tests covering suit/rank index mapping (matching the
`card_plugin` doc-commented `[suit][rank]` layout), empty-theme
no-panic, back-slot overwrite, and the URL format from `set_theme`.
cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (950 passed, 0 failed, 9 ignored).
Implements Phase 3 of CARD_PLAN.md — the embedded:// + themes:// asset
sources the card-theme system loads from. The bundled default-theme
manifest ships in the binary via Bevy's EmbeddedAssetRegistry; user
themes load from user_theme_dir() through a FileAssetReader-backed
source registered as `themes://`.
Registration is split across:
register_theme_asset_sources(&mut App)
Called BEFORE DefaultPlugins. Registers `themes://` while
AssetSourceBuilders is still mutable.
AssetSourcesPlugin
Added AFTER DefaultPlugins. Populates the EmbeddedAssetRegistry
that AssetPlugin's build step would otherwise overwrite.
Constants exposed for downstream consumers:
USER_THEMES = "themes" (asset-source name)
DEFAULT_THEME_MANIFEST_URL = "embedded://solitaire_engine/assets/themes/default/theme.ron"
Includes a stub default theme.ron (52 face slots + back) so
`ThemeManifest::validate()` accepts it today; PROVENANCE.md documents
the plan to drop in real SVG art (hayeah/playing-cards-assets) in a
follow-up.
4 new tests covering source registration, embedded-registry
population, manifest validation against the embedded stub, and the
manifest-URL constant matching the embedded asset path.
cargo check --workspace --all-targets / clippy --workspace
--all-targets -- -D warnings clean. cargo test could not be run in
this turn because the C linker (cc) is unexpectedly absent from the
sandbox; the test bodies compile cleanly under cargo check --tests
and will run on a normal toolchain.
Conservative cleanup pass — applied only the high-signal pedantic
lints whose fixes either remove genuine waste or read more naturally,
skipping anything stylistic that would bloat the diff.
- map_unwrap_or: 29 .map(...).unwrap_or(...) sites collapsed to
.map_or / .is_some_and / .map_or_else equivalents
- uninlined_format_args: 7 production format!/write!/println! sites
rewritten to the inline-argument style; assert! sites in test code
intentionally untouched
- match_same_arms: 2 redundant arms collapsed where the bodies were
identical and the merger didn't obscure intent
Public API is unchanged. No dependencies added or removed. The
pedantic warning count dropped from 840 to 807 (-33). Out-of-scope
findings — needless_pass_by_value on Bevy Res params, false-positive
explicit_iter_loop on Bevy Query iterators, items_after_statements
inside test mods, and the "ask before changing" merge logic in
solitaire_sync — were intentionally deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings gains an optional window_geometry field (size + position)
serialized via #[serde(default)] so legacy settings.json files without
the field deserialize cleanly to None. On launch the app restores
the persisted dimensions and position; first run and pre-upgrade
saves keep the existing 1280x800 centered default.
settings_plugin records changes from WindowResized and WindowMoved
into a PendingWindowGeometry resource and writes them to disk through
the existing atomic .tmp+rename path once the events have stayed
quiet for WINDOW_GEOMETRY_DEBOUNCE_SECS (0.5s). A merge_geometry
helper preserves whichever component (size or position) the latest
event burst didn't carry, so a position-only WindowMoved never wipes
the recorded size.
Pure should_persist_geometry and merge_geometry helpers are unit
tested for the boundary cases. Headless integration tests cover the
full flow: a single resize event then a quiet window persists, a
move event after a resize updates only position, a rapid storm
collapses to the final size, and a quiet frame with no events
leaves the geometry untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Smoke-test report: window resize was still laggy after the card-side
throttle landed. Diagnosis pointed at the wgpu / OS layer rather than
ECS work — Bevy's default PresentMode is AutoVsync (Fifo), which gates
every frame on the monitor's vblank. On X11 / Wayland the compositor
sends WindowResized events at high frequency during a drag and the
vsync gate stalls each one, producing visible lag even when the
downstream systems do almost no work.
AutoNoVsync prefers Mailbox (triple-buffered, no blocking) and falls
back to Immediate when the backend can't honour Mailbox. Either is
fine for solitaire — the frame budget is tiny and the occasional
dropped frame from disabling vsync is imperceptible compared to the
stall this fixes.
Layered with the prior in-place resize updates and the 50ms
ResizeThrottle, this should bring the window-drag feel from "really
laggy" to native-feeling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The window previously snapped straight to a card deal, which read more
like a prototype than a finished game. SplashPlugin lays a fullscreen
overlay (BG_BASE backdrop, ACCENT_PRIMARY title, version subtitle) on
top of the gameplay layer for MOTION_SPLASH_TOTAL_SECS — the board
deals behind it so the splash dissolve hands off naturally to the
deal animation.
Visibility curves through fade-in (300ms), hold (~1s), fade-out
(300ms) using a pure splash_alpha helper that gets pinned by a unit
test rather than wired to the Bevy clock — Time<Virtual>'s 250ms
per-tick clamp makes float-tight alpha assertions around the fade
boundary brittle.
Any keystroke or mouse-button press jumps the age forward to the
fade-out window so the splash dissolves immediately. The dismiss
handler is read-only on ButtonInput / Touches, so the same press is
still visible to gameplay handlers downstream — pressing Space on the
splash both dismisses it and triggers the next-tick stock draw, as
verified by dismissal_keypress_is_visible_to_other_systems.
Z_SPLASH sits above every other UI rung (Z_TOAST + 100) so the splash
owns the viewport for its brief lifetime. The hierarchy test was
extended to enforce the new rung's monotonic position.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A new ui_tooltip module owns a Tooltip(Cow<'static, str>) component
that turns any UI node into a hover-revealing help target. Bevy 0.18's
required-components attribute auto-inserts an Interaction so callers
just attach Tooltip and the rest is wired.
A single overlay entity is reparented above the focus ring (new
Z_TOOLTIP token = Z_FOCUS_RING + 10) and tracked from the hovered
target's GlobalTransform + ComputedNode. The chained Update systems
start a hover timer on Interaction::Hovered, show the overlay once
MOTION_TOOLTIP_DELAY_SECS (0.5s) has elapsed, hide it the moment hover
ends, and refresh the text when the hover target switches without an
intervening unhover.
Tested headless under MinimalPlugins with a 200ms ManualDuration
ticker — Bevy clamps Time<Virtual>'s max_delta to 250ms by default, so
a one-shot 1s step doesn't actually advance the clock past the
threshold; the tests step five times to exercise both pre- and
post-delay invariants.
This commit ships the infrastructure only — no entity in the engine
has Tooltip attached yet. A follow-up applies tooltips to the HUD
readouts and action bar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every button spawned via spawn_modal_button is now keyboard-navigable.
Tab/Shift-Tab cycles focus within the active modal, Enter activates
the focused button via the same Interaction::Pressed signal mouse
clicks use, and the primary action auto-focuses on modal open. Mouse
clicks transfer focus so the two input modes stay in sync.
The visual indicator is a single overlay entity that's reparented
above the topmost modal scrim and tracks the focused button's
GlobalTransform + ComputedNode each frame. Sitting outside the
modal-card subtree means the ring isn't affected by the open
animation's 0.96→1.0 scale, and sitting outside any scroll container
means it can't be clipped by Settings' Overflow::scroll_y. Z-order
sits one rung above Z_MODAL_TOP via the new Z_FOCUS_RING token.
Existing 11 modals (Help, Stats, Achievements, Settings, Profile,
Leaderboard, Pause, Forfeit confirm, GameOver, Confirm new game,
Onboarding, Home) get focus support without any call-site changes —
attach_focusable_to_modal_buttons walks the ancestry of any
ModalButton lacking Focusable to find its scrim and tags it
automatically. selection_plugin's Tab handler keeps working when no
modal is open; when one is, focus consumes Tab/Enter before the
selection system sees them.
Phase 1 scope only — HUD action bar, Home mode cards, and Settings
bespoke buttons (icon, swatch, toggle) come in Phase 2/3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sets Window::name so X11/Wayland taskbars group the game correctly,
centers the window on the primary monitor on startup, and installs a
panic hook that appends a timestamped crash record to crash.log under
the platform data dir (gracefully no-ops when the directory is
unavailable).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
- 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>
- 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>
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>
- 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>
Round 7 — Input & feedback
- H key cycles hints; F1 opens help (conflict resolved)
- N key cancels active Time Attack session
- Hint text distinguishes "draw from stock" vs "recycle waste"
- Forfeit (G) clears AutoCompleteState so chime does not bleed into new deal
- Zen mode timer clears immediately on Z press
- HUD shows recycle count in both draw modes
- Settings scroll position persisted across open/close
Round 8 — Polish & clarity
- Undo unavailable fires "Nothing to undo" toast
- Streak-break toast on forfeit/abandon when streak > 1
- F11 fullscreen toggle with toast; documented in help and home screens
- H-after-win, new-game countdown expiry, Tab-no-cards toasts
- Win cascade duration/stagger scales with animation speed setting
- Draw-Three cycle counter HUD ("Cycle: N/3")
- Forfeit requires G×2 confirmation within 3 s (mirrors N key)
Round 9 — Game feel & information
- Escape dismisses game-over/stuck overlay (PausePlugin skips Escape when overlay visible)
- Shake animation on rejected drag before snap-back
- Forfeit countdown cancels when any other key is pressed (U/H/D/Z/Space)
- Tab wrap-around fires "Back to first card" toast
- HUD selection indicator shows active Tab-selected pile in yellow
- Challenge time-limit HUD turns orange < 60s, red < 30s
- Win summary shows XP breakdown (+50 base, +25 no-undo, +N speed)
- Game-over overlay: "No more moves available" with clear N/Escape/G instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add FeedbackAnimPlugin with three card feedback animations:
- #54 ShakeAnim: horizontal shake on MoveRejectedEvent targeting
destination pile cards; 0.3 s damped sine wave
- #55 SettleAnim: Y-scale bounce on valid placement (StateChangedEvent);
1.0 → 0.92 → 1.0 over 0.15 s for all top-of-pile cards
- #69 Deal animation: slides each card from stock position to its deal
position on NewGameRequestEvent (move_count == 0), using existing
CardAnim with 0.04 s per-card stagger
Pure-function helpers shake_offset, settle_scale, and deal_stagger_delay
are public and covered by 6 unit tests. Fix pre-existing compile/clippy
errors: stubbed handle_confirm_input/handle_game_over_input, removed dead
CycleCardBack/CycleBackground variants, annotated ambient_handle field,
and fixed draw_mode.clone() in pause_plugin.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Task #27: Double-click auto-move — best_destination() finds optimal target
(foundation over tableau); handle_double_click() fires MoveRequestEvent.
Task #28: Hint system — find_hint() returns first legal from/to/count triple;
H key tints the source stack HintHighlight (yellow pulse via tick_hint_highlight).
Task #29: No-moves detection — has_legal_moves() checks stock/waste/all face-up
cards; check_no_moves system fires InfoToastEvent("No moves available") once per
stalemate (debounced so it fires only once until the state changes).
Task #30: Forfeit — G key fires ForfeitEvent; StatsPlugin records abandoned game,
persists stats, starts a new deal.
Task #37: Mute-all (M) and mute-music (Shift+M) toggles; MuteState resource
applied in apply_volume_on_change.
Task #39: Daily challenge HUD constraint label (time limit / target score).
Task #40: Undo-count HUD label; amber colour when undos > 0.
Task #44: Win-streak and level line on pause screen.
Task #48: Undo sound routes UndoRequestEvent → lib.flip audio channel.
Task #49: Onboarding banner rich-text key highlights — D and H rendered as
orange KeyHighlightSpan children so they stand out from body text.
Also registers CursorPlugin in solitaire_app (tasks #31/#32 wire-up).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds HudPlugin with a persistent top-left overlay that shows score,
move count, and elapsed time during every game. A mode badge highlights
DAILY, CHALLENGE, ZEN, or TIME ATTACK when the game is not in Classic
mode. HUD updates whenever GameStateResource changes (moves and per-second
time ticks) without a separate polling system.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Press L to open a leaderboard overlay. On open, an async fetch is
dispatched against the active SyncProvider. Results cache in
LeaderboardResource; the panel rebuilds live when data arrives.
Closing during a fetch is safe (ClosedThisFrame flag prevents
re-spawning the panel in the same frame as the user's despawn command).
Format: ranked table with player name, best score, and fastest win time.
Non-authenticated / LocalOnly providers return an empty list gracefully.
- solitaire_data: add fetch_leaderboard() to SyncProvider trait
(default → Ok([])) and implement in SolitaireServerClient
- solitaire_engine: new LeaderboardPlugin with 5 unit tests
- solitaire_app: register LeaderboardPlugin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When is_auto_completable flips true (stock/waste empty, all cards
face-up), AutoCompletePlugin fires MoveRequestEvent every 120 ms,
driving cards to the foundation one at a time without player input.
An "Auto-completing…" toast announces the sequence.
- solitaire_core: add next_auto_complete_move() to GameState with
3 new unit tests
- solitaire_engine: new AutoCompletePlugin with detect + drive systems
and 4 unit tests; animation_plugin shows one-shot toast on activation
- solitaire_app: register AutoCompletePlugin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>