Two-deck (104-card) Spider on the upstream Stack/Pile containers,
exercising the multi-deck Card encoding for the first time:
- SpiderGame: 10-pile deal (4x6 + 6x5), build-down-any-suit,
same-suit-run pickup, deal-10 gated on no empty pile, automatic
K->A run removal, win at 8 runs
- SpiderSuits difficulty (1/2/4 suits over the same 104 cards);
1- and 2-suit games contain identical Card values by construction
(documented — engine entity mapping will need positional keys)
- Seeded deals via inline SplitMix64 + Fisher-Yates (core has no
rand dep; Spider's seed space is deliberately self-contained)
- SpiderGameState session wrapper mirroring GameState conventions:
Result<_, MoveError> mutations, upstream undo/score bookkeeping,
Microsoft-style scoring (500 base, -1/move, +100/run, -1/undo)
- 17 unit tests + card-conservation/validity proptest over random
legal walks; stacked-deal win-path test included
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The #152 poller fixes resolution + layout on a missed fold/unfold, but
a transient clip could survive if card sprites held stale visuals after
geometry converged. Emit StateChangedEvent alongside the synthetic
WindowResized so card_plugin re-renders every sprite from scratch —
silent self-heal, no player-facing prompt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Points THEME_STORE_DIR at the existing solitaire-db PVC so catalog
content survives pod restarts. Scan is startup-only; restart the
deployment after adding zips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 1+2 of the theme-store roadmap: a free catalog served by
solitaire_server and an in-app browse/install flow, making custom
themes installable on Android for the first time (the manual
drop-a-zip flow can't reach the app-private themes dir there).
- solitaire_sync: ThemeCatalogEntry/ThemeCatalogResponse wire types
(additive module; SyncPayload and SyncProvider untouched)
- solitaire_server: THEME_STORE_DIR scan at startup (meta-only
theme.ron parse, sha256, 20 MiB cap, best-effort per archive);
public GET /api/themes, /api/themes/{id}/download, /{id}/preview;
compose volume + README_SERVER docs
- solitaire_data: ThemeStoreClient — catalog fetch + download with
mandatory size/sha256 verification before bytes are released
- solitaire_engine: ThemeStorePlugin — 'Browse theme store' button in
Settings → Cosmetic, modal catalog (leaderboard-style rebuild),
download on AsyncComputeTaskPool, atomic .tmp+rename write into
user_theme_dir, then the existing hardened import_theme pipeline and
an in-place registry refresh
New deps: sha2 (workspace, server+data); ron/zip reused in server;
serde_json added to solitaire_sync dev-deps for DTO round-trip tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- set_theme resolved every id to themes:// — the bundled dark/classic
themes live at embedded://, so switching to them via the public API
would NotFound and silently keep the old theme. All three load paths
now share one theme_manifest_url resolver.
- load_initial_theme's settings-absent fallback was "classic", a
leftover from v0.33 when classic was the default; it now derives from
Settings::default() so it can't drift from the data crate again, with
a test pinning the default id to a bundled theme.
- settings.rs doc no longer claims "classic" is migrated to "dark";
only the pre-rename "default" id is rewritten.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Refresh tokens are single-use since the rotation change (PR #136). Two
in-flight requests hitting 401 together (replay upload racing a manual
sync) both called /api/auth/refresh; the second presented an already-
consumed token, got rejected, and surfaced as a spurious 'session
expired' re-login prompt.
refresh_token() now takes the stale access token that earned the 401
and runs behind a tokio::sync::Mutex; a loser of the lock race that
finds the stored token already rotated returns Ok and retries with it
instead of spending the new refresh token.
Untested caveat: exercising the race in a unit test needs an in-memory
auth_tokens double (the real keyring is unavailable on headless Linux
runners) — noted for a future test-support addition.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
§8 documented a Waste variant that doesn't exist in klondike 0.4; the
real convention (Stock denotes the waste pile in pile-coordinate space)
is now stated where the enum is sketched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Random arm was only unreachable because seeds_for(Random) returns
None in a different function — a future catalog change would turn it
into a shipped runtime panic. Returning a system-time seed is the
correct Random behaviour either way. CLAUDE.md §2.3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
user_theme_dir() panicked on desktop when dirs::data_dir() returned
None (broken $HOME / $XDG_*). Return an empty path instead — exactly
what the wasm32 branch already did — so theme discovery reports 'no
user themes' and the bundled default keeps working. Warns once with
the set_user_theme_dir() workaround. CLAUDE.md §2.3: no panic! in
runtime logic.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95)
wraps signatures and call sites differently, so every touched file was
picking up unrelated formatting hunks. One mechanical pass, and a
'cargo fmt --check' step in the test workflow (same pinned 1.95.0
toolchain) so drift can't accumulate again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two runs (447, 486) died with 'ld terminated with signal 9' linking the
solitaire_engine test binary — the runner OOMs on full dev debuginfo for
the Bevy dependency graph. line-tables-only preserves file:line panic
backtraces at a fraction of the link footprint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
winit's Android backend does not forward content-rect changes that happen
while backgrounded (open TODOs in winit 0.30 logged on every resume), so a
fold/unfold cycle can leave Bevy rendering and laying out for the previous
screen: tableau clipped off the left edge, bottom third empty (#130).
Add a continuous decor-view size poller (JNI, every 30 frames) that, on
mismatch with the cached Window resolution: writes the real physical size
into window.resolution, emits a synthetic WindowResized in logical pixels,
and re-arms the safe-area inset poller — covering both the fold-size and
inset-staleness cases in one mechanism, without relying on
AppLifecycle::WillResume being delivered (it may never be; a new evidence
log in rearm_on_resumed settles that question on-device).
Closes#130
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clears the final 47 pairs and turns the ratchet into a hard gate
(AMBIGUITY_BASELINE = 0, assert_eq):
- HudButtons: the 14 HUD button/popover handlers run as one chain,
before ui_focus::FocusKeys — Esc/keyboard consumption order is now
defined (restore prompt → buttons/popovers → focus navigation →
settings toggle) instead of scheduler-dependent.
- HUD text updaters (update_hud, update_selection_hud,
update_won_previously) chained, in UiTextFx, after the new
AutoComplete set (update_hud reads AutoCompleteState).
- restore_hud_on_modal → apply_hud_visibility chained before
UpdateOnResize: HudVisibility writes, application, and the layout
read happen in a fixed order.
- New writer sets UndoRequestWriters / InfoToastWriters (same
self-ambiguous pattern as NewGameRequestWriters).
- Logic-before-paint: check_no_moves and the AutoComplete chain order
before BoardVisuals; SettingsMutation after UpdateOnResize.
- MarkerVisuals set wraps the table painter chain; chrome fx declare
disjointness from it.
- update_hud_typography after BoardVisuals; avatar/settings-toggle
interaction handlers declared disjoint from HudButtons.
302 → 198 → 171 → 47 → 0 in four batches, one day. New systems now
fail CI unless they declare their ordering or their disjointness.
Closes#143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Sprite/Transform cluster (112 pairs) was every board painter
racing every other one. Two structural moves:
- BoardVisuals set (card_plugin): all card/marker painters run as ONE
deterministic chain in data-flow order — layout refinement → card
authority (sync_cards_on_change) → flip anims → shadows → highlights
→ stock indicators → resize snapping → corner labels — with
LayoutSystem::UpdateOnResize ordered before the whole set and the
table plugin's marker painters chained after it. Paint order is now
identical every frame instead of scheduler-dependent.
- UiTextFx set (ui_theme): chrome text effects (score pulse/floater,
streak flourish, modal enter, focus-ring pulse) animate Transform on
UI entities only; they are declared ambiguous with BoardVisuals and
with each other — the entity domains are disjoint by construction.
All chain members are cheap and change-gated; sequential execution is
not a measurable cost for a card game. Existing ordering constraints
(fan-frac before sync, shadows after sync, snap after collect) are
preserved inside the chain.
Baseline ratchets 171 → 47. Remaining: long tail of small subjects
(input, event writers, AutoCompleteState, HudVisibility).
Refs #143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New SettingsMutation set: the per-frame settings mutators
(handle_volume_keys → record_window_geometry_changes →
persist_window_geometry_after_debounce) run as a deterministic chain
ordered before GameMutation, so every reader already after GameMutation
observes the current frame's settings transitively. The four readers
outside that ordering (modal enter-speed chain, focus-ring pulse, HUD
avatar, and the game plugin's pre-mutation chain) are ordered after the
set explicitly.
SettingsResource ambiguities: 21 → 0. Baseline ratchets 198 → 171.
Refs #143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two structural fixes from the #143 backlog:
- Game-state ordering spine: the three pre-mutation GameStateResource
writers (tick_elapsed_time, sync_settings_to_game,
handle_restore_prompt) are now a deterministic chain before
GameMutation, and the remaining unordered readers
(update_selection_hud, handle_hint_button, tick_hint_highlight,
handle_right_click, snap_cards_on_window_resize,
sync_pile_marker_visibility, auto_save_game_state) are ordered after
it. Readers now see the current frame's moves deterministically
instead of racing the mutators.
- NewGameRequestWriters set: every in-cluster writer of
NewGameRequestEvent (buttons, modals, mode picker, seed poller,
restore prompt) is registered in a shared set marked ambiguous with
itself — writer-vs-writer append order is meaningless since consumers
drain the whole queue. Out-of-cluster writers (home, challenge,
time-attack, win-summary, play-by-seed, difficulty, stats plugins)
can join the set when the test cluster grows.
AMBIGUITY_BASELINE ratchets 302 → 198. Remaining backlog is dominated
by the Sprite (72) / Transform (48) visual-domain cluster, which needs
per-domain set architecture — next batch.
Refs #143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First measurement of schedule hygiene (issue #143): the headless
gameplay cluster (Game/Table/Card/Hud/AutoComplete/UiModal/UiFocus/
Settings) carries 302 system pairs with conflicting data access and no
ordering edge. Too many to triage in one pass and most are likely
benign event/resource writers — but unproven, and nothing stopped the
count from growing.
New schedule_checks test builds the cluster with ambiguity detection
promoted to error, parses the reported pair count, and asserts it never
exceeds AMBIGUITY_BASELINE (302). New ambiguous pairs now fail CI at
the PR; the legacy backlog can be burned down incrementally by adding
.before/.after or .ambiguous_with and lowering the baseline.
Refs #143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the three actionable server Low findings from the 2026-07-06
review:
- #139: unknown-username logins now verify against a static bcrypt
dummy hash so both failure paths pay the same cost — response timing
no longer reveals which usernames exist.
- #140: register maps a unique-constraint violation to UsernameTaken
(409) — the SELECT pre-check stays as the friendly fast path, the
constraint is the arbiter for the concurrent case.
- #141: avatar uploads must start with the magic bytes of the declared
image type; the stored extension (which decides how the file is
re-served) is now backed by content, not the Content-Type header.
Unit tests: real/spoofed/truncated signatures for all four formats,
and the dummy hash's validity at BCRYPT_COST.
Closes#139Closes#140Closes#141
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The exit push spawned a detached task on AppExit, which process
teardown almost always killed before the network round-trip completed —
the final session's sync silently never happened (2026-07-06 review,
finding M3). Local persistence meant no data loss, but stats stayed
unsynced until the next launch.
push_on_exit now blocks the closing app's final frame for at most
EXIT_PUSH_TIMEOUT (2s) via tokio::time::timeout: long enough for one
healthy round-trip, short enough that quitting never feels hung when
the server is unreachable. Timeout and errors are logged and skipped —
the next launch's pull/push converges as before.
Closes review finding M3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings 1+2 (Quat-underuse lens, 2026-07-06):
- solitaire_core gains pub const FOUNDATIONS / TABLEAUS — the canonical
iteration source for the upstream pile enums (upstream klondike has no
Foundation::ALL, and inherent impls cannot be added to foreign types).
Deletes three identical private const-fn copies (radial_menu,
table_plugin, input_plugin) and the hand-enumerated variants in
card_plugin::sync::all_cards and solitaire_wasm.
- Hand-rolled [Suit; 4] / [Rank; 13] arrays replaced with upstream
Suit::SUITS / Rank::RANKS. The order-sensitive CardImageSet indexing
is re-keyed through canonical card_plugin::{suit_index, rank_index}
helpers that match upstream order, with regression tests asserting
the correspondence — one ordering everywhere instead of three
divergent local ones.
Net -177 lines. No behaviour change; all consumers go through the
canonical helpers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two concurrency fixes from the 2026-07-06 review (findings M1, M2):
sync push (M1): the load→merge→store cycle ran as three separate DB
operations. Two devices pushing concurrently both read the same stored
payload, merged independently, and the second store overwrote the first
merge — the server visibly regressed until the losing device pushed
again. The whole cycle (including the leaderboard update) now runs in
one transaction; SQLite serialises the writers. The leaderboard
helper's stale docstring (claiming a single conditional UPDATE that was
actually two statements) is corrected — the transaction now provides
the atomicity it described.
refresh rotation (M2): SELECT-then-DELETE let two concurrent refreshes
with the same token both pass the liveness check and both mint fresh
token pairs. The SELECT is gone; rotation now gates on the DELETE's
rows_affected — whoever removes the jti row wins, everyone else gets
401. Sequential reuse was already covered by
consumed_refresh_token_is_rejected, which still passes unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback on #135: floating 'stable' + -D warnings lets every new
clippy release redden master with new lints — pin 1.95.0 like the
web-wasm-rebuild workflow. And ubuntu-latest lacks the ALSA/udev/X11/
Wayland dev packages the Bevy crates link against; install the standard
Bevy CI set (the project's own builder images have no desktop-Bevy
precedent — android-builder targets the NDK and web-e2e only builds the
server).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Until now no CI workflow ran the test suite or clippy at all — the
android-release, docker-build, web-e2e, web-wasm-rebuild, and
builder-image workflows cover packaging and e2e, but a direct push to
master (including the web-wasm-rebuild bot commit) never executed
cargo test or clippy. The gate discipline in CLAUDE.md §6 existed only
on developer machines.
test.yml runs the exact §6 commands (clippy --workspace --all-targets
-D warnings; cargo test --workspace) on master pushes and PRs, with
SQLX_OFFLINE against the checked-in .sqlx cache, path-filtered so
docs-only merges don't burn CI. Toolchain/cache mirror web-e2e.yml.
Found during the 2026-07-06 large-scale review (finding H1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
canvas_bg.wasm shipped at 36.2 MB — plain release (opt-level 3, thin
LTO) piped through wasm-opt -O2. Download size, not throughput, is the
binding constraint for the browser canvas, so solitaire_web now builds
with a dedicated wasm-release profile: opt-level "s", fat LTO, one
codegen unit. Local result: 23.2 MB after the unchanged wasm-opt -O2
pass — 35.9% smaller.
The binaryen pass stays at -O2 (the -Oz grey-screen miscompile note in
build_wasm.sh still applies). profile.strip is deliberately NOT set: on
wasm it also removes the target_features custom section, which makes
wasm-opt reject the module ('all used features should be allowed' on
trunc_sat).
Verified with the new artifact: all 5 play_canvas e2e specs pass (debug
bridge, draw3 param, apply/undo, replay diagnostics, 40-seed autoplay
invariants). Headless pixel verification is inconclusive in this
environment — wgpu cannot create a usable adapter locally and panics
identically on the OLD artifact too — so visual parity should be
confirmed against production after the next deploy.
pkg/ artifacts are intentionally not committed: the web-wasm-rebuild
workflow is the single source of truth and will regenerate them with
this profile on merge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>