Commit 38e4c03 (shipped in v0.40.0) switched Android's focused_mode to
reactive_low_power(100 ms) on the premise that every animation tick
system writes RequestRedraw while it has active work. The writers were
never actually added — the diff only contained the WinitSettings change,
imports, and add_message registrations. Result: with no touch input,
nothing wakes the winit loop during a card slide except the 100 ms
fallback ceiling, so animations render at ~10 fps on Android. Desktop
and web keep Continuous mode, which is why only Android was affected
(reported by Rhys the day v0.40.0 shipped; confirmed absent on v0.39.1).
Adds the missing MessageWriter<RequestRedraw> to all seven systems the
original commit message named:
- advance_card_animations (CardAnimationPlugin)
- advance_card_anims (AnimationPlugin — deal/win cascade/slides)
- tick_shake_anim, tick_settle_anim, tick_foundation_flourish
(FeedbackAnimPlugin)
- drive_toast_display (AnimationPlugin — toast countdown)
- drive_auto_complete (AutoCompletePlugin — step-interval keepalive)
Each writes one RequestRedraw per frame while active work exists
(including delay phases, which also need per-frame ticks). Regression
test asserts an active CardAnimation emits RequestRedraw and an idle
board does not.
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>
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>
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>
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>
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>
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>
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>
- 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>
- #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>
Core (solitaire_core):
- fix(scoring): apply -15 penalty for Foundation→Tableau moves when
take_from_foundation is enabled; update test
- fix(solver): is_won() validates full Ace→King suit sequence, not
just card count — prevents hint system from emitting invalid paths
Engine — animation / layout:
- fix(animation): guard CardAnim advance against duration=0 to prevent
NaN-poisoned Transform (analogous to CardAnimation's instant-snap path)
- fix(card_plugin): align TABLEAU_FAN_FRAC (0.25→0.18) and
TABLEAU_FACEDOWN_FAN_FRAC (0.20→0.14) with layout.rs so the initial
layout and first dynamic update produce identical fan spacing
- fix(layout): update tableau_fan_frac doc comment from 0.25→0.18
Engine — ECS / modal guards:
- fix(auto_complete): drive_auto_complete now checks PausedResource so
cooldown does not tick while paused (prevents instant-move on unpause)
- fix(play_by_seed): handle_open_dialog checks global ModalScrim guard
to prevent stacking over an existing modal
- fix(win_summary): spawn_win_summary_after_delay checks global
ModalScrim guard; collect_session_achievements uses .next() not
.last() to avoid draining the new_games stream
Engine — message registration:
- fix(leaderboard): register InfoToastEvent in LeaderboardPlugin::build
so opt-in/opt-out toasts work under MinimalPlugins
- fix(replay_playback): register StateChangedEvent in
ReplayPlaybackPlugin::build to prevent panic when used standalone
Security:
- fix(sync_setup): zero password SyncFieldBuffer immediately after
spawning auth task — credential must not linger in ECS components
Server:
- fix(auth): replace MIME contains-chain with exact match for avatar
upload; removes illusory starts_with guard and dead ALLOWED_IMAGE_TYPES
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Standard Klondike behaviour: any Ace can land in any empty foundation,
and that slot then claims the suit until the pile empties. The
previous PileType::Foundation(Suit) variant pre-assigned each of the
four foundations to a fixed suit ("C / D / H / S" placeholders) and
rejected mismatched Aces — non-standard and (per the smoke-test
feedback) confusing.
Replaces the variant payload with a slot index Foundation(u8) (0..=3)
and derives the claimed suit from the bottom card via a new
Pile::claimed_suit() method. The bottom card is, by construction,
the Ace that established the claim; using it directly eliminates an
entire class of "stuck claim after undo" bugs that a separate
claimed_suit field would have introduced.
can_place_on_foundation drops its suit parameter — the rule reduces
to "empty pile accepts any Ace; non-empty pile accepts the next
rank up of the bottom card's suit." Iteration sites across
input_plugin, cursor_plugin, selection_plugin, card_plugin,
auto_complete_plugin, game_plugin, layout, and hud_plugin all swap
the four-suit list for `(0..4u8).map(PileType::Foundation)`.
next_auto_complete_move now prefers a slot whose claimed_suit matches
the candidate card before falling back to the first empty slot for
an Ace — so the same suit consistently auto-targets the same slot
across the whole game, matching player expectations.
The HUD selection label and the hint toast read claimed_suit() and
fall back to "Foundation N" / "move to foundation" only when the
slot is empty. Empty foundation pile markers no longer render the
suit-letter children — they're plain translucent rectangles, matching
empty tableau placeholders.
Save-format invalidation: GameState gains a schema_version field
(serde-default to 1 for back-compat parsing of old files), the
constant is bumped to 2, and load_game_state_from rejects mismatched
schemas. Old in-progress saves silently fall through to "fresh game
on launch" — the user accepted this loss given the mechanic change.
Stats / progress / achievements / settings live in separate files,
contain no PileType data, and are unaffected.
9 new tests pin the contract:
- Pile::claimed_suit returns None for empty / non-foundation, Some
for non-empty foundation
- Any Ace lands in the first empty foundation; successive Aces
distribute across slots 0..3
- Claim drops when the slot is emptied via undo
- Auto-complete picks the slot with a matching claim, not the first
empty slot
- A v1-format game_state.json is rejected; sibling stats save/load
is unaffected
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- BorderRadius is no longer a Component; moved into Node.border_radius
field at all 15 spawn sites across 6 plugin files
- Events<T> renamed to Messages<T> in test code (12 files)
- KeyboardEvents SystemParam renamed to KeyboardMessages to match the
MessageWriter rename done in the 0.17 hop
- WindowResolution::from((f32,f32)) removed; use (u32,u32) tuple in main.rs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Event/EventReader/EventWriter renamed to Message/MessageReader/MessageWriter
- add_event → add_message for all 67 call sites
- ScrollPosition changed to tuple struct ScrollPosition(Vec2)
- CursorIcon import moved from bevy::winit::cursor to bevy::window
- WindowResolution::from((f32,f32)) removed — use (u32,u32) tuple
- World::send_event → World::write_message in test code
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Round 7 — Input & feedback
- H key cycles hints; F1 opens help (conflict resolved)
- N key cancels active Time Attack session
- Hint text distinguishes "draw from stock" vs "recycle waste"
- Forfeit (G) clears AutoCompleteState so chime does not bleed into new deal
- Zen mode timer clears immediately on Z press
- HUD shows recycle count in both draw modes
- Settings scroll position persisted across open/close
Round 8 — Polish & clarity
- Undo unavailable fires "Nothing to undo" toast
- Streak-break toast on forfeit/abandon when streak > 1
- F11 fullscreen toggle with toast; documented in help and home screens
- H-after-win, new-game countdown expiry, Tab-no-cards toasts
- Win cascade duration/stagger scales with animation speed setting
- Draw-Three cycle counter HUD ("Cycle: N/3")
- Forfeit requires G×2 confirmation within 3 s (mirrors N key)
Round 9 — Game feel & information
- Escape dismisses game-over/stuck overlay (PausePlugin skips Escape when overlay visible)
- Shake animation on rejected drag before snap-back
- Forfeit countdown cancels when any other key is pressed (U/H/D/Z/Space)
- Tab wrap-around fires "Back to first card" toast
- HUD selection indicator shows active Tab-selected pile in yellow
- Challenge time-limit HUD turns orange < 60s, red < 30s
- Win summary shows XP breakdown (+50 base, +25 no-undo, +N speed)
- Game-over overlay: "No more moves available" with clear N/Escape/G instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When is_auto_completable flips true (stock/waste empty, all cards
face-up), AutoCompletePlugin fires MoveRequestEvent every 120 ms,
driving cards to the foundation one at a time without player input.
An "Auto-completing…" toast announces the sequence.
- solitaire_core: add next_auto_complete_move() to GameState with
3 new unit tests
- solitaire_engine: new AutoCompletePlugin with detect + drive systems
and 4 unit tests; animation_plugin shows one-shot toast on activation
- solitaire_app: register AutoCompletePlugin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>