Android corner label children sit at local z=0.02; with STACK_FAN_FRAC=0.003
the card below's label (world z=1.02) rendered above the card on top's sprite
(world z=1.003), causing overlapping rank/suit text on foundation piles.
Raising STACK_FAN_FRAC to 0.025 ensures every card sprite covers all children
of the card below it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.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>
Tag-push events are not reliably processed by the self-hosted Gitea
runner. workflow_dispatch with a tag input allows manual triggering
via the Gitea UI or API as a fallback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Issue #7 — new game during win cascade:
sync_cards now stores each in-flight CardAnimation's end position instead of
a plain bool. Before calling update_card_entity, the end position is compared
against the game-state target. If they differ by more than 2 px (stale cascade
scatter vs. new-game dealt position) the CardAnimation is removed immediately
so the card slides to its correct dealt position. Drag-rejection tweens are
unaffected because their end equals the card's current game-state position.
Issue #6 — Android stale corner label text:
AndroidCornerLabel now carries the label string as AndroidCornerLabel(String).
resize_android_corner_labels refreshes Text2d content from the stored value
alongside the existing font-size and transform updates, closing the narrow
race where a layout change could display a previous card's rank/suit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The four tests polled the async task pool with a fixed budget of five
app.update() calls. Under cargo test --workspace the pool's background
threads are starved by other tests, so even an instantly-resolving future
can take more than five frames to be polled. Replace the fixed loop with a
deadline-bounded loop (5 s timeout) that exits early once the expected
side-effect is observable — the same pattern used in sync_plugin.rs tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a card slides to a foundation slot already occupied, both card entities
share the same x,y for the duration of the tween. With STACK_FAN_FRAC only
0.003 apart, the incoming card partially occludes the stationary one, making
the two exposed corners look like a single mismatched card.
Elevate every CardAnim-driven card to target.z + 50 during transit so it
fully occludes any card resting at the destination. On completion the card
snaps to the correct resting z. The value sits below DRAG_Z (500) so dragged
cards still render above animated ones.
Closes #implicitly-related-to-corner-mismatch-investigation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The game timer kept counting during the auto-complete animation even
though the player had already made their last decision. stopTimer() is
now called the moment is_auto_completable fires so elapsed_seconds
reflects only real play time, not the animation delay.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The pre-built pkg predated fix c35c045 (enable take-from-foundation by
default) so the WASM game always had take_from_foundation=false, silently
rejecting every drag from a foundation pile to a tableau column.
Rebuilt with wasm-pack --release against current solitaire_core.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Initialised ruflo v3 via `npx @claude-flow/cli@latest init --wizard --force`.
Registers the ruflo MCP server in .mcp.json (hierarchical-mesh topology,
max 15 agents). Includes .claude-flow/ runtime config and capability manifest.
.claude/ remains gitignored (local agents/commands/settings stay per-developer).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Every draw-from-stock tap was also firing the HUD auto-hide toggle
because the stock pile is not an ActionButton and toggle_hud_on_tap
had no way to know the tap was consumed by game logic.
Add GameInputConsumedResource(bool): handle_touch_stock_tap sets it
on TouchPhase::Started when a draw fires; toggle_hud_on_tap checks
and clears it on TouchPhase::Ended, treating it as equivalent to
started_on_button so the HUD stays put.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add Rank=1..13 explicit discriminants so `rank as u8 == rank.value()`; collapse 13-arm value() match to `self as u8`
- Add Rank::RANKS and Suit::SUITS iteration constants
- Add Rank::checked_add / checked_sub (const fn, type-safe boundary enforcement); update rules.rs to use them
- Add GameState::possible_instructions() enumerating all valid move_cards triples (foundation for hints/solver)
- Fix waste buffer card peeking through during draw-slide animation by setting Visibility::Hidden on the buffer entity in sync_cards
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- poll_opt_in_task / poll_opt_out_task: error branches now fire WarningToastEvent instead of InfoToastEvent
- Settings gains leaderboard_opted_in: bool (serde-defaulted to false); set true/false when opt-in/out tasks succeed
- handle_display_name_confirm: when already opted in and a remote provider is active, spawns an opt_in_leaderboard task to push the new name (server endpoint is an upsert)
- LeaderboardPublicNameText marker component added; update_leaderboard_public_name_label system rewrites the label each frame the panel is open, so it reflects SettingsResource immediately after the display-name modal saves
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ScorePulse, ScoreFloater, StreakFlourish (hud_plugin) and ShakeAnim,
FoundationFlourish, FoundationMarkerFlourish (feedback_anim_plugin) are
now all suppressed when Settings::reduce_motion_mode is on. Events are
still drained so no messages accumulate. Closes the remaining gap from
the v0.21.1 "future scope" footnote for the reduce-motion flag.
Three new tests pin the gates:
- score_change_skips_pulse_and_floater_under_reduce_motion
- shake_anim_skipped_under_reduce_motion
- foundation_flourish_skipped_under_reduce_motion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sync errors were silently swallowed — the player had no feedback when a
pull failed due to network issues or an expired session. Now `poll_pull_result`
emits a `WarningToastEvent` with a human-readable message for every error
variant, and reopens the Connect modal on auth failure so the player can
re-enter credentials without navigating through Settings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ModalCard carries Transform (for its 0.96→1.0 scale entrance animation),
which auto-inserts GlobalTransform. Bevy 0.18's on_insert hook on
GlobalTransform fires B0004 when the child has GlobalTransform but the
parent does not. ModalScrim had only Node (which gives InheritedVisibility
via UiTransform but not GlobalTransform), so every modal spawn triggered
the warning.
Adding Transform::default() to ModalScrim gives it GlobalTransform and
satisfies the hook. UI layout is unaffected because Bevy's layout pipeline
reads UiTransform, not Transform.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ZIndex(Z_HUD + 4) and ZIndex(Z_HUD + 5) across four sites in
hud_plugin.rs were magic-number expressions. Define named constants in
ui_theme:
Z_HUD_POPOVER_BACKDROP = Z_HUD + 4 (fullscreen dismiss backdrop)
Z_HUD_POPOVER = Z_HUD + 5 (popover panel)
The score-delta floater (Z_HUD + 10) now uses the existing Z_HUD_TOP
constant, whose doc is updated to mention transient annotations.
Both new constants are added to the monotonic z-hierarchy test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The FOCUS_RING constant was updated to match ACCENT_PRIMARY (brick-red,
srgb 0.647/0.259/0.259) during the Terminal palette swap but the doc
comment still described the old cyan value (rgba 111/194/239). Update
the colour name and rgba sample to match the actual constant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
opt_in_leaderboard in sync_client.rs was passing display_name through
as-is, relying solely on the engine's .chars().take(32) call upstream.
Add the truncation in the sync client so any caller is protected, and
also apply it at save-time in handle_display_name_confirm so settings
never stores an over-length name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The keystore atomic write used path.with_extension("tmp") producing
auth_tokens.tmp, while cleanup_orphaned_tmp_files only matched *.json.tmp.
A crash after the write but before the rename left an orphaned file
invisible to cleanup.
Fix: use path.with_extension("bin.tmp") to produce auth_tokens.bin.tmp,
and broaden the cleanup glob from ends_with(".json.tmp") to
ends_with(".tmp") so both JSON and binary temp files are caught.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add PartialEq, Eq, Serialize, Deserialize to AchievementContext per
CLAUDE.md §5.3 derive order. The struct holds only primitive types
(u32, u64, i32, bool, Option<u32>) so all four derives apply without
complications.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
recycle_count already used saturating_add(1); move_count was
inconsistently using += 1 at all three call sites. No real-world
overflow risk (u32 at ~4 billion moves), but the inconsistency was
a code smell flagged by the review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DrawMode is a fieldless two-variant enum — it is trivially bitwise-
copyable. Adding Copy + updating choose_winnable_seed to take the value
directly eliminates 13 superfluous .clone() calls across solitaire_core,
solitaire_engine, solitaire_assetgen, and solitaire_wasm.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The HUD buttons section in the Android controls reference showed "→"
(right-arrow) for the Hint action, but the actual on-screen button is
labelled "!" (ASCII exclamation). Extract ANDROID_HINT_LABEL from
hud_plugin so both the spawn path and the help text share a single
source of truth. Add a cfg(android) regression test that asserts the
hint row's key string matches the const.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace per-call new_current_thread() runtimes with a single
TokioRuntimeResource(Arc<Runtime>) built once at startup using
new_multi_thread(worker_threads(2)). The Arc is cloned cheaply into
each AsyncComputeTaskPool closure, eliminating repeated OS thread
allocation on every sync pull/push, auth, avatar fetch, and analytics
flush. Using a multi-threaded runtime ensures concurrent block_on calls
from different worker threads are safe.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously both ReplayPlayer::state() and ::step() returned JsValue::NULL for
both the expected "replay exhausted" case and the unexpected "serialisation
failed" case. JavaScript callers could not distinguish the two.
Now both methods return Result<JsValue, JsValue>:
- step() returns Ok(null) when the replay is finished (expected sentinel)
- step() and state() Err(string) when serde_wasm_bindgen fails (throws JS exception)
Same fix applied to SolitaireGame::state().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The same "Leaderboard update failed" message was shown for both join and
leave failures, leaving the player unable to tell which operation failed.
Now shows "Failed to join leaderboard" or "Failed to leave leaderboard"
with specific wording that matches the player's intent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Hoist APP_DIR_NAME = "ferrous_solitaire" to solitaire_data crate root
as pub(crate); remove 5 duplicate local definitions across achievements,
progress, settings, storage, replay modules (L-9)
- Add #[must_use] to can_place_on_foundation, can_place_on_tableau, and
is_valid_tableau_sequence in solitaire_core::rules so callers that
accidentally discard the result get a compile-time warning (L-6)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- find_draggable_at: break instead of return None on non-top non-tableau
hit so remaining pile searches are not abandoned early (M-9)
- update_stock_count_badge: run only when GameStateResource changes (M-5)
- update_drop_highlights: run only when DragState changes (M-6)
- update_high_contrast_borders/backgrounds: run only when SettingsResource
changes (M-7)
- update_selection_hud: run only when SelectionState or GameStateResource
changes; uses resource_exists_and_changed to avoid panic in tests where
SelectionState is not registered (M-8)
- Volume toast threshold: f32::EPSILON → 0.001 to avoid spurious toasts
from float rounding noise in settings events (M-10)
- check_no_moves: collapse read().next().is_some() + clear() into a single
read().count() > 0 drain (M-11)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Derive PartialOrd+Ord on PileType and sort pile entries in pile_map_serde
before serializing so save-file output is deterministic (M-4)
- Add #[serde(skip)] to undo_stack so transient undo history is never written
to save files, eliminating unnecessary bloat (M-3)
- Add merge_at() accepting an explicit resolved_at timestamp so callers can
inject the server-side time; merge() wraps it with Utc::now() for
backwards compatibility (M-1)
- Fix url_encode to percent-encode UTF-8 bytes rather than Unicode codepoints
so multi-byte characters produce RFC 3986-compliant output (M-2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move /avatars ServeDir behind require_auth middleware so avatar files
can only be fetched by authenticated users (H-11)
- Make avatar upload atomic via .tmp write + rename, cleaning up stale
extensions only after the rename succeeds (H-12)
- Return 401 instead of silently returning an empty username string when
the user row is unexpectedly missing a username (L-17)
- Add user_id mismatch guard to merge(): returns local payload unchanged
with a ConflictReport rather than silently cross-contaminating data (H-2)
- Truncate opt-in display_name to 32 chars client-side before sending,
matching the server's DISPLAY_NAME_MAX validation (L-5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
H-3: cursor_plugin drop_overlay_rect and card_centre_for_index now use
layout.tableau_fan_frac instead of the static TABLEAU_FAN_FRAC constant,
so drop zones match the actual card fan on portrait Android.
Removed now-unused TABLEAU_FAN_FRAC import.
H-4: touch_end_drag uncommitted-tap branch no longer writes StateChangedEvent.
The mouse path (end_drag) already omits this event for uncommitted drags;
the touch path now matches, preventing double-animation on valid taps.
H-6: update_selection_highlight is now gated with run_if(resource_changed)
on SelectionState | KeyboardDragState | GameStateResource, eliminating
the unconditional every-frame despawn+respawn of highlight sprites.
H-7: toggle_home_screen (M-key) now checks other_modal_scrims.is_empty()
before spawning the home screen, preventing a second concurrent ModalScrim
when another overlay is already open.
H-8: spawn_mode_card now inserts ModalButton(ButtonVariant::Secondary) so
paint_modal_buttons applies hover/press colour feedback on Android.
H-10: auto_resume_on_overlay excludes ForfeitConfirmScreen from its
"other scrims" query via NonPauseFamilyScrim type alias. Opening the
forfeit confirm no longer immediately despawns its parent pause modal.
Also guards paused.0 assignment with an if-check to suppress spurious
change-detection writes (L-15).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CR-2: dismiss_modal_on_scrim_click now queries only the target scrim's
Children rather than all ModalCard entities globally. Prevents
dismissing the wrong scrim when two overlapping modals are open.
CR-5: handle_home_draw_mode_buttons and handle_home_difficulty_toggle
now check other_modal_scrims.is_empty() before the despawn+respawn
cycle, preventing a concurrent second ModalScrim in the same frame.
H-1: solitaire_core::game_state — replaced all panicking piles[&key]
index accesses with safe .get().ok_or(MoveError::InvalidSource)?,
.get().is_some_and(...), or .get().and_then(...) in draw(),
check_auto_complete(), next_auto_complete_move(), foundation_slot_for().
H-5: input_plugin end_drag and touch_end_drag — replaced piles[&target]
with .get(&target).is_some_and(...) so missing pile types reject the
move rather than panicking.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CR-1: apply_stock_empty_indicator now receives a Handle<Font> from FontResource
so the ↺ label uses FiraMono (Arrows block) instead of the default font.
All three callers (startup, state-change, window-resize) updated.
CR-4: spawn_hud_band, spawn_hud, spawn_hud_avatar, spawn_action_buttons no
longer add SafeAreaInsets physical-pixel values to initial Val::Px offsets.
SafeAreaAnchoredTop/Bottom systems already divide by scale_factor and apply
the correct logical-pixel offset when insets arrive; the initial spawn value
is always 0.0 at Startup on Android anyway. Removed now-unused SafeAreaInsets
import and parameter from all four Startup systems.
H-9: Difficulty section chevrons ▶/▼ (U+25BA/U+25BC, Geometric Shapes — not in
FiraMono) replaced with ASCII ">"/"v" which render correctly on Android.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug 1 (card_plugin): waste Draw-Three fan step was a fixed 0.28×card_width,
chosen for the desktop gap ratio (H_GAP_DIVISOR=4). On Android
(H_GAP_DIVISOR=32) the column spacing is only 1.031×card_width, so the same
fraction pushed the top fanned card's centre past the waste column's right
edge. Fix: derive fan_step from column spacing × 0.224 — preserves 0.28×cw
on desktop while reducing to ≈0.231×cw on Android, keeping fanned cards
within their column footprint. Adds regression test on 900×2000 portrait window.
Bug 2 (safe_area): refresh_insets stored its retry counter as Local<u32>,
making it impossible to re-arm after a background/foreground cycle. On resume
the counter was already saturated so JNI was never re-queried; layouts
computed with stale (zero) insets pushed the top card row up under the HUD.
Fix: convert tries to SafeAreaPollTries Resource; add android::rearm_on_resumed
which resets both counter and SafeAreaInsets on AppLifecycle::WillResume so
the poller re-fires; add on_app_resumed (all platforms) which emits a synthetic
WindowResized on WillResume to immediately trigger layout recomputation. Adds
pure-function regression test in layout.rs pinning the suspend→resume invariant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All three spades face cards had a heart (♥) baked into their
bottom-right corner instead of a spade (♠). Fixed by rotating the
correct top-left corner 180° and stamping it over the wrong corner.
Pixel-count parity confirmed between TL and BR corners on all three cards.
Deletes QS_BUG.md now that the asset content bug is resolved.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug #1 (QS wrong watermark): extracted card_face_asset_path() pure helper so
the (Rank, Suit) → filename mapping is tested in isolation. 6 new unit tests
confirm all 52 keys are unique and each suit resolves to its correct letter.
QS.png has the wrong artwork baked in (confirmed via MD5); QS_BUG.md documents
the required asset replacement.
Bug #2/#3 (red square / invisible black suit on Android): add_android_corner_label
used TextFont { ..default() } which gives Bevy's built-in font — that font
lacks U+2660–U+2666, so suit glyphs rendered as a colored missing-glyph
rectangle. Threaded Option<&Handle<Font>> from sync_cards_startup/on_change →
sync_cards → spawn/update_card_entity → add_android_corner_label, which now
passes FiraMono explicitly. Non-Android builds silence the unused param with
let _ = font_handle.
Bug #4 (waste pile): static analysis found no z or fan-offset bug; two new
tests (waste_pile_cards_have_strictly_increasing_z, _draw_one_cards_have_distinct_z)
pin the invariant for future changes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SettingsResource is not yet available at Startup, so load_initial_theme
fell back to "dark" on every run. On AMOLED the dark back (▒151515) is
invisible, showing only a 24×32 px red badge — the "tiny red squares"
bug. Cascade-collapse and top-row legibility were visual consequences of
the same invisible face-down cards, not layout bugs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug 1: StockCountBadge was centred 12 px inward from the stock pile's right
edge but its half-width of 17 px pushed the right edge 5 px past the pile
boundary. On Android (H_GAP_DIVISOR=32, inter-pile gap ~4 px) the badge
corner covered the waste pile's left edge at Z=30, making the waste card
appear clipped. STOCK_BADGE_INSET.x: -12 → -20 keeps the right edge 3 px
inside the stock pile on every device.
Bug 2: The top HUD band Node had an opaque dark-grey BackgroundColor sized to
HUD_BAND_HEIGHT (64/80 px). With only Tier-1 content (~30 px) visible in
typical gameplay the grey block appeared far taller than its content. Removed
BackgroundColor from the band entity; layout reservation in compute_layout is
unchanged and the bottom action bar retains its own background.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dark theme back.svg uses #151515 (near-black) as the card back background,
which AMOLED screens render as fully-off pixels, leaving only the tiny
#a54242 red badge visible — user sees solid red squares instead of card backs.
Fix: change fresh-install default theme from "dark" to "classic" (white
background with navy diamond pattern, clearly visible on all display types).
Also remove the stale "classic" -> "dark" sanitize migration, correct wrong
asset paths in load_card_images (classic/ subdirectory was missing), and
update tests that hardcoded the old TABLEAU_FAN_FRAC=0.25 constant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>