Step 1 — Cargo & registry:
- Add .cargo/config.toml with Quaternions sparse registry
(https://git.aleshym.co/api/packages/Quaternions/cargo/)
- Add klondike = "0.2.0" to workspace deps (+ card_game v0.3.0,
arrayvec v0.7.6 as transitives via the Quaternions registry)
- Add klondike as a solitaire_core dep
Step 3 — KlondikeConfig / MoveFromFoundationConfig:
- KlondikeAdapter::new(draw_mode, take_from_foundation) builds a
KlondikeConfig with the correct DrawStockConfig and
MoveFromFoundationConfig (Allowed/Disallowed); exposes it via
klondike_config() for future solver and pile-mapping steps
Step 4 — Scoring via ScoringConfig:
- GameState.adapter (serde(skip)) owns the authoritative KlondikeConfig
with ScoringConfig::DEFAULT (WXP values)
- score_for_move/flip/undo/recycle replace direct scoring.rs calls;
scoring.rs retained for reference and future deletion
- score_for_recycle implements the WXP free-recycle allowance rule
that ScoringConfig::recycle cannot express (flat delta)
- PartialEq/Eq for KlondikeAdapter compare draw_stock and
move_from_foundation only (scoring is always DEFAULT)
All 192 solitaire_core tests pass; clippy -D warnings clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both upstream issues are now merged:
- PR #13 (closes#10): ScoringConfig with 5 configurable deltas lands
in KlondikeConfig; KlondikeStats gains flip_up_bonus_count and
move_from_foundation_count; score() takes &ScoringConfig
- PR #12 (closes#11): MoveFromFoundationConfig (Allowed/Disallowed)
lands in KlondikeConfig; is_instruction_valid enforces it
Doc changes:
- "Already has" table updated with ScoringConfig, MoveFromFoundationConfig,
richer KlondikeStats counters, and version numbers (v0.3.0 / v0.2.0)
- Gap 1 scoring table gains a "Handled by" column showing which deltas
upstream now owns vs. which remain in our adapter (undo penalty,
recycle-with-free-allowance, score floor, time bonus)
- Gap 1 adds note that ScoringConfig::recycle is a flat delta and cannot
express the "N free recycles then penalty" WXP rule
- Gap 4 marked as upstream merged; notes that upstream default is
MoveFromFoundationConfig::Allowed — we must explicitly set Disallowed
- Integration path: steps renumbered (8→7), step 3 now configures
MoveFromFoundationConfig, step 4 splits upstream-handled vs.
adapter-owned scoring; dependency versions pinned
- References updated with PR links and release commit hashes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.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>
toggle_leaderboard_screen was missing the other_modal_scrims guard that
all other panel-toggle systems have. Pressing L (or the HUD button) while
any other modal was open would spawn a second ModalScrim on top of the
existing one, breaking z-ordering and leaving the first modal un-dismissable.
Adds:
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>
and the early-return guard before spawn_leaderboard_screen is called.
Closes#77
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pressing S (or the Stats HUD button) while another modal was open
(Settings, Profile, Leaderboard, etc.) would spawn a second ModalScrim
on top of the existing one, violating the one-scrim-at-a-time invariant.
Add other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>
matching the guard pattern used by every other modal-spawning system.
Also import ModalScrim which was previously not imported in this file.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- sync.rs: replace Uuid::nil() placeholder with the authenticated
user's real UUID before the mismatch check so desktop client pushes
no longer fail with 400 user_id mismatch (#73)
- replays.rs: use server-computed received_at instead of client-supplied
header.recorded_at when updating leaderboard recorded_at to prevent
timestamp spoofing (#74)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- challenge_plugin: replace silent warn+return with InfoToast when all
challenges are completed so the player gets clear feedback (#72)
- input_plugin: add AutoCompleteState guard to start_drag,
touch_start_drag, and handle_double_tap so player input cannot race
with the auto-complete move sequence (#71)
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>
All engine plugin registrations now live in CoreGamePlugin::build().
build_app() is reduced to DefaultPlugins setup + CoreGamePlugin registration.
sync_provider is threaded through CoreGamePlugin::new() via Mutex<Option<...>>.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
'git checkout deploy' is ambiguous because the repo contains both a
deploy/ directory and a deploy remote tracking branch. Switch to
'git switch' which is branch-only and unambiguous.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The binary in pkg/ was built on May 18, predating commit 3322fd4
(fix(wasm): enable take-from-foundation in web game client, May 19).
Dragging Foundation cards to Tableau was silently rejected because
take_from_foundation was false in the stale binary.
Rebuilt with ./build_wasm.sh against current solitaire_core.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add two tests verifying that possible_instructions includes
Foundation→Tableau moves when take_from_foundation is enabled,
and excludes them when it is disabled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fixes#34, #35, #36
- all_hints: add Foundation as source for Tableau hints (guarded by
take_from_foundation); previously H key never suggested Foundation→Tableau
- end_drag / touch_end_drag: enforce take_from_foundation at input layer
so a rejected-by-core MoveRequestEvent is never fired
- animation_plugin: pub CARD_ANIM_Z_LIFT so card_plugin can consume it
- update_card_entity: set CardAnim start.z = z + CARD_ANIM_Z_LIFT to
eliminate 1-frame z artifact where animated card appeared behind resting cards
- solitaire_app: use AutoVsync on Android (caps GPU at display Hz vs
spinning at 200+ fps); add WinitSettings unfocused reactive_low_power
so app draws ~1fps when backgrounded
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
targetRevision changed from master to deploy so Argo CD tracks the
image-tag commits the CI bot writes there, not the source branch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The CI bot was committing image-tag bumps back to master after every
Docker build, which forced a `git pull --rebase` before every developer
push. Moving the kustomization commit to a dedicated `deploy` branch
keeps master clean — the build bot no longer diverges it.
Argo CD / Flux should now watch the `deploy` branch (targetRevision:
deploy) instead of master.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Engine: replace broken has_legal_moves loop (which checked buried
mid-column cards without sequence validation) with a delegation to
possible_instructions(), mirroring the hint system's logic exactly.
WASM: add has_moves: bool to GameSnapshot, computed in snap() using the
same stock/waste/possible_instructions check so the web client gets the
flag in every state update at no extra round-trip cost.
Web: show a non-blocking no-moves banner (slide-up toast) with Undo and
New Game actions when has_moves is false and the game is not won. Banner
hides automatically once a move restores legal play (e.g. after undo).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The flag was modelled as an opt-in non-standard rule but moving a card
off a foundation is in fact standard Klondike — disabling it is the
non-standard variant.
Changing the core default to true means every client (desktop, Android,
web) gets correct behaviour without each having to independently patch
the value after construction. Clients that expose a settings toggle
(desktop/Android) can still disable it through SettingsResource.
- game_state.rs: flip default from false → true in new_with_mode
- game_state.rs: rename/update take_from_foundation_disabled_by_default
test to reflect the new intended default
- solitaire_wasm/lib.rs: remove now-redundant override in new()
(from_saved keeps its override to fix old saves that serialised false)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GameState::new_with_mode defaults take_from_foundation=false (non-
standard; the flag exists so the desktop can offer it as a setting).
The WASM web client has no settings layer, so this flag was never
flipped on — every drag or double-click from a foundation pile was
silently rejected by the rules engine.
Set take_from_foundation=true in both SolitaireGame::new (fresh games)
and SolitaireGame::from_saved (restored games, which may have the old
default serialised).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- solitaire_wasm: add SolitaireGame::serialize() and from_saved() so JS
can round-trip the full GameState through localStorage as JSON
- game.js: save {gameState, elapsedSecs, drawThree} to localStorage
(key: fs_game_save) on every render(); clear the save on win
- game.js: on bootstrap, check for a saved game and show a resume
dialog if one exists; Resume restores state + timer, New Game discards
the save and starts fresh with a random seed
- game.html: add #resume-overlay markup (same pattern as win-overlay)
- game.css: add styles for the resume dialog and its secondary button
localStorage failures (private-browsing quota) are silently ignored so
they never block gameplay.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The sync-setup modal was silently blocked by its own guard:
other_modal_scrims checks for any ModalScrim without SyncSetupScreen,
but the Settings panel IS a ModalScrim, so clicking Connect from within
Settings always hit the guard and returned early.
Two fixes:
- handle_sync_buttons: set SettingsScreen.0 = false when ConnectSync
is pressed so settings closes as the event is fired
- open_sync_setup_modal: exclude SettingsPanel from other_modal_scrims
to handle the deferred-despawn timing window (settings scrim entity
still exists in the world until command buffers flush at frame end)
- Make SettingsPanel pub so sync_setup_plugin can reference it
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GameState serializes take_from_foundation=false (the core default),
so saved games on disk and direct-loaded states never had the setting
applied from SettingsResource — only freshly dealt games did.
Two fixes:
- sync_settings_to_game: new system that reads SettingsChangedEvent
and patches game.0.take_from_foundation on every settings change
(covers initial settings load at startup and in-session toggles)
- handle_restore_prompt: apply settings immediately after game.0 =
restored so the Continue path also respects the current setting
- Register SettingsChangedEvent in GamePlugin::build (idempotent with
SettingsPlugin) so the message is available in headless test apps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- radial_menu: replace active_id.unwrap() with let Some guard — no
runtime panic possible even if DragState races (§2.3)
- card_plugin: add bottom-right AndroidCornerBg overlay to mask the
rotated baked-in text on classic PNG cards (mirrors top-left treatment)
- hud_plugin: bump Android action button min_width 44→52 px to give
~22px glyphs adequate padding after dynamic font-size increase
- layout: fix doc-lazy-continuation clippy lint in BOTTOM_BAR_HEIGHT comment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
compute_layout only subtracted safe_area_bottom (OS gesture/nav bar) from
the vertical budget, but the app's own action bar (≡ ← || ? ! M +) sits
*above* that zone — invisible to safe_area_bottom. On Android the bar is
60 px tall (44 px min-height buttons + 8 px top + 8 px bottom bar padding),
so deep tableau columns scrolled 60 px behind the button row.
Fix: add BOTTOM_BAR_HEIGHT (60 px Android, 0 desktop) to safe_area_bottom
before both affected calculations:
• card_width_height_based — height-based card sizing
• avail — budget fed to update_tableau_fan_frac for adaptive fan spacing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The bottom bar's 7 icon buttons (≡ ← || ? ! M +) used TYPE_BODY = 14 px,
a fixed size that is too small on phone screens.
New behaviour:
- `action_bar_font_size(window_width)` returns `(window_width / 40).clamp(16, 30)`,
giving ~22 px on a 900 logical-px phone and ~16 px on narrow viewports.
- `ActionButtonLabel` marker added to each button's text node (Android only).
- `spawn_action_buttons` reads `Query<&Window>` at startup to apply the
correct initial size before the first frame renders.
- `resize_action_bar_labels` system re-runs whenever `LayoutResource`
changes (window resize / orientation change) to keep glyphs in sync.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
card_plugin: AndroidCornerLabel used CARD_FACE_COLOUR (dark ~#1a1a1a) as
the background and BLACK_SUIT_COLOUR (near-white) for clubs/spades text —
both designed for the Terminal theme. On classic PNG cards (white face),
this produced an ugly dark box with invisible black-suit text. Switch the
corner-label background to Color::WHITE and black-suit text to
CARD_FACE_COLOUR (dark ink on white), matching traditional card printing.
layout: HUD_BAND_HEIGHT on Android raised 80 → 112 px. The HUD column has
4 flex tiers plus 3 inter-tier gaps (4 px each) and a SPACE_2 = 8 px top
offset. With empty tiers still occupying gap height in Bevy's flex layout,
the actual rendered HUD could reach ~80 px, overlapping the top card row
by up to one text line. 112 px provides ~28 px clearance in the common
case (Tiers 1 + 3 visible) and remains workable even when Tier 1 wraps.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>