975 Commits

Author SHA1 Message Date
funman300 4850e9417e Merge pull request 'fix(web): cap /play canvas at the real 2048 wgpu limit (corrects #97)' (#98) from fix/web-canvas-cap-2048-constant into master
Build and Deploy / build-and-push (push) Successful in 5m53s
Web E2E / web-e2e (push) Successful in 5m39s
2026-06-24 01:14:49 +00:00
funman300 8a2a22ff1b fix(web): cap /play canvas at the real 2048 wgpu limit, not gl.MAX_TEXTURE_SIZE
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>
2026-06-23 18:14:20 -07:00
funman300 b3b282ec2d Merge pull request 'fix(web): clamp /play canvas to GPU MAX_TEXTURE_SIZE (wgpu 2048 panic)' (#97) from fix/web-canvas-max-texture-size into master
Build and Deploy / build-and-push (push) Successful in 5m29s
Web E2E / web-e2e (push) Failing after 6m18s
2026-06-24 00:45:29 +00:00
funman300 7b5d69e164 fix(web): clamp /play canvas to GPU MAX_TEXTURE_SIZE to stop wgpu panic
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>
2026-06-23 17:44:46 -07:00
funman300 923a67dc7b Merge pull request 'fix(web): classic timer ran at 2x (idempotent startTimer)' (#96) from fix/classic-timer-double-count into master
Build and Deploy / build-and-push (push) Successful in 6m8s
Web E2E / web-e2e (push) Successful in 7m5s
2026-06-24 00:18:53 +00:00
funman300 d4ad184324 fix(web): make classic timer idempotent to stop 2x double-counting
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>
2026-06-23 17:18:10 -07:00
Gitea CI 0b7a24a75b chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m10s
Web E2E / web-e2e (push) Failing after 5m16s
2026-06-23 17:20:10 +00:00
funman300 fc45b6d261 Merge pull request 'fix(web): CI-owned wasm artifacts + e2e harness fixes' (#95) from fix/web-ci-reproducible-and-e2e into master
Build and Deploy / build-and-push (push) Successful in 6m15s
Web E2E / web-e2e (push) Failing after 4m40s
Web WASM Rebuild / rebuild (push) Successful in 8m54s
2026-06-23 17:01:04 +00:00
funman300 2328643223 ci(web): replace wasm freshness gate with CI-side rebuild-and-commit
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>
2026-06-23 09:59:51 -07:00
funman300 5b2b234c54 fix(web-e2e): expose serialize() on classic bridge; use real clock API
Web WASM Freshness / freshness (pull_request) Failing after 7m8s
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>
2026-06-22 12:46:12 -07:00
funman300 9c473d6a51 fix(web): make wasm builds reproducible so the freshness gate passes
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>
2026-06-22 12:46:12 -07:00
funman300 b3a8575bbd Merge pull request 'ci(web-e2e): prebuild server so Playwright webServer stops timing out' (#94) from ci/web-e2e-prebuild-server into master
Build and Deploy / build-and-push (push) Successful in 1m30s
Web E2E / web-e2e (push) Failing after 5m18s
2026-06-22 19:11:22 +00:00
funman300 5091d1b397 ci(web-e2e): prebuild server so Playwright webServer stops timing out
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>
2026-06-22 12:09:47 -07:00
funman300 da165f1622 Merge pull request 'ci(web): add precise wasm-freshness gate; drop flawed drift heuristic' (#93) from ci/web-wasm-freshness-gate into master
Build and Deploy / build-and-push (push) Successful in 1m15s
Web WASM Freshness / freshness (push) Failing after 7m6s
2026-06-22 19:03:49 +00:00
funman300 43076a48d6 ci(web): add precise wasm-freshness gate; drop flawed drift heuristic
Web WASM Freshness / freshness (pull_request) Failing after 7m14s
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>
2026-06-22 12:03:00 -07:00
funman300 5af69d7551 Merge pull request 'fix(web): regenerate stale WASM artifacts against current master' (#92) from fix/regenerate-web-wasm into master
Build and Deploy / build-and-push (push) Successful in 5m55s
Web E2E / web-e2e (push) Failing after 3m19s
2026-06-22 18:56:31 +00:00
funman300 6c9259beff fix(web): regenerate stale WASM artifacts against current master
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>
2026-06-22 11:53:56 -07:00
funman300 dde65a7e30 Merge pull request 'refactor(core): card_game redundancy cleanup + derive scoring from upstream stats' (#88) from refactor/strip-card_game-redundancies into master
Build and Deploy / build-and-push (push) Failing after 1m0s
Web E2E / web-e2e (push) Failing after 3m19s
2026-06-22 18:44:37 +00:00
funman300 e3b8a403ef style(engine): clear latent android-only clippy warnings in hud_plugin
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>
2026-06-22 11:39:21 -07:00
funman300 9299176b2d refactor(android): forbid unsafe workspace-wide, quarantine JNI in app (#91)
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>
2026-06-22 11:32:26 -07:00
funman300 6a9352cde1 docs: correct font-embed claims in font_plugin and CLAUDE.md
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>
2026-06-22 11:04:11 -07:00
funman300 8995a8ae9c refactor(core): move compute_time_bonus into scoring module
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>
2026-06-22 11:04:11 -07:00
funman300 0d5c9cdb1d refactor(core): delegate check_win to Session::is_win
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>
2026-06-12 14:22:35 -07:00
funman300 ceb9c950a1 chore: add pedantic workspace lints (#90)
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>
2026-06-12 13:05:28 -07:00
funman300 9bbb57134f refactor: persist replay/save moves as KlondikeInstruction, not pile coords (#89)
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>
2026-06-12 12:43:47 -07:00
funman300 e0a858d4e8 refactor: remove card.rs / card_to_id; use card_game::Card directly (#83)
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>
2026-06-11 19:33:47 -07:00
funman300 5c992cbdca refactor: replace local DrawMode with upstream klondike::DrawStockConfig (#82)
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>
2026-06-11 16:01:11 -07:00
funman300 d045781119 chore: gitignore .claude-flow scratch dirs
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>
2026-06-11 15:12:18 -07:00
funman300 f0871c03e8 chore: gitignore local helper scripts
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>
2026-06-11 15:10:18 -07:00
funman300 e841a7ab4f refactor: delete solitaire_data::solver wrapper; solve via card_game directly
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>
2026-06-11 15:04:47 -07:00
funman300 424c8b2d50 perf(engine): route remaining drag card→entity lookups through CardEntityIndex
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>
2026-06-11 11:05:24 -07:00
funman300 372b6423d8 refactor(core): derive score/undo/recycle from upstream session stats
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>
2026-06-11 10:36:31 -07:00
funman300 9e3c6b06b0 chore: gitignore local agent-tooling artifacts
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>
2026-06-10 21:17:04 -07:00
funman300 f0832f3dfa refactor: remove leftover redundancies after card_game migration
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>
2026-06-10 20:12:03 -07:00
funman300 ef1efdc3b5 refactor(core): make KlondikeInstruction the move currency
Build and Deploy / build-and-push (push) Failing after 1m1s
Web E2E / web-e2e (push) Failing after 3m26s
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>
2026-06-10 16:58:28 -07:00
funman300 dc4cf45ea0 build(deps): switch klondike/card_game to Quaternions registry
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>
2026-06-10 16:58:13 -07:00
funman300 0d3f037672 refactor: consolidate card_to_id into solitaire_core
Build and Deploy / build-and-push (push) Failing after 1m21s
Web E2E / web-e2e (push) Failing after 3m27s
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>
2026-06-10 10:14:20 -07:00
funman300 cac77a54a6 refactor: slim solver to card_game-native types
Build and Deploy / build-and-push (push) Failing after 1m34s
Web E2E / web-e2e (push) Failing after 4m22s
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>
2026-06-10 10:05:47 -07:00
funman300 2d0359c2ee build(deps): switch card_game/klondike to mainline fb01881f
Build and Deploy / build-and-push (push) Failing after 1m6s
Web E2E / web-e2e (push) Failing after 3m7s
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>
2026-06-10 09:34:56 -07:00
funman300 056459619b refactor(core): derive draw_mode/is_won/move_count/is_auto_completable from session
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>
2026-06-10 09:24:03 -07:00
funman300 1438fd6265 refactor(core): complete card_game::Card migration across engine + wasm
Build and Deploy / build-and-push (push) Failing after 1m2s
Web E2E / web-e2e (push) Failing after 3m19s
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>
2026-06-09 17:45:34 -07:00
funman300 920f2c8597 refactor(core): move solver to solitaire_data, DrawMode to klondike_adapter, remove pile/solver/schema_version
- 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>
2026-06-09 09:38:04 -07:00
funman300 37a21b9b42 docs: record android avd smoke 2026-06-08 19:24:42 -07:00
funman300 712ed6be80 docs: clarify android support status 2026-06-08 19:14:48 -07:00
funman300 324003562b test: cover mobile card label glyphs
Build and Deploy / build-and-push (push) Successful in 1m4s
2026-06-08 19:13:40 -07:00
funman300 a69a774edf docs: refresh handoff after runbooks 2026-06-08 19:12:15 -07:00
funman300 df4887fb36 docs: update android smoke test runbook 2026-06-08 19:11:02 -07:00
funman300 159774f811 docs: add analytics validation runbook
Build and Deploy / build-and-push (push) Successful in 1m6s
2026-06-08 19:09:22 -07:00
funman300 b3c4d08dfc docs: avoid stale handoff head hash 2026-06-08 19:06:07 -07:00
funman300 f313cfd8b7 docs: update session handoff state 2026-06-08 19:05:20 -07:00