The blanket `no-cache` from the earlier fix (#99) regressed the web-e2e cycle
regression gate: it reloads /play-classic 240 times, and with no-cache the
browser re-validated and recompiled the wasm on every load, blowing past the
30s bridge-ready timeout (green at #98, red from #99 onward).
Scope no-cache to just the `include_str!` HTML routes (which change on every
deploy and have no validators — the actual staleness source). The `/web` +
`/assets` ServeDir keep their default Last-Modified caching, so repeated page
loads reuse the downloaded/compiled wasm and the cycle gate is fast again. HTML
freshness — the fix Rhys needed — is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2048 surface limit was never wgpu's or the GPU's — it's
downlevel_webgl2_defaults().max_texture_dimension_2d (2048), which
WgpuSettingsPriority::WebGL2 forces. Switch to Functionality: on the WebGL2
(Gl) backend Bevy then adopts the adapter's real limits, which are still
WebGL2-constrained for features/buffers (shaders stay GLES-compatible) but
report the GPU's true max texture dimension (e.g. 16384). The device is
requested with exactly what the adapter offers, so creation can't fail, the
surface is no longer capped, and large viewports (4K) render with no letterbox
and no hardcoded cap.
Removes the resize_constraints cap and the play.html max-width/height caps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous attempts (#97/#98) capped a wrapper element's max-width and
relied on the canvas's width:100% resolving against it — but winit observes
and sizes the *canvas element itself* (via ResizeObserver on its content box),
so the wrapper cap never reached the surface and /play still panicked at
2560x1440 on a 4K@150% viewport.
Use the canonical mechanism instead: set the primary Window's
`resize_constraints { max_width: 2048, max_height: 2048 }`. On web, Bevy maps
this to winit `set_max_inner_size` → the canvas's own `max-width`/`max-height`
style, so the wgpu surface can never exceed wgpu's downlevel_webgl2
max_texture_dimension_2d (2048). play.html mirrors the same `max-*` directly on
#bevy-canvas (belt-and-suspenders) and centres the letterbox with margin:auto;
the obsolete wrapper + clamp script are removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Returning players kept getting stale builds: after the /play canvas fix
deployed, the origin served the new play.html (verified — 8/8 cache-busted
requests) but browsers still rendered the old one even after a hard reload.
Cause: the server sets no Cache-Control on the web router. The HTML pages are
include_str!'d into the binary and the wasm-bindgen output (canvas.js,
canvas_bg.wasm, solitaire_wasm.*) uses fixed filenames that change in place on
every deploy, so browsers heuristically cache them indefinitely.
Add `Cache-Control: no-cache` in the security_headers middleware (which wraps
the web router). Browsers now revalidate before using a cached copy; ServeDir
supplies Last-Modified/ETag so unchanged assets still return a cheap 304, while
changed ones (a new deploy) are re-fetched. Stops the stale-build problem for
everyone going forward.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correction to the previous canvas clamp (#97), which would NOT have fixed the
crash. It capped the wrapper to the device's gl.MAX_TEXTURE_SIZE, but that's
the hardware limit — on a 4K/integrated GPU it reports 8192+, so the wrapper
was never actually capped and the 2560-wide surface still exceeded the limit.
The real ceiling is wgpu's, not the hardware's: on wasm Bevy creates the device
with Limits::downlevel_webgl2_defaults() (forced by WgpuSettingsPriority::WebGL2
in solitaire_web/src/lib.rs; see bevy_render-0.18.1 settings.rs), whose
max_texture_dimension_2d is a fixed 2048 regardless of GPU. So the cap must be
the constant 2048.
(For the record: the reporter's display is 3840x2160 at 150% scale → a 2560x1440
logical viewport, which is the 2560 in the panic — nothing hardcodes 1440p.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rhys hit a fatal wgpu validation panic loading /play on a 1440p display:
Surface::configure ... Requested was (2560, 1440), maximum extent for
either dimension is 2048
The earlier scale_factor_override(1.0) fix only neutralised HiDPI (CSS×DPR);
it didn't help when the *logical* viewport itself exceeds 2048. `fit_canvas_to_
parent` sizes the wgpu surface to the canvas's parent, so a 2560-wide viewport
configures a 2560-wide surface — past WebGL2's 2048 per-dimension limit on
laptop/integrated GPUs, and the panic kills the WASM thread on the first frame.
Wrap the canvas in #bevy-wrap and, before init, clamp that element's max-
width/height to the device's actual gl.MAX_TEXTURE_SIZE (falling back to the
2048 WebGL2 floor). fit_canvas_to_parent then never produces a surface larger
than the GPU allows. Only devices at the 2048 floor letterbox (centered); GPUs
that report 4096/8192 still fill the viewport.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The /play-classic timer ran at double speed. startTimer() always created a new
setInterval and overwrote timerInterval without clearing the old one, so any
extra call leaked a second interval that also incremented elapsedSecs. The
visibilitychange handler calls startTimer() on "visible", and a load-time
visibilitychange (while startGame's timer is already running) stacks a second
interval — after which stopTimer() only clears one, so the leak persists and
the clock counts ~2x forever.
Guard startTimer() to no-op when an interval is already running. This fixes the
two e2e timer tests that surfaced it once the suite actually ran (they were
asserting 0:03 / 0:04 but seeing 0:06 / 0:13), and the user-visible 2x timer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rebuild-and-diff freshness gate (#93) could never pass: the Bevy wasm
artifacts aren't byte-reproducible across machines. Confirmed conclusively —
identical rustc 1.95.0 / LLVM 22.1.2, identical flags, Cargo.lock and remapped
source paths still yield host-dependent output (CI's build was 248 KB smaller
than a local one), while same-machine rebuilds are bit-identical. Path
remapping (kept in build_wasm.sh) is necessary but not sufficient.
Make CI the single source of truth instead: `web-wasm-rebuild` rebuilds pkg/ on
every master change to a wasm-feeding crate and commits it back. The deployed
artifacts can't silently rot and contributors no longer hand-run build_wasm.sh.
The pkg/ commit isn't in this workflow's trigger paths (no loop) but is in
docker-build's, so the refreshed wasm deploys.
Removes the false-failing web-wasm-freshness workflow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two latent test bugs surfaced once the e2e suite actually ran (it had been
red on the webServer startup timeout, so these never executed): 14 passed,
4 failed.
- game.js `__FERROUS_DEBUG__` was missing `serialize()` — play.html's bridge
has it but the /play-classic bridge (which the resume/move-history tests
use) drifted. Added it (the wasm SolitaireGame already exposes serialize()).
- game_behaviors.spec.js called `page.clock.tick()`, which is the sinon name;
Playwright's Clock API method is `page.clock.runFor()`. Replaced all 6 calls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The web-wasm-freshness gate (#93) false-failed because the Bevy wasm build
baked in machine-specific absolute source paths — the cargo registry
(/home/<user>/.cargo/registry/...), the rustup std sources (~/.rustup/...),
and this checkout — so CI's rebuild (under /workspace, /root) never matched the
committed bytes even with identical pinned tool versions.
build_wasm.sh now exports CARGO_ENCODED_RUSTFLAGS with three --remap-path-prefix
rules (cargo home -> /cargo, rustup home -> /rustup, repo -> /build) so the
embedded paths are identical on any machine. It re-states the
getrandom_backend cfg because a *_RUSTFLAGS env var replaces (not merges with)
.cargo/config.toml's target rustflags.
Regenerated all four artifacts with the remap applied — verified zero
machine-specific paths remain (only canonical /cargo + /rustup prefixes), which
is the precondition for the gate's byte-for-byte rebuild-and-diff to match
across machines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The web-e2e job has been failing on every run with:
Error: Timed out waiting 120000ms from config.webServer.
Playwright's `webServer` runs `cargo run -p solitaire_server --quiet` and
waits 120s for /health. The workflow cached only npm — no Rust cache — so each
run cold-compiled the entire server graph (axum/sqlx/reqwest/aws-lc-sys) inside
that 120s window and never came up. The browser tests never executed; the
failure was infra, not a web regression.
Fix the harness so the tests actually run:
- add `Swatinem/rust-cache@v2` to warm the cargo cache across runs
- add a `Prebuild server` step (`SQLX_OFFLINE=true cargo build -p
solitaire_server`) before Playwright, so `webServer`'s `cargo run` reuses the
compiled binary and starts in seconds (.sqlx offline cache is committed)
- raise `webServer.timeout` 120s -> 300s as a safety margin for a cold cargo
cache (e.g. first run after a deps bump)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The web build shipped a ~3-week-stale pkg/ because the old "Check wasm pkg
drift" step in docker-build.yml only hard-failed on direct solitaire_web/
edits and treated solitaire_engine/_core changes as a non-blocking notice —
so the entire card_game migration (engine/core/data churn, v4->v5 save
schema) slipped through.
Replace it with a dedicated `web-wasm-freshness` workflow that rebuilds the
artifacts and diffs them against what's committed. A fresh build on a pinned
toolchain (rust 1.95.0 / wasm-bindgen 0.2.120 / wasm-pack 0.14.0 / binaryen
130) is byte-for-byte reproducible — verified locally — so this is precise:
it fails on *any* real drift (including gameplay-logic changes that don't
touch the JS API surface) and has no false positives on wasm-irrelevant edits.
Runs on pull_request as well as master push, so staleness is caught before
merge rather than only blocking the post-merge deploy.
Remove the superseded heuristic from docker-build.yml; master stays fresh via
the PR gate, so the deploy image is always built from current artifacts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The committed `solitaire_server/web/pkg/` artifacts (canvas.* and
solitaire_wasm.*) were last built 2026-06-02 (8b262af) and predate the entire
card_game/klondike migration (#82–#90) plus the v4→v5 save-schema change. The
deployed WASM therefore no longer matched the current source, the JS API glue,
or the HTML (play.html changed in 2cf7282) — which is why the web build was
broken even though the wasm crates compile cleanly on `wasm32-unknown-unknown`.
Rebuilt all four artifacts via build_wasm.sh (wasm-pack 0.14.0,
wasm-bindgen CLI 0.2.120 matching the crate). The regenerated solitaire_wasm.js
and canvas.js glue confirm the API drift (the replay move currency became
KlondikeInstruction in #89).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These six warnings only surfaced on an `aarch64-linux-android` clippy build;
the host workspace gate never compiles the `cfg(target_os = "android")` HUD
tap-toggle code, so they had accumulated unseen.
- unqualify `Vec2` / `TouchInput` (reachable via the bevy prelude) in the
android tap tracker, plugin wiring, and `toggle_hud_on_tap` signature
- make the `PausedResource` import unconditional and unqualify its use in
`handle_hint_button` (host-compiled, so the import had been android-gated
purely to satisfy `toggle_hud_on_tap`)
- allow `clippy::too_many_arguments` on the `toggle_hud_on_tap` Bevy system
- collapse a nested `if let` / `if` into a let-chain
Verified clean on both host `clippy -p solitaire_engine --all-targets
-- -D warnings` and `aarch64-linux-android` clippy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses #91: the #90 posture (`deny(unsafe_code)` + three scattered
`#![allow(unsafe_code)]` across solitaire_data and solitaire_engine) punched
unsafe holes into otherwise-pure logic crates. Replace it by *reducing* the
unsafe rather than relocating it, then forbidding it everywhere it can be
forbidden.
Changes:
- solitaire_data: new safe `android_jni` bridge owning the cached `JavaVM`
and activity `GlobalRef`; exposes `with_env` / `with_activity_env` so
keystore/clipboard/safe-area never touch a raw handle.
- keystore: drop the `JavaVM::from_raw` init path (now in the app) and
replace the three `unsafe { JByteArray::from_raw(x.into_raw()) }` casts
with the safe `JByteArray::from(JObject)` conversion jni 0.21 provides.
- engine clipboard + safe_area: route through the bridge; remove their
`#![allow(unsafe_code)]` and all `from_raw` calls.
- solitaire_app: becomes the single owner of FFI unsafe. `android_main`
reconstructs the raw `JavaVM` / activity once (it must, as the cdylib that
exports `#[unsafe(no_mangle)]`) and registers the safe wrappers. It opts to
its own `deny`-level lints with two scoped `#[allow(unsafe_code)]`.
- workspace: `unsafe_code` is now `forbid`. Every crate except the app entry
point is fully unsafe-free.
Net: 7 unsafe sites across three crates collapse to 3 at the OS boundary in
one crate. Verified with host `clippy --workspace -- -D warnings` and an
`aarch64-linux-android` clippy build of solitaire_app (transitively engine +
data); also fixed two latent android-only `collapsible_if` warnings surfaced
in the keystore by the cross-target check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
font_plugin's module doc claimed a parse failure aborts the program, but
the code warns and continues with glyph-less UI; fix the doc to match.
CLAUDE.md §4.2 listed only audio and the card theme as embedded while
saying "do not embed user fonts"; document that the bundled FiraMono face
is legitimately embedded via include_bytes! as the canonical UI font.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The win-time bonus is Ferrous house-rule scoring policy, not a bridge to
the upstream klondike crate, so it does not belong in klondike_adapter.
Relocate it to a dedicated solitaire_core::scoring module and update the
sole caller (win_summary_plugin) and the adapter/settings doc references.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
check_win() manually projected through session.state().state().is_win(),
reaching the inner Klondike's is_win. Session::is_win() (card_game 0.4.1)
wraps that exact same projection, so collapse the three-hop reach into a
single-hop delegation. check_auto_complete() keeps its projection because
is_win_trivial() is a Klondike-only method with no Session wrapper.
Behavior is bit-for-bit identical; the test-support override in
is_won()/is_auto_completable() (which call check_win) is untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add [workspace.lints.rust] and wire each member crate up with
[lints] workspace = true:
unsafe_code = "deny" (forbid would break the Android JNI build)
single_use_lifetimes = "warn"
trivial_casts = "warn"
unused_lifetimes = "warn"
unused_qualifications = "warn"
variant_size_differences = "warn"
unexpected_cfgs = "warn"
unsafe_code is "deny" rather than the issue's "forbid" so the three
Android JNI FFI modules (android_keystore, android_clipboard, safe_area)
can opt back in with a scoped #![allow(unsafe_code)] — forbid cannot be
locally overridden. Pure crates carry no unsafe and stay clean.
Clean up the warnings the new lints surface:
- 150ish unused_qualifications removed via `cargo fix` (purely syntactic
redundant-path-prefix removals).
- table_plugin: the TABLE_COLOUR import was #[cfg(test)]-gated while the
camera clear-colour used the fully-qualified path; unqualifying it left
a non-test build with no import. Made the import unconditional instead.
- assets/sources: the `as &[u8]` casts in embed_*_svg! coerce each
fixed-size &[u8; N] to a uniform slice so the tuples fit the
&[(&str, &[u8])] arrays — load-bearing, so scoped #[allow(trivial_casts)].
Workspace clippy -D warnings and the full test suite pass. Android build
not compiled here (needs the NDK; built separately per CLAUDE.md §15) —
the deny + scoped-allow keeps the JNI unsafe blocks legal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pile-position types (Tableau, Foundation, KlondikePile, KlondikePileStack)
are runtime-only and have no serde upstream. Per Rhys's guidance, the
persistence layer now stores the moves (KlondikeInstruction) rather than
board coordinates, decoding back to runtime pile positions on demand.
Core / data:
- game_state: instruction_history() -> Vec<KlondikeInstruction>; add
instruction_to_piles() and apply_instruction(); drop AnyInstruction.
- klondike_adapter: delete the entire Saved* serde mirror section
(SavedTableau/Foundation/SkipCards/KlondikePile/TableauStack/
KlondikePileStack/DstFoundation/DstTableau/SavedInstruction).
- replay: drop the bespoke ReplayMove serde mirror; Replay.moves is now
Vec<KlondikeInstruction>; REPLAY_SCHEMA_VERSION 2 -> 3.
- storage: game_state save format v3 rejected (v4/v5 only).
Engine / wasm consumers:
- record via KlondikeInstruction (stock click = RotateStock).
- playback decodes each instruction to (from, to, count) against the
live state via instruction_to_piles, then fires the canonical event;
undecodable instructions are skipped with a warning, never panic.
- remove all use solitaire_data::ReplayMove and Saved* imports.
Workspace check, clippy -D warnings, and the full test suite all pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
card_to_id was a frankenstein 0..=51 id shim. Replace it with card_game::Card:
- feedback_anim deal jitter now seeds off a hash of the Card itself
- radial_menu RightClickRadialState.cards: Vec<u32> -> Vec<Card>
- wasm CardSnapshot.id: u32 -> Card (serialises transparently as a plain JS
number, the same opaque key the renderer already used; new test asserts the
JSON id field is a number)
- wasm DebugInvariantReport deck-completeness check reworked from a [bool;52]
index into a HashSet<Card> + Card::new reference deck; the out-of-range check
is dropped since a Card is always valid
Delete card.rs entirely: the Card/Deck/Rank/Suit re-exports move to the crate
root and the 69 `solitaire_core::card::` import paths flatten to `solitaire_core::`.
The JS card.id is purely an opaque identity key (Map key / dataset.cardId, no
arithmetic, card faces render from rank+suit), so the value change is safe.
cargo test --workspace and clippy --workspace --all-targets -- -D warnings green.
Closes#83
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DrawMode was a 1:1 mirror of klondike::DrawStockConfig (DrawOne/DrawThree).
Delete it and use the upstream type everywhere; re-export DrawStockConfig from
solitaire_core. config_for assigns draw_stock directly and draw_mode() returns
session.config().inner.draw_stock.
Serde is unchanged — DrawStockConfig serialises to the same "DrawOne"/"DrawThree"
named variants, so persisted game_state.json / replay JSON stay byte-compatible
(no migration). Field/method/variable names containing draw_mode are unchanged.
35 files, mechanical type swap across all crates. Implemented via a multi-agent
workflow (core → per-crate consumers → verify). cargo test --workspace and
clippy --workspace --all-targets -- -D warnings green.
Closes#82
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A claude-flow tool run left solitaire_engine/src/.claude-flow/. Ignore
.claude-flow/ anywhere in the tree, matching the existing agent-tooling
artifact rules.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ignore the local token-saving Go helpers under scripts/ (peek, cargoclip,
testfail, diffclip, cratemap, sessionpack, etc.) via scripts/*.go. These are
inspection-only dev tools, not committed. Tracked scripts/*.sh and *.md are
unaffected. Replaces a broad, machine-local .git/info/exclude rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the standalone solver wrapper module. Its thin shaping — build a
solve-budgeted Session, run card_game::Session::solve(), extract the first
useful move — moves onto the domain type in solitaire_core as
GameState::solve_first_move() / GameState::solve_fresh_deal(), with the budget
consts and the SolveOutcome alias re-exported from solitaire_core.
Solving is deterministic, IO-free game logic, so core (which already owns
GameState and exposes session().solve()) is its correct home; solitaire_data is
the persistence/sync layer and never should have owned it.
Consumers now call the core API directly:
- engine: pending_hint (solve_first_move), game_plugin + play_by_seed_plugin
(solve_fresh_deal), input_plugin (budget consts)
- assetgen: gen_seeds + gen_difficulty_seeds (solve_fresh_deal)
The solver tests move to solitaire_core. cargo test --workspace and
clippy --workspace --all-targets -- -D warnings both green.
Resolves the "delete the solver" directive — card_game provides the solver.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace O(n) `Query::iter().find()` card scans with O(1) `CardEntityIndex`
lookups in the mouse and touch drag pipelines (`follow_drag`, `end_drag`,
`touch_follow_drag`, `touch_end_drag`) and `update_drag_shadow` — 7 sites
across 5 systems. Each ran per dragged card per frame during a drag.
`InputPlugin` now defensively `init_resource::<CardEntityIndex>()` (idempotent;
`CardPlugin` still owns and rebuilds it) so the plugin is self-sufficient in
tests. The lone remaining card-keyed `.find` is a `#[cfg(test)]` world-query
helper, which is the correct pattern there.
Completes the CardEntityIndex migration started in ef1efdc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Keep Codex / claude-flow scaffolding (.agents/, .codex/, AGENTS.md) out
of the repo — these are locally generated and not project sources.
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>
Replace the git-rev pin (fb01881f, commit 2d0359c) with the published
Quaternions registry releases klondike 0.4.0 / card_game 0.4.1. The
mainline-rev switch broke clean resolution because it dropped the
`registry = "Quaternions"` selector; pinning the registry versions
restores a reproducible lockfile.
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>