Replace the bespoke WXP scoring engine with the upstream
card_game/klondike session stats, eliminating duplicated state that
could drift from the single source of truth.
score()/undo_count()/recycle_count() now read session.stats(); the -15
undo penalty is configured as SessionConfig::undo_penalty and applied by
the upstream score formula. Save schema bumped v4 -> v5 (the three
counters are no longer persisted -- they are rebuilt by replaying the
forward instruction history on load).
- Remove GameState fields score, undo_count, recycle_count (#87)
- Remove score_history / is_recycle_history undo journal (#86)
- Remove KlondikeAdapter::apply_undo_score and the score_for_* helpers,
plus pre_instruction_score_delta / will_flip_tableau_source (#84)
These three issues are a single atomic change: each removed field/helper
is consumed by the same draw/apply_instruction/undo/serde/PartialEq
paths, so they cannot compile or pass tests in isolation.
Behaviour changes (intentional): the escalating recycle penalty and
per-step score floor are gone (upstream linear scoring, floored once at
0); recycle_count is now cumulative; undo_count resets across save/load.
Refs #84, #86, #87
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-migration audit found the card_game/klondike migration essentially
complete; these are the four small redundancies that remained:
- core: delete dead GameState::compute_time_bonus (zero callers; engine
uses the klondike_adapter free fn directly)
- data: drop dead public re-exports load_latest_replay_from /
save_latest_replay_to (no callers outside replay.rs); keep
latest_replay_path (engine legacy migration still uses it)
- data+engine: lift win-XP scoring into a shared XpBreakdown so the
win-summary modal breakdown and xp_for_win share one source of truth
instead of duplicating the speed/no-undo constants
- engine: replace feedback_anim_plugin's private foundation_from_slot
copy with the canonical klondike_adapter::foundation_from_slot
cargo test --workspace + clippy -D warnings green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the (from, to, count) tuple as an internal move-passing wrapper.
Game logic now stays in KlondikeInstruction space end to end:
- Add GameState::apply_instruction, the native apply path. move_cards
becomes a thin pile-coordinate adapter that converts to an instruction
and delegates, so move bookkeeping (validation, score/recycle history,
undo snapshot) lives in one place instead of being duplicated.
- next_auto_complete_move matches DstFoundation directly instead of
projecting every candidate to pile coordinates.
- proptests and the storage round-trip test apply instructions directly
rather than round-tripping instruction -> tuple -> move_cards.
The single instruction -> pile decode is renamed instruction_to_highlight
-> instruction_to_piles and kept in core: decoding a tableau run length
needs upstream pile-stack types core does not re-export, so relocating it
would duplicate the logic across engine and wasm. The two rendering edges
(engine hint highlight, wasm debug move list) call this one decoder; the
engine's hint_piles is a thin delegation to it.
Also includes the CardEntityIndex render-side index and a SelectionPlugin
init_resource fix so update_selection_highlight no longer panics in test
harnesses that omit CardPlugin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three byte-identical copies of the stable 0..=51 card-id helper
(suit_index*13 + rank-1) lived in feedback_anim_plugin, radial_menu, and
solitaire_wasm. The WASM copy's own comment notes it MUST match the engine
for cross-platform replay parity — exactly the kind of invariant a single
source of truth should enforce.
Add `solitaire_core::card::card_to_id(&Card) -> u32` and have all three
call sites import it. No behaviour change (same formula).
cargo test --workspace and cargo clippy --workspace --all-targets -- -D warnings pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Rhys: card_game's solver is the real engine, so drop the redundant
adapter types in solitaire_data::solver rather than maintain a parallel
verdict/config/move vocabulary.
- Delete SolverResult, SolverConfig, SolverMove, and snapshot_to_solver_move.
The verdict now reads straight off card_game's return:
Ok(Some(instr)) = winnable (first move on the path)
Ok(None) = provably unwinnable
Err(_) = inconclusive (budget exceeded)
- SolveOutcome is now Result<Option<KlondikeInstruction>, SolveError>.
- try_solve / try_solve_from_state take plain (moves_budget, states_budget)
u64s; add DEFAULT_SOLVE_{MOVES,STATES}_BUDGET consts.
- snapshot_to_solver_move duplicated core's GameState::instruction_to_move,
so make that pub and have the hint convert the first-move instruction to
highlighted (from, to) piles through it. Re-export KlondikeInstruction
from solitaire_core.
- HintSolverConfig now holds { moves_budget, states_budget } instead of
wrapping the deleted SolverConfig.
- Update consumers: pending_hint, play_by_seed (verdict badge), game_plugin
(choose_winnable_seed), input_plugin, hud_plugin, and the gen_seeds /
gen_difficulty_seeds asset tools.
solver.rs drops 274 -> 140 lines. cargo test --workspace and
cargo clippy --workspace --all-targets -- -D warnings pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move both crates off the damaged "hacked" rev 99b49e62 onto mainline
master (card_game 0.4.0->0.4.1, klondike 0.3.0->0.4.0) to pick up the new
serialize implementation.
Mainline drops the serde derives from Deck/Suit/Rank (only Card is serde
now, as a compact transparent NonZeroU8) and gives KlondikeInstruction a
hand-written serde impl. Adapt the repo:
- Rank::value() was removed; the enum discriminant is the 1..=13 value, so
use `rank as u32/u8` in the three card_to_id helpers (wasm, radial_menu,
feedback_anim).
- Drop the vestigial Serialize/Deserialize derive on theme::CardKey; theme
manifests address faces by manifest_name strings, never by serialising
CardKey, and Suit/Rank no longer implement serde.
GameState's own instruction-mirror serde (schema v3/v4) is insulated from
the klondike serde change, so the on-disk save format is unchanged.
cargo test --workspace and cargo clippy --workspace -- -D warnings pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the draw_mode, move_count, is_won, and is_auto_completable fields
from GameState; they are now &self methods deriving from the underlying
card_game session (draw_mode from session config, move_count from history
length, is_won/is_auto_completable from check_win/check_auto_complete).
Tests previously fabricated these via direct field writes, which is no
longer possible. Add gated test-support overrides on TestPileState
(won/auto_completable/move_count) plus setters set_test_won,
set_test_auto_completable, set_test_move_count, and set_test_draw_mode
(re-deals the seed). All compiled out in production builds.
Fix the field->method ripple across solitaire_data, solitaire_wasm, and
solitaire_engine. Add a test-support dev-dependency to solitaire_data for
the won-game storage test.
cargo test --workspace and cargo clippy --workspace -- -D warnings pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finish the half-applied Card refactor. solitaire_core::card::Card is now an
alias for the opaque card_game::Card: suit()/rank() are methods, there is no
id or face_up field, and it is Clone+Eq+Hash but not Copy. Pile accessors
return Vec<(Card, bool)> where the bool is face-up.
Card identity is now the Card value itself (via Eq/Hash), not a numeric u32:
- CardEntity stores `card: Card` (was `card_id: u32`); lookups compare cards.
- Drag/selection collections and the touch/keyboard selection setters use
Vec<Card>; CardFlippedEvent/CardFaceRevealedEvent/HintVisualEvent carry Card.
- replay_overlay and feedback/settle/deal animations updated accordingly.
solitaire_wasm: CardSnapshot derives its JSON id from suit+rank (matching the
desktop engine), and consumes the (Card, bool) pile tuples.
test-support: TestPileState tableau overrides now carry a per-card face-up flag
so tests can place face-down tableau cards. set_test_tableau_cards keeps its
Vec<Card> signature (defaulting to face-up); new set_test_tableau_cards_with_face
takes Vec<(Card, bool)>.
cargo test --workspace passes (engine lib 897 ok, 0 failed); cargo clippy
--workspace --all-targets -- -D warnings is clean. Save/serde format unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Delete solitaire_core::solver — moved wholesale to solitaire_data::solver (re-exported at crate root)
- Delete solitaire_core::pile — no external users
- Move DrawMode from game_state to klondike_adapter; re-export as solitaire_core::DrawMode
- Remove schema_version field from GameState (redundant — deserializer stamps it from the constant)
- Update all callers across solitaire_data, solitaire_engine, solitaire_assetgen, solitaire_wasm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
apply_safe_area_to_modal_scrims now sets both padding.top (status-bar
height) and padding.bottom (gesture-bar height) on every ModalScrim.
With align_items/justify_content: Center on the scrim, the modal card
lands at the visual midpoint of the visible area between the two system
bars, fixing the slight upward shift that occurred when only the bottom
inset was applied.
Also: mark all rewrite-plan phases (0–3) complete; drop obsolete stash
whose 20 files are already incorporated into master; update CLAUDE.md
§14.3 to document both edges.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All downstream crates now import Foundation, KlondikePile, Tableau,
Klondike, Session, Suit, Rank exclusively from solitaire_core.
solitaire_core is the single version-pin point for the upstream crates.
- solitaire_engine: 19 files updated, klondike direct dep removed
- solitaire_wasm: use statement updated, klondike direct dep removed
- solitaire_data: unused klondike dep removed
- Cargo.lock: klondike no longer a direct dep of engine/wasm/data
- Full workspace clippy clean, all tests pass
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: fit_canvas_to_parent requests a wgpu surface sized in
physical pixels (CSS pixels × devicePixelRatio). On HiDPI displays
(DPR ≈ 2) the physical size (e.g. 2612×1469) exceeds WebGL2's per-
dimension texture limit of 2048, triggering a wgpu validation panic
that kills the WASM thread immediately on the first window resize.
Fix: add `resolution: WindowResolution::default().with_scale_factor_override(1.0)`
to the primary window so Bevy uses CSS/logical pixels as the surface
dimensions. For a 1306×734 CSS viewport this keeps the framebuffer well
within 2048 regardless of devicePixelRatio.
Also remove the temporary [drag] console logging added in the previous
commit — the panic was causing drag to never run, not a hit-test bug.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add warn!/info! calls to start_drag so every click that doesn't produce
a drag emits a console line with the cursor world position, stock/waste
sizes, and per-tableau pile lengths. This lets us see in browser DevTools
whether find_draggable_at is returning None (wrong hit position) or
something earlier in the pipeline is blocking.
Remove once root cause is identified.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OnboardingPlugin previously used PostStartup which fires before the
first Update tick — guaranteeing the onboarding modal and the launch
splash (MOTION_SPLASH_TOTAL_SECS = 1.6 s) overlap for the entire
splash duration. The splash sits at Z_SPLASH (the highest UI z-index),
so the two screens fought visually and the user saw a confusing frozen
composite before the splash faded out.
Fix: move spawn_if_first_run to Update and gate it on
`splashes.is_empty()` (no SplashRoot entity alive). A Local<bool>
ensures the spawn fires at most once per session. Cost: ~one frame of
latency after the splash clears, which is imperceptible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pile_positions[KlondikePile::Stock] stores the waste column position
(col_x(1)). card_plugin renders the face-down deck one column to the
left (col_x(0) = Tableau1 x) via `base.x -= tableau_col_step`.
handle_stock_click and handle_touch_stock_tap were using pile_positions
[Stock] directly, so the click hotspot was on the waste card (right
column) instead of the deck (left column). Result: clicking the
visible face-down deck did nothing, while clicking the waste pile
triggered draw.
Fix: compute deck_pos = Vec2::new(tableau1.x, waste_pos.y) and hit-test
both the deck column AND the waste slot. Accepting waste clicks matches
standard Klondike UX where either card acts as the draw trigger.
Touch tap handler receives the same fix.
Also rebuild canvas_bg.wasm with the corrected engine source and
-O2 optimisation (replacing the previous -Oz that caused grey screen).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Apply cargo fmt to solitaire_engine, solitaire_server formatting.
- solitaire_server/src/lib.rs: add https://analytics.aleshym.co to
script-src, img-src, and connect-src so the analytics beacon loads
without a CSP violation.
- docs and README updates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Web client (game.js):
- Restart game timer after undo exits auto-complete sequence
- Pause timer while browser tab is hidden (visibilitychange)
- Validate URL seed — NaN / negative falls back to randomSeed()
- Guard onBoardClick/onBoardDblClick during win (snap.is_won)
- Delay win overlay 320 ms so last card CSS transition finishes
- Force reflow in flashIllegal() to restart shake on rapid re-trigger
Android (safe_area.rs):
- Preserve last-known insets on app resume instead of zeroing them;
eliminates double layout flash on every foreground cycle
All clients — Bevy engine:
- Radial menu: clamp icon anchors to viewport bounds so icons are
never placed off-screen on narrow phones
- Auto-complete: deactivate state.active when is_auto_completable
goes false (undo mid-sequence) to stop perpetual background retry
- Touch selection: gate highlight rebuild on is_changed() — was
despawning/respawning entities every frame unnecessarily
- Input: fire "Tap a pile to move" InfoToast on first tap in
TapToSelect mode; document cursor_world 1:1 viewport invariant
- Drag threshold: raise desktop from 4 → 6 px to prevent accidental
drags from cursor jitter on HiDPI displays
Desktop / Android (solitaire_app):
- Call cleanup_orphaned_tmp_files() at startup to remove .tmp files
left by crashes between atomic write and rename
Design clarification (klondike_adapter.rs):
- Doc comment: Draw-1 recycling is penalty-only by design (never
blocked) to avoid creating unwinnable positions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The 'No Moves Available' dialog's New Game button and keyboard shortcut
were firing NewGameRequestEvent::default() (confirmed: false). When the
player has made moves, handle_new_game sees needs_confirm = true, then
hits the scrims.is_empty() guard — which is false because the GameOver-
Screen itself is a ModalScrim — and silently returns without starting a
new game or showing the confirm dialog.
Fix: set confirmed: true in both handle_game_over_input (N/Escape key)
and handle_game_over_button_input (click). The game is already stuck so
the abandon-confirmation guard does not apply, as the doc comment on the
button handler has always said.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Gate `Startup` and `user_theme_dir` imports in theme/registry.rs
behind `#[cfg(not(target_arch = "wasm32"))]` — they are only used
in the non-wasm code path, eliminating two unused-import warnings
in the WASM release build.
- Rebuild canvas_bg.wasm and solitaire_wasm_bg.wasm with wasm-opt -Oz
(binaryen v129); canvas_bg.wasm drops from 57 MB → 30 MB.
- Add solitaire_web/Cargo.toml stub to server Dockerfile so
`cargo fetch --locked` resolves all workspace members.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes found while testing the Bevy WASM build in a real browser:
1. chrono wasmbind: add `wasmbind` feature to workspace chrono dep so
Local::now()/Utc::now() use js-sys::Date on wasm32 (previously
fell through to std::time::SystemTime which panics on wasm32).
2. std::time::SystemTime: replace all remaining direct SystemTime::now()
calls (4 sites across game_plugin, difficulty_plugin, time_attack_plugin,
solitaire_data/storage) with chrono::Utc::now() which is wasm32-safe.
3. user_dir: return empty PathBuf (instead of panicking) when data_dir()
is None on wasm32; there is no filesystem in the browser so user themes
are unsupported and a benign empty path is correct.
4. ThemeRegistryPlugin: gate build_registry_on_startup to non-wasm32
(the filesystem scan for user themes has nothing to scan in the browser;
only the bundled embedded themes are available).
5. AssetMetaCheck::Never: configure AssetPlugin in solitaire_web to skip
`.meta` sidecar fetches — we don't ship .meta files, so the default
AssetMetaCheck::Always produced a 404 flood on every card/background asset.
Result: `http://localhost:<port>/play` boots in Firefox with zero errors
and renders the full Bevy game — home screen, onboarding modal, HUD all
visible. Assets load correctly from /assets/. Chromium has a separate
wgpu-27/ANGLE/GLES shader translation bug (not in our code); Firefox works.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace PileType with typed KlondikePile (Foundation/Tableau variants)
throughout solitaire_core, solitaire_wasm, and solitaire_engine;
ReplayMove now uses SavedKlondikePile for serialisation stability
- Split replay_overlay.rs into replay_overlay/ module (mod, format,
input, update, tests) for maintainability
- Add klondike dep to solitaire_engine and solitaire_data Cargo.toml
- Add TestPileState infrastructure to game_state.rs for engine unit tests
- Rebuild solitaire_wasm pkg (js + wasm artefacts updated)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#80: add AUTO_COMPLETE_INITIAL_DELAY (0.75 s) before the first
auto-complete move fires. Previously cooldown was 0.0, causing the
sequence to hijack the board the same frame the condition was met.
Closes#81: fire MoveRejectedEvent in radial_open_on_right_click when
the right-clicked card has no legal destinations, so the shake
animation and invalid-move sound play consistently on desktop/web.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
All per-frame animation tick systems now write MessageWriter<RequestRedraw>
each frame they have active work, allowing WinitSettings focused_mode to
switch from Continuous to reactive_low_power(100 ms) on Android.
Systems updated:
- advance_card_animations (CardAnimationPlugin)
- advance_card_anims (AnimationPlugin — deal/win cascade)
- tick_shake_anim, tick_settle_anim, tick_foundation_flourish (FeedbackAnimPlugin)
- drive_toast_display (AnimationPlugin — toast countdown)
- drive_auto_complete (AutoCompletePlugin — step interval keepalive)
The 100 ms low-power ceiling means the game timer still ticks ~10×/s
with no input; animations self-sustain via the redraw chain at full
frame rate while active; and the GPU is completely idle between frames
when the board is static.
Each plugin registers add_message::<RequestRedraw>() so the message
type is available under MinimalPlugins in unit tests.
Closes#78, #79
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
toggle_leaderboard_screen was missing the other_modal_scrims guard that
all other panel-toggle systems have. Pressing L (or the HUD button) while
any other modal was open would spawn a second ModalScrim on top of the
existing one, breaking z-ordering and leaving the first modal un-dismissable.
Adds:
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>
and the early-return guard before spawn_leaderboard_screen is called.
Closes#77
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pressing S (or the Stats HUD button) while another modal was open
(Settings, Profile, Leaderboard, etc.) would spawn a second ModalScrim
on top of the existing one, violating the one-scrim-at-a-time invariant.
Add other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>
matching the guard pattern used by every other modal-spawning system.
Also import ModalScrim which was previously not imported in this file.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- challenge_plugin: replace silent warn+return with InfoToast when all
challenges are completed so the player gets clear feedback (#72)
- input_plugin: add AutoCompleteState guard to start_drag,
touch_start_drag, and handle_double_tap so player input cannot race
with the auto-complete move sequence (#71)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
queries already in .sqlx cache; EXISTS variant would require sqlx prepare
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
All engine plugin registrations now live in CoreGamePlugin::build().
build_app() is reduced to DefaultPlugins setup + CoreGamePlugin registration.
sync_provider is threaded through CoreGamePlugin::new() via Mutex<Option<...>>.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fixes#34, #35, #36
- all_hints: add Foundation as source for Tableau hints (guarded by
take_from_foundation); previously H key never suggested Foundation→Tableau
- end_drag / touch_end_drag: enforce take_from_foundation at input layer
so a rejected-by-core MoveRequestEvent is never fired
- animation_plugin: pub CARD_ANIM_Z_LIFT so card_plugin can consume it
- update_card_entity: set CardAnim start.z = z + CARD_ANIM_Z_LIFT to
eliminate 1-frame z artifact where animated card appeared behind resting cards
- solitaire_app: use AutoVsync on Android (caps GPU at display Hz vs
spinning at 200+ fps); add WinitSettings unfocused reactive_low_power
so app draws ~1fps when backgrounded
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>