Compare commits

...

18 Commits

Author SHA1 Message Date
funman300 374858ab6d Merge pull request 'feat(engine): tabbed Settings panel — Phase A of the menu redesign' (#160) from feat/settings-tabs into master
Build and Deploy / build-and-push (push) Successful in 2m23s
Web WASM Rebuild / rebuild (push) Waiting to run
Test / test (push) Successful in 37m11s
2026-07-09 04:59:11 +00:00
Gitea CI e1d91bee73 ci(web-wasm-rebuild): keep on Docker runner (host executor incompatible with taiki-e) [skip ci] 2026-07-08 15:18:40 -07:00
Gitea CI 9dcd25b3e7 ci(web-wasm-rebuild): set RUNNER_OS/ARCH on the taiki-e step (step-level env)
Web WASM Rebuild / rebuild (push) Failing after 1m54s
2026-07-08 15:01:12 -07:00
Gitea CI 2ef0bce1ea ci(web-wasm-rebuild): set RUNNER_OS/RUNNER_ARCH for host-executor runner
Web WASM Rebuild / rebuild (push) Failing after 1m54s
2026-07-08 14:53:42 -07:00
Gitea CI 326ef6894a ci: route web-wasm-rebuild workflow to the rust-host runner (CT 107)
Web WASM Rebuild / rebuild (push) Failing after 2m0s
2026-07-08 13:27:34 -07:00
Gitea CI caaafe34e0 ci(test): route test workflow to the rust-host runner (CT 107)
Test / test (push) Failing after 1m3s
2026-07-08 12:27:34 -07:00
Gitea CI 2254693a7a build(wasm): honor CARGO_TARGET_DIR in build_wasm.sh [skip ci] 2026-07-08 12:25:09 -07:00
Gitea CI 4ba646738c chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m24s
Web E2E / web-e2e (push) Successful in 5m33s
2026-07-08 03:08:06 +00:00
funman300 251d35bc28 Merge pull request 'feat: Show solution — winning_line core API + auto-playback (card_game bucket 4)' (#159) from feat/solution-line into master
Build and Deploy / build-and-push (push) Successful in 6m20s
Test / test (push) Successful in 11m24s
Web E2E / web-e2e (push) Successful in 4m46s
Web WASM Rebuild / rebuild (push) Successful in 8m10s
2026-07-08 01:08:58 +00:00
funman300 fe3c3aed31 fix(ci): cap cargo to two parallel jobs so concurrent test-binary links fit runner memory
Test / test (push) Successful in 36m31s
Runs 514/516/519 (incl. master itself) died on 'ld terminated with
signal 9' — the workspace now links enough large test binaries that
per-core parallel linking OOMs the runner even at line-tables-only
debuginfo. CARGO_BUILD_JOBS=2 keeps at most two links in flight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:33:36 -07:00
Gitea CI d0e4ce796b chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m24s
Web E2E / web-e2e (push) Successful in 4m12s
2026-07-08 00:24:36 +00:00
funman300 50d6d41d85 feat(engine): tabbed Settings panel — Phase A of the menu redesign
Test / test (pull_request) Failing after 7m15s
Replaces the 37-row single-scroll settings modal with five tabs
(Audio, Gameplay, Appearance, Accessibility, Account); only the active
tab's rows spawn, so every tab fits without scroll-hunting.

- SettingsTab + ActiveSettingsTab (session-only) + SettingsTabButton
  chips under the modal header; rebuild-on-switch mirrors the
  leaderboard despawn/respawn pattern via a shared build_panel helper
- Accessibility tab gathers color-blind, high-contrast, reduce-motion,
  touch-input, and tooltip-delay from the old Gameplay/Cosmetic mix;
  Account = Sync + Privacy
- SettingsButton enum, input handlers, and persistence untouched
- Tests: per-tab Focusable/Tooltip sweeps, tab-switch rebuild test,
  picker/thumbnail tests pinned to the Appearance tab

Plan: docs/ui-redesign-2026-07.md (also added, phases A–M)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:47:06 -07:00
funman300 9e4d4a6716 Merge pull request 'feat(core): Spider rules as a second card_game::Game implementation' (#157) from feat/spider-core into master
Build and Deploy / build-and-push (push) Successful in 6m3s
Web E2E / web-e2e (push) Successful in 5m7s
Web WASM Rebuild / rebuild (push) Successful in 7m10s
Test / test (push) Successful in 9m31s
2026-07-07 23:40:43 +00:00
funman300 ea1014285d feat(engine): Show solution — auto-play the winning line from the pause menu
Test / test (pull_request) Successful in 15m21s
New SolutionPlaybackPlugin: the pause modal's 'Show solution' button
resumes the game and requests a solve of the live position via
GameState::winning_line on AsyncComputeTaskPool (never blocks the main
thread; stale results discarded via a move_count snapshot). The line
then plays back one instruction per 0.45 s through the normal
MoveRequestEvent / DrawRequestEvent pipeline — animations, scoring,
undo history and win detection behave as if the player made the moves.
Tableau run counts are decoded against the live state at step time.

Playback cancels on Esc, pause, undo / new-game requests, and any
rejected move (the signature of the player diverging the board).
Toasts cover search start, line found, unwinnable, and budget-
exhausted outcomes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:33:49 -07:00
Gitea CI 4f849a23b8 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m20s
Web E2E / web-e2e (push) Successful in 4m55s
2026-07-07 23:31:55 +00:00
funman300 299f6bfea7 feat(core): winning_line — full solver line via Solution::clean_solution
New GameState::winning_line(moves_budget, states_budget) returns the
complete instruction sequence to a win (Ok(None) when unwinnable or
already won, Err on budget exhaustion), compacting the raw DFS trace
with the previously unused card_game Solution::clean_solution and
stripping foundation→foundation no-ops when the stripped line still
replays to a win — an internal replay check guarantees the returned
sequence always applies cleanly via apply_instruction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:19:12 -07:00
funman300 8c0eb3dfab Merge pull request 'fix(engine): force full board repaint when the decor-view poller fires (#130)' (#155) from fix/130-forced-repaint into master
Build and Deploy / build-and-push (push) Successful in 2m13s
Test / test (push) Successful in 8m7s
Web WASM Rebuild / rebuild (push) Successful in 7m29s
2026-07-07 23:14:11 +00:00
funman300 a39e04329e fix(engine): force full board repaint when the decor-view poller fires (#130)
Test / test (pull_request) Successful in 12m14s
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>
2026-07-07 16:01:25 -07:00
18 changed files with 1448 additions and 335 deletions
+8 -1
View File
@@ -29,14 +29,21 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: rust-host
# Full debuginfo made the solitaire_engine test-binary link peak past the
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
# line-tables-only keeps file:line in panic backtraces while cutting the
# link's memory footprint enough to fit the runner.
#
# CARGO_BUILD_JOBS=2: with one job per core, cargo links several large
# test binaries concurrently; as the workspace grew (runs 514/516/519)
# two+ simultaneous ld processes OOM-killed the runner again even at
# line-tables-only. Two jobs keeps at most two links in flight — the
# compile-throughput cost is small next to the cache-warm build.
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_BUILD_JOBS: '2'
steps:
- name: Checkout
+1 -1
View File
@@ -77,7 +77,7 @@ wasm-bindgen \
--out-name canvas \
--target web \
--no-typescript \
"$REPO_ROOT/target/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
"${CARGO_TARGET_DIR:-$REPO_ROOT/target}/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
# wasm-opt passes are skipped silently when the tool is not installed.
+204
View File
@@ -0,0 +1,204 @@
# Menu UX Redesign — July 2026
Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
out of scope — this is about **structure and interaction**, not colors or type.
## Diagnosis (from code survey, 2026-07-07)
| Surface | Today | Pain |
| --- | --- | --- |
| Settings | One scrolling modal, 5 section labels, ~37 control rows, single Done | Scroll-hunting for any toggle; endless on the folded (cover) screen; every new feature (theme store, import) stretches it further |
| Home | Modal launcher: stats strip, draw-mode row, difficulty rows, 6 equal mode cards, Cancel | No "Continue" primacy; deal options (draw/difficulty/winnable) crowd the mode choice; six cards weighted identically though Classic dominates play |
| HUD menu | Popover with 7 destinations (Help, Modes, Stats, Achievements, Profile, Settings, Leaderboard) | "Modes" duplicates Home's job; flat list, no grouping; each item opens another modal — modal-on-modal navigation |
| Global | Everything is a modal over the felt | No hierarchy; near-square unfolded Fold renders one narrow wasted column |
## Phase A — Settings tabs (highest pain, lowest risk)
- Replace the single scroll with **tabs**: `Audio · Gameplay · Appearance ·
Accessibility · Account`.
- *Appearance* = today's Cosmetic (card back, background, card theme,
theme store, import).
- *Accessibility* = extracted from Gameplay/Cosmetic: color-blind,
high-contrast, reduce-motion, touch input mode, tooltip delay.
- *Account* = Sync + Privacy merged.
- Narrow screens: tab chips in a row under the header. Wide (aspect > ~1.2 —
unfolded Fold, desktop): left rail, rows in two columns.
- Only the active tab's rows are spawned → each tab is ≤ 8 rows and fits the
cover screen without scrolling.
- **Implementation constraint:** pure `ui.rs` re-layout + one `SettingsTab`
resource. The `SettingsButton` enum, input handlers, and persistence are
untouched, so the 25 settings tests and the ambiguity gate stay green.
## Phase B — Home becomes a real home
- **Hierarchy, top to bottom:**
1. `Continue` card (only when a game is in progress) — mode, elapsed, score.
2. Hero `New Game` — one tap, reuses last mode + deal options.
3. Compact 2×3 mode grid (Classic, Daily, Zen, Challenge, Time Attack,
Seed) — smaller cards, glyph + name + one-line description on wide only.
4. Stats strip moves to the bottom (or right pane when unfolded).
- **Deal options** (draw 1/3, difficulty, winnable-only) move off the top
level into a disclosure on the Classic card / New Game hero — they are
Classic-mode concerns, not global ones.
- Cancel becomes `Back to table` and only renders when a game exists.
- Unfolded Fold / desktop: two panes — modes left, Continue + stats +
daily/weekly right.
## Phase C — HUD menu consolidation
- Drop `Modes` from the popover (Home owns mode selection; HUD's New-Game
path opens Home).
- Group the remaining six: **Play** (Home) · **You** (Profile, Stats,
Achievements) · **Community** (Leaderboard) · **System** (Settings, Help) —
section dividers in the existing popover, not a new widget.
- Dismissal audit: Esc / scrim-tap / Done must behave identically on every
modal (most already do via `ScrimDismissible`; sweep the stragglers).
## Phase D — Validation gates
- Optional Stitch mockups (Terminal design-system asset
`assets/0f1652274fea460585d0b331a9ddbb06`, project 593811793268308763) for
Home + Settings before implementing Phase B. Phase A is safe to build
straight in-engine.
- Per-phase gates: full test suite, clippy `-D warnings`, ambiguity gate at 0,
Android checklist (§15.3), on-device pass on the Fold 7 in BOTH postures.
## Sequencing & risk
| Phase | Size | Risk | Depends on |
| --- | --- | --- | --- |
| A — Settings tabs | ~1 session | Low (layout-only) | — |
| B — Home hierarchy | 12 sessions | Medium (touches launch flow) | D mockups if wanted |
| C — Menu grouping | small | Low | B (Modes removal) |
## Phase E — "You" hub (reuses the Phase A tab component)
Profile, Stats, Achievements, and the replay browser are four separate
modals today, reached through the HUD popover one at a time. Fold them
into ONE tabbed hub — `Profile · Stats · Achievements · Replays` — using
the same tab widget Phase A builds for Settings. Cuts the HUD popover to
four destinations and makes the tab component pay for itself twice.
Existing per-screen systems keep their markers/handlers; only the outer
shell changes (same trick as Phase A).
## Phase F — Mobile ergonomics (Fold-first)
- **Bottom action bar** on touch: Undo, Hint, Draw within thumb reach at
the bottom safe-area edge (via the existing `SafeAreaAnchoredBottom`),
score-only band stays on top. Desktop keeps the current top band.
Biggest one-handed-play win available; folded posture is tall and
top-heavy today.
- **Hold-to-repeat Undo** — press-and-hold steps back repeatedly
(respecting the existing undo scoring penalty), instead of tap-tap-tap.
- Both are additive; game stays fully playable with the top band alone
(UI-first rule §3.3 satisfied).
## Phase G — Post-win flow
Win summary today is a stats dump with a close. Give it an action
hierarchy: **Play again** (same mode/options, primary) · **Share replay**
(the upload already returns a share URL; surface it here with copy
feedback) · **Watch replay** · quiet stats below. Rematch loop drops from
4 taps to 1.
## Phase H — Feedback polish (small, bundling candidates)
- Unify queued vs. immediate toast styling (two subtly different styles
exist today) and anchor both to one position; consider a 5-item toast
history on the stats screen.
- Theme-store modal: render the preview PNGs the server already serves
(v1 is text-only rows).
- Hint: optional ghost-motion preview of the suggested move instead of a
static highlight (auto-disabled under reduce-motion).
## Phase I — Onboarding & discoverability
The first-run onboarding exists, but everything learned after it is
invisible: the radial menu (long-press/right-click), hint cycling,
tap-to-toggle HUD chrome, hotkeys. Add **contextual one-time tips** —
fired by the situation, not a tour (first stall → hint tip; first
long-press-able stack → radial tip; each shows once, stored in
Settings like `shown_achievement_onboarding` already is). Plus a
one-shot **"What's new"** card on first launch after a version bump —
ObtainX updates are silent today, so shipped features go unnoticed
(nobody will find the theme store on their own).
## Phase J — Keyboard & focus completeness (desktop + web)
`ui_focus` already gives deterministic Esc order and tab-walk
(`FocusRow`/`Focusable`), and `KeyboardDragState` exists — but full
keyboard-only play has never been audited end to end. Deliverables: every
modal reachable/dismissable without a mouse, a visible focus ring styled
to the Terminal system (current focus state is subtle), and a hotkey
cheat-sheet overlay (hold `?`) generated from the actual bindings instead
of the static Help text. Web build (Rhys) benefits most.
## Phase K — UI scale & touch-target accessibility
All type sizes flow through `TYPE_*` tokens and layout through
`compute_layout` — which makes a **UI scale setting** (90/100/115/130 %)
cheap to wire and genuinely useful on the Fold's dense cover screen.
Pair with: respect Android system font scale on first run (seed the
setting from it), and a one-time audit that every interactive element
meets the 44 px logical minimum in BOTH Fold postures (the pill buttons
and picker swatches are the suspects). Lands in the Accessibility tab
that Phase A creates.
## Phase L — Empty & loading states, standardized
Async/empty surfaces each improvise today: leaderboard with no entries,
stats with no games, replay browser with no replays, theme store
loading/error (just added), sync status text. Define ONE pattern —
glyph + one-line explanation + a single next-step action — as a
`spawn_empty_state` helper in `ui_modal`, and sweep all five surfaces
onto it. Small, mechanical, big perceived-quality win.
## Phase M — Sync transparency & data stewardship
The sync layer returns `ConflictReport`s ("data is never silently
discarded") — but no UI ever shows them; players can't tell what a merge
did. Add: a post-sync summary line ("merged, 2 conflicts kept newer
values" → tap for detail in the Account tab), a visible last-synced
timestamp (exists as `SyncStatus::LastSynced`, barely surfaced), and
local **data export/import** (zip of the JSON saves) for device
migrations without a server. Closes the trust loop the server work
opened.
## Roadmap tie-ins (already in flight elsewhere)
- **Spider mode** (feat/spider-core, PR #157): Home's mode grid gains a
7th card once engine work lands — the 2×3 grid in Phase B should be
designed as N-card flow from day one. Spider UI also needs positional
card→entity keys (documented in spider.rs module docs).
- **Show solution** (feat/solution-line, in flight): lands in the pause
menu; Phase C's grouping should leave room for it under a "Game"
cluster if the pause menu grows.
## Sequencing (updated)
| Phase | Size | Risk | Depends on |
| --- | --- | --- | --- |
| A — Settings tabs | ~1 session | Low | — (DECIDED: tabs) |
| E — "You" hub | ~1 session | Low | A's tab component |
| C — Menu grouping | small | Low | E (popover shrinks) |
| B — Home hierarchy | 12 sessions | Medium | D mockups if wanted |
| F — Bottom action bar | ~1 session | Medium (input paths) | — |
| G — Post-win flow | small | Low | — |
| H — Feedback polish | small each | Low | — |
| I — Onboarding & discoverability | ~1 session | Low | ships best after B/F land |
| J — Keyboard & focus completeness | ~1 session | Low | A/E (fewer modals to audit) |
| K — UI scale & touch targets | ~1 session | Medium (layout-wide) | A (Accessibility tab) |
| L — Empty-state standardization | small | Low | — |
| M — Sync transparency & export | ~1 session | LowMedium | A (Account tab) |
Suggested order: **A → E → C** (one arc: the tab component and the menu
slimming), then **B**, then **F/G/H** as independent follow-ups.
## Open decisions
1. ~~Settings: tabs vs. sub-pages~~**DECIDED 2026-07-07: tabs.**
2. Deal options: disclosure on Classic card (proposed) vs. keep global row?
3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
tucked under a "More" card?
4. Stitch mockups for Phase B, or iterate directly in-engine?
5. Phase F bottom bar: touch-only (proposed) or also desktop?
+142
View File
@@ -1109,6 +1109,66 @@ impl GameState {
})
}
/// Full winning line from the current position:
///
/// * `Ok(Some(line))` — winnable; applying every instruction in order via
/// [`GameState::apply_instruction`] reaches a won game.
/// * `Ok(None)` — provably unwinnable, or already won (no moves to show).
/// * `Err(SolveError)` — inconclusive; budget exhausted before a verdict.
///
/// Delegates to upstream [`card_game::Session::solve`] on a solve-budgeted
/// copy of the board like [`GameState::solve_first_move`], but returns the
/// whole path instead of the first move, compacted with
/// [`card_game::Solution::clean_solution`] (drops move ranges that loop
/// back to an already-seen state — the raw DFS trace is full of them).
///
/// Foundation→foundation shuffles ([`KlondikeInstruction::is_useless`])
/// are additionally stripped when the remaining sequence still replays to
/// a win; if stripping one would break a later move's preconditions the
/// unstripped cleaned line is returned instead, so the replay contract
/// above always holds. `clean_solution` is quadratic-ish in line length —
/// callers should run this off the UI thread with modest budgets.
pub fn winning_line(
&self,
moves_budget: u64,
states_budget: u64,
) -> Result<Option<Vec<KlondikeInstruction>>, SolveError> {
if self.is_won() {
return Ok(None);
}
let inner = KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation);
let config = SessionConfig {
inner: inner.clone(),
undo_penalty: 0,
solve_moves_budget: moves_budget,
solve_states_budget: states_budget,
};
let start = self.session.state().state().clone();
let session = Session::new(start.clone(), config);
let Some(solution) = session.solve()? else {
return Ok(None);
};
let cleaned: Vec<KlondikeInstruction> = solution
.clean_solution()
.iter()
.map(|snapshot| *snapshot.instruction())
.collect();
let filtered: Vec<KlondikeInstruction> = cleaned
.iter()
.copied()
.filter(|instruction| !instruction.is_useless())
.collect();
if line_replays_to_win(start, &inner, &filtered) {
Ok(Some(filtered))
} else {
Ok(Some(cleaned))
}
}
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
///
/// Fresh-deal solving models standard Klondike rules, so the non-standard
@@ -1126,6 +1186,25 @@ impl GameState {
}
}
/// `true` when applying `line` in order from `start` is legal at every step
/// and ends in a won game. Pure replay check backing
/// [`GameState::winning_line`]'s "the returned sequence always replays to a
/// win" contract.
fn line_replays_to_win(
mut state: Klondike,
config: &KlondikeConfig,
line: &[KlondikeInstruction],
) -> bool {
let mut stats = <Klondike as card_game::Game>::Stats::default();
for &instruction in line {
if !state.is_instruction_valid(config, instruction) {
return false;
}
state.process_instruction(&mut stats, config, instruction);
}
state.is_win()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1351,6 +1430,69 @@ mod tests {
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
// ── Full winning line (winning_line) ──────────────────────────────────
#[test]
fn winning_line_replays_to_a_won_game() {
// Seed 0xD1FF_0000_0000_0012 / DrawOne is proven Winnable at 5k
// budgets by `budget_is_passed_through_not_clamped`. Standard rules
// (take-from-foundation off) keep the search space identical to
// that baseline. The returned line must apply cleanly through the
// normal instruction pipeline and end in a win — the whole point
// of the API contract.
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
let line = game
.winning_line(5_000, 5_000)
.expect("this seed must not exhaust a 5k budget")
.expect("this seed must be winnable");
assert!(!line.is_empty(), "a winnable unfinished game needs moves");
for (i, instruction) in line.iter().enumerate() {
game.apply_instruction(*instruction)
.unwrap_or_else(|e| panic!("move {i} of the line must be legal: {e}"));
}
assert!(game.is_won(), "line must end in a won game");
}
#[test]
fn winning_line_contains_no_useless_moves() {
// The foundation→foundation strip must survive the replay check on
// this seed; a line shown to the player should never shuffle
// between foundations. Same proven-winnable seed and standard
// rules as `winning_line_replays_to_a_won_game`.
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
let line = game
.winning_line(5_000, 5_000)
.expect("budget")
.expect("winnable");
assert!(
!line.iter().any(KlondikeInstruction::is_useless),
"filtered line must not contain foundation→foundation moves"
);
}
#[test]
fn winning_line_is_inconclusive_when_budget_exhausted() {
let game = GameState::new(7, DrawStockConfig::DrawOne);
let outcome = game.winning_line(5_000, 0);
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
#[test]
fn winning_line_matches_first_move_verdict() {
// The two solver entry points must agree on winnability for the
// same position and budgets (both are deterministic DFS).
let game = GameState::new(42, DrawStockConfig::DrawOne);
let first = game.solve_first_move(5_000, 5_000);
let line = game.winning_line(5_000, 5_000);
assert_eq!(
matches!(first, Ok(Some(_))),
matches!(line, Ok(Some(_))),
"solve_first_move and winning_line disagree on winnability"
);
}
#[test]
fn budget_is_passed_through_not_clamped() {
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
+5 -3
View File
@@ -18,9 +18,10 @@ use crate::{
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
};
#[cfg(not(target_arch = "wasm32"))]
use crate::{
@@ -93,6 +94,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin)
.add_plugins(SolutionPlaybackPlugin)
.add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin)
.add_plugins(StatsPlugin::default())
+6
View File
@@ -159,6 +159,12 @@ pub struct DeleteAccountRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct PauseRequestEvent;
/// Request to solve the current deal and auto-play the winning line.
/// Fired by the pause menu's "Show solution" button; consumed by
/// `solution_playback_plugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ShowSolutionRequestEvent;
/// Request to toggle the help / controls overlay. Fired by the HUD "Help"
/// button alongside the existing `F1` accelerator so the overlay is
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`.
+2
View File
@@ -46,6 +46,7 @@ pub mod safe_area;
mod schedule_checks;
pub mod selection_plugin;
pub mod settings_plugin;
pub mod solution_playback_plugin;
pub mod splash_plugin;
pub mod stats_plugin;
#[cfg(not(target_arch = "wasm32"))]
@@ -161,6 +162,7 @@ pub use settings_plugin::{
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
};
pub use solitaire_data::SyncProvider;
pub use solution_playback_plugin::{SolutionPlayback, SolutionPlaybackPlugin, SolutionSolveTask};
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
+32
View File
@@ -71,6 +71,12 @@ struct PauseResumeButton;
#[derive(Component, Debug)]
struct PauseForfeitButton;
/// Marker on the "Show solution" secondary button on the pause modal.
/// A click resumes the game and fires `ShowSolutionRequestEvent`;
/// `solution_playback_plugin` takes it from there.
#[derive(Component, Debug)]
struct PauseSolutionButton;
/// Marker on the forfeit-confirm modal scrim.
#[derive(Component, Debug)]
pub struct ForfeitConfirmScreen;
@@ -107,6 +113,7 @@ impl Plugin for PausePlugin {
.add_message::<PauseRequestEvent>()
.add_message::<ForfeitRequestEvent>()
.add_message::<ForfeitEvent>()
.add_message::<crate::events::ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>()
.init_resource::<PausedResource>()
.add_systems(
@@ -125,6 +132,7 @@ impl Plugin for PausePlugin {
handle_pause_draw_buttons,
handle_pause_resume_button,
handle_pause_forfeit_button,
handle_pause_solution_button,
handle_forfeit_request,
handle_forfeit_confirm_buttons,
handle_forfeit_keyboard,
@@ -304,6 +312,22 @@ fn handle_pause_resume_button(
}
}
/// Translates a click on the pause modal's "Show solution" button into
/// a resume (`PauseRequestEvent` — playback can't run while paused)
/// plus a `ShowSolutionRequestEvent` for `solution_playback_plugin`.
fn handle_pause_solution_button(
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseSolutionButton>)>,
mut pause: MessageWriter<PauseRequestEvent>,
mut solution: MessageWriter<crate::events::ShowSolutionRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
pause.write(PauseRequestEvent);
solution.write(crate::events::ShowSolutionRequestEvent);
}
}
}
/// Translates a click on the pause modal's Forfeit button into a
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
/// confirm modal — same code path as the `G` accelerator.
@@ -498,6 +522,14 @@ fn spawn_pause_screen(
ButtonVariant::Tertiary,
font_res,
);
spawn_modal_button(
actions,
PauseSolutionButton,
"Show solution",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
PauseResumeButton,
+9 -2
View File
@@ -82,7 +82,8 @@ impl Plugin for SafeAreaInsetsPlugin {
);
#[cfg(target_os = "android")]
app.init_resource::<android::SafeAreaPollTries>()
app.add_message::<crate::events::StateChangedEvent>()
.init_resource::<android::SafeAreaPollTries>()
.add_systems(Update, android::refresh_insets)
.add_systems(Update, android::rearm_on_resumed)
.add_systems(Update, android::refresh_surface_size);
@@ -330,11 +331,16 @@ mod android {
/// `on_window_resized` in `table_plugin` recomputes the board layout,
/// 3. re-arms the inset poller, because a screen change almost always
/// moves the system bars too — covering the "re-poll never fires"
/// hole left open in #116.
/// hole left open in #116,
/// 4. emits `StateChangedEvent` so `card_plugin`'s sync pipeline
/// re-renders every card sprite from scratch — belt-and-braces for
/// the transient tableau clip of #130, where geometry converged but
/// stale card visuals survived the relayout.
pub(super) fn refresh_surface_size(
mut frame: Local<u32>,
mut windows: Query<(Entity, &mut Window)>,
mut resize_events: MessageWriter<WindowResized>,
mut state_events: MessageWriter<crate::events::StateChangedEvent>,
mut poll: ResMut<SafeAreaPollTries>,
) {
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
@@ -381,6 +387,7 @@ mod android {
width: decor_w as f32 / scale,
height: decor_h as f32 / scale,
});
state_events.write(crate::events::StateChangedEvent);
poll.0 = 0;
}
@@ -418,6 +418,25 @@ pub(super) fn handle_settings_buttons(
}
}
/// Switches the active Settings tab when a chip is pressed. The
/// rebuild itself happens in `rebuild_panel_on_tab_change`, driven by
/// change detection on [`ActiveSettingsTab`].
pub(super) fn handle_tab_buttons(
interactions: Query<(&Interaction, &SettingsTabButton), Changed<Interaction>>,
mut active: ResMut<ActiveSettingsTab>,
) {
for (interaction, chip) in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
// Compare before writing so re-clicking the active chip doesn't
// trip change detection and rebuild the panel for nothing.
if active.0 != chip.0 {
active.0 = chip.0;
}
}
}
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
/// and Delete Account. Split from `handle_settings_buttons` to stay within
/// Bevy's 16-parameter system limit.
+55 -1
View File
@@ -170,6 +170,51 @@ struct SmartDefaultSizeText;
#[derive(Component, Debug)]
struct AnalyticsEnabledText;
/// Which tab of the Settings panel is showing. Tabs replace the old
/// single-scroll five-section layout (Phase A of
/// `docs/ui-redesign-2026-07.md`) so each group fits on screen without
/// scroll-hunting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(super) enum SettingsTab {
#[default]
Audio,
Gameplay,
Appearance,
Accessibility,
Account,
}
impl SettingsTab {
/// Every tab, in display order.
pub(super) const ALL: [Self; 5] = [
Self::Audio,
Self::Gameplay,
Self::Appearance,
Self::Accessibility,
Self::Account,
];
/// Chip label.
pub(super) fn label(self) -> &'static str {
match self {
Self::Audio => "Audio",
Self::Gameplay => "Gameplay",
Self::Appearance => "Appearance",
Self::Accessibility => "Access",
Self::Account => "Account",
}
}
}
/// The active Settings tab. Session-only — deliberately not persisted;
/// reopening Settings within a session returns to the last tab.
#[derive(Resource, Debug, Default)]
pub(super) struct ActiveSettingsTab(pub(super) SettingsTab);
/// Marker on each tab chip button in the Settings header row.
#[derive(Component, Debug)]
pub(super) struct SettingsTabButton(pub(super) SettingsTab);
/// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)]
struct SettingsPanelScrollable;
@@ -369,6 +414,7 @@ impl Plugin for SettingsPlugin {
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
.init_resource::<SettingsScreen>()
.init_resource::<SettingsScrollPos>()
.init_resource::<ActiveSettingsTab>()
.init_resource::<PendingWindowGeometry>()
.add_message::<SettingsChangedEvent>()
.add_message::<ManualSyncRequestEvent>()
@@ -423,7 +469,15 @@ impl Plugin for SettingsPlugin {
app.add_systems(
Update,
(
sync_settings_panel_visibility,
// Chained: a tab click must observe this frame's
// visibility state, and the rebuild must observe the
// click — total order avoids panel double-spawns.
(
sync_settings_panel_visibility,
handle_tab_buttons,
rebuild_panel_on_tab_change,
)
.chain(),
handle_settings_buttons,
handle_sync_buttons,
#[cfg(not(target_arch = "wasm32"))]
+128 -70
View File
@@ -98,6 +98,25 @@ fn volume_clamped_at_zero_does_not_emit_event() {
);
}
/// Opens the Settings panel showing `tab`, then waits out the
/// spawn / child-flush / marker-attach frames.
fn open_settings_on(app: &mut App, tab: SettingsTab) {
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
}
/// Switches the already-open panel to `tab` (drives the
/// `rebuild_panel_on_tab_change` path) and waits out the rebuild frames.
fn switch_settings_tab(app: &mut App, tab: SettingsTab) {
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
app.update();
app.update();
app.update();
}
#[test]
fn pressing_o_toggles_settings_screen_flag() {
let mut app = headless_app();
@@ -243,40 +262,40 @@ fn headless_app_with_focus() -> App {
fn settings_buttons_get_focusable_marker() {
let mut app = headless_app_with_focus();
// Open the panel.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
// Two more ticks: the first runs `sync_settings_panel_visibility`
// and queues the spawn commands; the second flushes them and
// runs `attach_focusable_to_settings_buttons`.
app.update();
app.update();
// Walk every tab: each rebuild must leave no bespoke
// `SettingsButton` (not `Done`, which is also a `ModalButton`)
// without a `Focusable`.
let mut total_tagged = 0usize;
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
if i == 0 {
open_settings_on(&mut app, tab);
} else {
switch_settings_tab(&mut app, tab);
}
// Every bespoke `SettingsButton` (not `Done`, which is also a
// `ModalButton`) must carry a `Focusable`.
let untagged: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
.iter(app.world())
.collect();
let untagged: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
.iter(app.world())
.collect();
assert!(
untagged.is_empty(),
"every bespoke Settings button on tab {tab:?} must carry Focusable; missing: {untagged:?}"
);
total_tagged += app
.world_mut()
.query_filtered::<&SettingsButton, With<Focusable>>()
.iter(app.world())
.count();
}
// Floor across all five tabs so the assertion above isn't
// vacuously true (audio 4 + gameplay 8 + appearance ≥5 +
// accessibility 6 + account ≥1).
assert!(
untagged.is_empty(),
"every bespoke Settings button must carry Focusable; missing: {:?}",
untagged
);
// And there must be at least one tagged `SettingsButton` so the
// assertion above isn't vacuously true (the panel really did
// spawn).
let tagged_count = app
.world_mut()
.query_filtered::<&SettingsButton, With<Focusable>>()
.iter(app.world())
.count();
assert!(
tagged_count >= 6,
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
total_tagged >= 20,
"expected the five tabs to spawn many bespoke buttons; got {total_tagged}"
);
}
@@ -290,41 +309,45 @@ fn settings_buttons_get_focusable_marker() {
fn settings_buttons_carry_tooltip() {
let mut app = headless_app_with_focus();
// Open the panel and let spawn + child-flush run.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// Walk every tab: no bespoke `SettingsButton` (i.e. excluding
// `Done`, which is also a `ModalButton`) may be missing a
// `Tooltip` on any of them.
let mut total_tipped = 0usize;
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
if i == 0 {
open_settings_on(&mut app, tab);
} else {
switch_settings_tab(&mut app, tab);
}
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
// also a `ModalButton`) may be missing a `Tooltip`.
let untipped: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
.iter(app.world())
.collect();
let untipped: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
.iter(app.world())
.collect();
assert!(
untipped.is_empty(),
"every bespoke Settings button on tab {tab:?} must carry Tooltip; missing: {untipped:?}"
);
total_tipped += app
.world_mut()
.query_filtered::<&SettingsButton, With<Tooltip>>()
.iter(app.world())
.count();
}
// Floor across all five tabs so the assertion above isn't
// vacuously true.
assert!(
untipped.is_empty(),
"every bespoke Settings button must carry Tooltip; missing: {:?}",
untipped
total_tipped >= 20,
"expected the five tabs to spawn many tooltipped buttons; got {total_tipped}"
);
// And there must be at least 6 tipped buttons so the assertion
// above isn't vacuously true: SFX +/, Music +/, Draw Mode,
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
// card-back and one background swatch — well over the floor.
let tipped_count = app
.world_mut()
.query_filtered::<&SettingsButton, With<Tooltip>>()
.iter(app.world())
.count();
assert!(
tipped_count >= 6,
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
);
// The loop ends on the Account tab, where with default (Local)
// settings the Connect button spawns.
// Spot-check: with default (Local) settings the Connect button
// spawns. We verify its tooltip carries the canonical microcopy.
// We verify its tooltip carries the canonical microcopy.
let connect_tip = app
.world_mut()
.query::<(&SettingsButton, &Tooltip)>()
@@ -342,10 +365,8 @@ fn settings_buttons_carry_tooltip() {
fn settings_picker_rows_get_focus_row_marker() {
let mut app = headless_app_with_focus();
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// Picker rows live on the Appearance tab.
open_settings_on(&mut app, SettingsTab::Appearance);
// Two picker rows are spawned (card-back + background); each
// must carry the FocusRow marker.
@@ -360,6 +381,45 @@ fn settings_picker_rows_get_focus_row_marker() {
);
}
/// Switching tabs despawns the old rows and spawns the new tab's —
/// exactly one panel exists afterwards (no double-spawn from the
/// visibility system racing the rebuild system).
#[test]
fn switching_tab_rebuilds_panel_with_new_rows() {
let mut app = headless_app_with_focus();
fn has_button(app: &mut App, wanted: fn(&SettingsButton) -> bool) -> bool {
app.world_mut()
.query::<&SettingsButton>()
.iter(app.world())
.any(wanted)
}
open_settings_on(&mut app, SettingsTab::Audio);
assert!(has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)));
assert!(
!has_button(&mut app, |b| matches!(b, SettingsButton::ToggleDrawMode)),
"Gameplay rows must not spawn while the Audio tab is active"
);
switch_settings_tab(&mut app, SettingsTab::Gameplay);
assert!(
!has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)),
"Audio rows must despawn when leaving the Audio tab"
);
assert!(has_button(&mut app, |b| matches!(
b,
SettingsButton::ToggleDrawMode
)));
let panel_count = app
.world_mut()
.query_filtered::<Entity, With<SettingsPanel>>()
.iter(app.world())
.count();
assert_eq!(panel_count, 1, "tab switch must not stack panels");
}
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
/// at least one theme and the [`ThemeThumbnailCache`] holds a
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
@@ -404,11 +464,9 @@ fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
);
app.insert_resource(cache);
// Open the panel and let the spawn + child-flush systems run.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// Open the panel on the Appearance tab (the theme picker's home)
// and let the spawn + child-flush systems run.
open_settings_on(&mut app, SettingsTab::Appearance);
// Find every ImageNode tagged with ThemeThumbnailMarker — the
// theme picker chip for "test_theme" must contribute exactly
+292 -194
View File
@@ -45,13 +45,34 @@ pub(super) fn spawn_settings_panel(
scroll_offset: f32,
font_res: Option<&FontResource>,
theme_overrides_back: bool,
active_tab: SettingsTab,
) {
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Settings", font_res);
// Scrollable bodycontains every section so tall content stays
// reachable on short windows. The Done button below stays fixed
// outside the scroll so it's always one click away.
// Tab chips — one group of rows on screen at a time, no
// scroll-hunting (Phase A, docs/ui-redesign-2026-07.md).
card.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_2,
row_gap: VAL_SPACE_2,
margin: UiRect::bottom(VAL_SPACE_3),
..default()
},
))
.with_children(|row| {
for tab in SettingsTab::ALL {
tab_chip(row, tab, tab == active_tab, font_res);
}
});
// Scrollable body — most tabs fit without scrolling, but the
// Appearance pickers can still overflow short windows. The Done
// button below stays fixed outside the scroll so it's always one
// click away.
card.spawn((
SettingsPanelScrollable,
SettingsScrollNode,
@@ -65,200 +86,20 @@ pub(super) fn spawn_settings_panel(
..default()
},
))
.with_children(|body| {
// --- Audio ---
section_label(body, "Audio", font_res);
volume_row(
.with_children(|body| match active_tab {
SettingsTab::Audio => spawn_audio_tab(body, settings, font_res),
SettingsTab::Gameplay => spawn_gameplay_tab(body, settings, font_res),
SettingsTab::Appearance => spawn_appearance_tab(
body,
"SFX Volume",
settings.sfx_volume,
SfxVolumeText,
SettingsButton::SfxDown,
SettingsButton::SfxUp,
"Lower sound effects volume.",
"Raise sound effects volume.",
font_res,
);
volume_row(
body,
"Music Volume",
settings.music_volume,
MusicVolumeText,
SettingsButton::MusicDown,
SettingsButton::MusicUp,
"Lower music and ambience volume.",
"Raise music and ambience volume.",
font_res,
);
// --- Gameplay ---
section_label(body, "Gameplay", font_res);
toggle_row(
body,
"Draw Mode",
DrawModeText,
draw_mode_label(&settings.draw_mode),
SettingsButton::ToggleDrawMode,
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
font_res,
);
toggle_row(
body,
"Winnable deals only",
WinnableDealsOnlyText,
winnable_deals_only_label(settings.winnable_deals_only),
SettingsButton::ToggleWinnableDealsOnly,
"When on, fresh Classic deals are filtered through a solver \
(may take a moment when on).",
font_res,
);
toggle_row(
body,
"Anim Speed",
AnimSpeedText,
anim_speed_label(&settings.animation_speed),
SettingsButton::CycleAnimSpeed,
"Cycle animation speed: Normal, Fast, Instant.",
font_res,
);
tooltip_delay_row(
body,
settings.tooltip_delay_secs,
font_res,
);
time_bonus_multiplier_row(
body,
settings.time_bonus_multiplier,
font_res,
);
replay_move_interval_row(
body,
settings.replay_move_interval_secs,
font_res,
);
toggle_row(
body,
"Smart window size",
SmartDefaultSizeText,
smart_default_size_label(!settings.disable_smart_default_size),
SettingsButton::ToggleSmartDefaultSize,
"When ON, fresh launches resize the window to ~70 % of the \
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
window size always wins.",
font_res,
);
// --- Cosmetic ---
section_label(body, "Cosmetic", font_res);
toggle_row(
body,
"Theme",
ThemeText,
theme_label(&settings.theme),
SettingsButton::ToggleTheme,
"Cycle felt color: Green, Blue, Dark.",
font_res,
);
toggle_row(
body,
"Color-blind Mode",
ColorBlindText,
color_blind_label(settings.color_blind_mode),
SettingsButton::ToggleColorBlind,
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
font_res,
);
toggle_row(
body,
"High Contrast",
HighContrastText,
on_off_label(settings.high_contrast_mode),
SettingsButton::ToggleHighContrast,
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
font_res,
);
toggle_row(
body,
"Reduce Motion",
ReduceMotionText,
on_off_label(settings.reduce_motion_mode),
SettingsButton::ToggleReduceMotion,
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res,
);
toggle_row(
body,
"Touch Input Mode",
TouchInputModeText,
touch_input_mode_label(&settings.touch_input_mode),
SettingsButton::ToggleTouchInputMode,
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
font_res,
);
if theme_overrides_back {
// The active theme provides its own back; the legacy
// picker has no visible effect, so we replace its
// swatch row with an informational caption. The
// player's `selected_card_back` value still
// round-trips through `settings.json` — the moment
// they switch to a theme without a back, the picker
// re-appears with their previous choice intact.
picker_row_overridden_by_theme(body, "Card Back", font_res);
} else {
picker_row(
body,
"Card Back",
unlocked_card_backs,
settings.selected_card_back,
SettingsButton::SelectCardBack,
"Choose your deck art. New backs unlock at higher levels.",
font_res,
);
}
picker_row(
body,
"Background",
settings,
unlocked_card_backs,
unlocked_backgrounds,
settings.selected_background,
SettingsButton::SelectBackground,
"Choose your felt art. New felts unlock at higher levels.",
themes,
theme_overrides_back,
font_res,
);
// Card-art theme picker — only renders when the registry has
// entries (production: always; tests: only when
// ThemeRegistryPlugin is installed).
if !themes.is_empty() {
theme_picker_row(
body,
"Card Theme",
themes,
&settings.selected_theme_id,
"Choose card-face artwork. Imported themes appear here.",
font_res,
);
}
#[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res);
#[cfg(not(target_arch = "wasm32"))]
theme_store_row(body, font_res);
// --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() {
section_label(body, "Privacy", font_res);
toggle_row(
body,
"Share usage data",
AnalyticsEnabledText,
on_off_label(settings.analytics_enabled),
SettingsButton::ToggleAnalytics,
"Sends anonymous game events to Matomo for aggregate analytics.",
font_res,
);
}
// --- Sync ---
section_label(body, "Sync", font_res);
sync_row(body, sync_status, &settings.sync_backend, font_res);
),
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res),
});
// Done is the only action — primary so the player always knows
@@ -276,6 +117,263 @@ pub(super) fn spawn_settings_panel(
});
}
/// One Settings tab chip. The active chip is filled + bright; inactive
/// chips are quiet outlines.
fn tab_chip(
parent: &mut ChildSpawnerCommands,
tab: SettingsTab,
active: bool,
font_res: Option<&FontResource>,
) {
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
parent
.spawn((
SettingsTabButton(tab),
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }),
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
))
.with_children(|b| {
b.spawn((
Text::new(tab.label()),
font,
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
));
});
}
/// Audio tab: the two volume rows.
fn spawn_audio_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
volume_row(
body,
"SFX Volume",
settings.sfx_volume,
SfxVolumeText,
SettingsButton::SfxDown,
SettingsButton::SfxUp,
"Lower sound effects volume.",
"Raise sound effects volume.",
font_res,
);
volume_row(
body,
"Music Volume",
settings.music_volume,
MusicVolumeText,
SettingsButton::MusicDown,
SettingsButton::MusicUp,
"Lower music and ambience volume.",
"Raise music and ambience volume.",
font_res,
);
}
/// Gameplay tab: rules and pacing.
fn spawn_gameplay_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
toggle_row(
body,
"Draw Mode",
DrawModeText,
draw_mode_label(&settings.draw_mode),
SettingsButton::ToggleDrawMode,
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
font_res,
);
toggle_row(
body,
"Winnable deals only",
WinnableDealsOnlyText,
winnable_deals_only_label(settings.winnable_deals_only),
SettingsButton::ToggleWinnableDealsOnly,
"When on, fresh Classic deals are filtered through a solver \
(may take a moment when on).",
font_res,
);
toggle_row(
body,
"Anim Speed",
AnimSpeedText,
anim_speed_label(&settings.animation_speed),
SettingsButton::CycleAnimSpeed,
"Cycle animation speed: Normal, Fast, Instant.",
font_res,
);
time_bonus_multiplier_row(body, settings.time_bonus_multiplier, font_res);
replay_move_interval_row(body, settings.replay_move_interval_secs, font_res);
toggle_row(
body,
"Smart window size",
SmartDefaultSizeText,
smart_default_size_label(!settings.disable_smart_default_size),
SettingsButton::ToggleSmartDefaultSize,
"When ON, fresh launches resize the window to ~70 % of the \
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
window size always wins.",
font_res,
);
}
/// Appearance tab: felt, deck art, backgrounds, and the theme system.
#[allow(clippy::too_many_arguments)]
fn spawn_appearance_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
unlocked_card_backs: &[usize],
unlocked_backgrounds: &[usize],
themes: &[ThemePickerEntry],
theme_overrides_back: bool,
font_res: Option<&FontResource>,
) {
toggle_row(
body,
"Theme",
ThemeText,
theme_label(&settings.theme),
SettingsButton::ToggleTheme,
"Cycle felt color: Green, Blue, Dark.",
font_res,
);
if theme_overrides_back {
// The active theme provides its own back; the legacy
// picker has no visible effect, so we replace its
// swatch row with an informational caption. The
// player's `selected_card_back` value still
// round-trips through `settings.json` — the moment
// they switch to a theme without a back, the picker
// re-appears with their previous choice intact.
picker_row_overridden_by_theme(body, "Card Back", font_res);
} else {
picker_row(
body,
"Card Back",
unlocked_card_backs,
settings.selected_card_back,
SettingsButton::SelectCardBack,
"Choose your deck art. New backs unlock at higher levels.",
font_res,
);
}
picker_row(
body,
"Background",
unlocked_backgrounds,
settings.selected_background,
SettingsButton::SelectBackground,
"Choose your felt art. New felts unlock at higher levels.",
font_res,
);
// Card-art theme picker — only renders when the registry has
// entries (production: always; tests: only when
// ThemeRegistryPlugin is installed).
if !themes.is_empty() {
theme_picker_row(
body,
"Card Theme",
themes,
&settings.selected_theme_id,
"Choose card-face artwork. Imported themes appear here.",
font_res,
);
}
#[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res);
#[cfg(not(target_arch = "wasm32"))]
theme_store_row(body, font_res);
}
/// Accessibility tab: perception and input assists, gathered from the
/// old Gameplay/Cosmetic sections so they're findable in one place.
fn spawn_accessibility_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
toggle_row(
body,
"Color-blind Mode",
ColorBlindText,
color_blind_label(settings.color_blind_mode),
SettingsButton::ToggleColorBlind,
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
font_res,
);
toggle_row(
body,
"High Contrast",
HighContrastText,
on_off_label(settings.high_contrast_mode),
SettingsButton::ToggleHighContrast,
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
font_res,
);
toggle_row(
body,
"Reduce Motion",
ReduceMotionText,
on_off_label(settings.reduce_motion_mode),
SettingsButton::ToggleReduceMotion,
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res,
);
toggle_row(
body,
"Touch Input Mode",
TouchInputModeText,
touch_input_mode_label(&settings.touch_input_mode),
SettingsButton::ToggleTouchInputMode,
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
font_res,
);
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
}
/// Account tab: privacy opt-in (when analytics is configured) and the
/// sync backend controls.
fn spawn_account_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
sync_status: &str,
font_res: Option<&FontResource>,
) {
// --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() {
section_label(body, "Privacy", font_res);
toggle_row(
body,
"Share usage data",
AnalyticsEnabledText,
on_off_label(settings.analytics_enabled),
SettingsButton::ToggleAnalytics,
"Sends anonymous game events to Matomo for aggregate analytics.",
font_res,
);
}
// --- Sync ---
section_label(body, "Sync", font_res);
sync_row(body, sync_status, &settings.sync_backend, font_res);
}
/// Section divider — small lavender label inside the scrollable body.
pub(super) fn section_label(
parent: &mut ChildSpawnerCommands,
+119 -51
View File
@@ -17,6 +17,7 @@ use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBord
#[allow(clippy::too_many_arguments)]
pub(super) fn sync_settings_panel_visibility(
screen: Res<SettingsScreen>,
active_tab: Res<ActiveSettingsTab>,
panels: Query<Entity, With<SettingsPanel>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
@@ -35,60 +36,17 @@ pub(super) fn sync_settings_panel_visibility(
}
if screen.0 {
if panels.is_empty() && other_modal_scrims.is_empty() {
let status_label = sync_status.map_or_else(
|| "Status: local only".to_string(),
|s| sync_status_label(&s.0),
);
let unlocked_backs = progress
.as_ref()
.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
let unlocked_bgs = progress
.as_ref()
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
// Snapshot themes by id, display_name and (optional)
// thumbnail pair so spawn_settings_panel doesn't have to
// know about the registry / cache shapes. Empty when
// ThemeRegistryPlugin isn't installed (tests under
// MinimalPlugins) — the picker row simply won't render.
// Missing thumbnails (cache not ready, or partial user
// theme) leave `thumbnails: None` so the chip renders its
// plain-text fallback instead of a broken sprite.
let themes: Vec<ThemePickerEntry> = theme_registry
.as_deref()
.map(|r| {
r.iter()
.map(|e| ThemePickerEntry {
id: e.id.clone(),
display_name: e.display_name.clone(),
thumbnails: theme_thumbs
.as_deref()
.and_then(|c| c.get(&e.id))
.filter(|p| p.is_fully_populated())
.cloned(),
})
.collect()
})
.unwrap_or_default();
// The active card-art theme can supply its own back image —
// see `card_plugin::CardImageSet::theme_back`. When that is
// populated the legacy "Card Back" picker has no visible
// effect, so we render it muted with an explanatory caption
// rather than letting the player click swatches that do
// nothing. Absent under `MinimalPlugins`; treated as
// "no override" in that case.
let theme_overrides_back = card_images
.as_ref()
.is_some_and(|cs| cs.theme_back.is_some());
spawn_settings_panel(
build_panel(
&mut commands,
&settings.0,
&status_label,
unlocked_backs,
unlocked_bgs,
&themes,
scroll_pos.0,
sync_status.as_deref(),
progress.as_deref(),
font_res.as_deref(),
theme_overrides_back,
theme_registry.as_deref(),
theme_thumbs.as_deref(),
card_images.as_deref(),
scroll_pos.0,
active_tab.0,
);
}
} else {
@@ -102,6 +60,116 @@ pub(super) fn sync_settings_panel_visibility(
}
}
/// Gathers the spawn context (sync label, unlocks, theme snapshot,
/// back-override flag) and spawns the panel showing `active_tab`.
/// Shared by [`sync_settings_panel_visibility`] (open) and
/// [`rebuild_panel_on_tab_change`] (tab switch).
#[allow(clippy::too_many_arguments)]
fn build_panel(
commands: &mut Commands,
settings: &Settings,
sync_status: Option<&SyncStatusResource>,
progress: Option<&ProgressResource>,
font_res: Option<&FontResource>,
theme_registry: Option<&crate::theme::ThemeRegistry>,
theme_thumbs: Option<&ThemeThumbnailCache>,
card_images: Option<&crate::card_plugin::CardImageSet>,
scroll_offset: f32,
active_tab: SettingsTab,
) {
let status_label = sync_status.map_or_else(
|| "Status: local only".to_string(),
|s| sync_status_label(&s.0),
);
let unlocked_backs = progress.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
let unlocked_bgs = progress.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
// Snapshot themes by id, display_name and (optional)
// thumbnail pair so spawn_settings_panel doesn't have to
// know about the registry / cache shapes. Empty when
// ThemeRegistryPlugin isn't installed (tests under
// MinimalPlugins) — the picker row simply won't render.
// Missing thumbnails (cache not ready, or partial user
// theme) leave `thumbnails: None` so the chip renders its
// plain-text fallback instead of a broken sprite.
let themes: Vec<ThemePickerEntry> = theme_registry
.map(|r| {
r.iter()
.map(|e| ThemePickerEntry {
id: e.id.clone(),
display_name: e.display_name.clone(),
thumbnails: theme_thumbs
.and_then(|c| c.get(&e.id))
.filter(|p| p.is_fully_populated())
.cloned(),
})
.collect()
})
.unwrap_or_default();
// The active card-art theme can supply its own back image —
// see `card_plugin::CardImageSet::theme_back`. When that is
// populated the legacy "Card Back" picker has no visible
// effect, so we render it muted with an explanatory caption
// rather than letting the player click swatches that do
// nothing. Absent under `MinimalPlugins`; treated as
// "no override" in that case.
let theme_overrides_back = card_images.is_some_and(|cs| cs.theme_back.is_some());
spawn_settings_panel(
commands,
settings,
&status_label,
unlocked_backs,
unlocked_bgs,
&themes,
scroll_offset,
font_res,
theme_overrides_back,
active_tab,
);
}
/// Rebuilds the open panel when [`ActiveSettingsTab`] changes (tab chip
/// clicked). No-op while the panel is closed — the next open reads the
/// resource directly.
#[allow(clippy::too_many_arguments)]
pub(super) fn rebuild_panel_on_tab_change(
active_tab: Res<ActiveSettingsTab>,
panels: Query<Entity, With<SettingsPanel>>,
mut scroll_pos: ResMut<SettingsScrollPos>,
mut commands: Commands,
settings: Res<SettingsResource>,
sync_status: Option<Res<SyncStatusResource>>,
progress: Option<Res<ProgressResource>>,
font_res: Option<Res<FontResource>>,
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
) {
if !active_tab.is_changed() || active_tab.is_added() {
return;
}
if panels.is_empty() {
return;
}
for entity in &panels {
commands.entity(entity).despawn();
}
// A fresh tab starts at the top; per-tab scroll memory isn't worth
// the bookkeeping now that no tab needs much scrolling.
scroll_pos.0 = 0.0;
build_panel(
&mut commands,
&settings.0,
sync_status.as_deref(),
progress.as_deref(),
font_res.as_deref(),
theme_registry.as_deref(),
theme_thumbs.as_deref(),
card_images.as_deref(),
0.0,
active_tab.0,
);
}
/// Keeps the sync-status text node current while the panel is open.
pub(super) fn update_sync_status_text(
sync_status: Option<Res<SyncStatusResource>>,
@@ -0,0 +1,414 @@
//! "Show solution" — solve the current deal off-thread and auto-play
//! the winning line through the normal move pipeline.
//!
//! The pause menu's "Show solution" button fires
//! [`crate::events::ShowSolutionRequestEvent`]. This plugin snapshots
//! the live [`GameState`], runs [`GameState::winning_line`] on
//! [`AsyncComputeTaskPool`] (the solver plus `clean_solution` can take
//! seconds — §2.4 never block the main thread), then steps the returned
//! instructions on a cadence, one `MoveRequestEvent` /
//! `DrawRequestEvent` per tick — the same events player input produces,
//! so animations, scoring, undo history, and win detection all behave
//! exactly as if the player made the moves.
//!
//! Playback cancels on Esc, on pause, on undo / new-game requests, and
//! on any rejected move (which is what a player interfering mid-line
//! produces — their move diverges the state, the next scripted step
//! becomes illegal, and the rejection stops the run cleanly).
use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::GameState;
use solitaire_core::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
};
use crate::events::{
DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
ShowSolutionRequestEvent, UndoRequestEvent,
};
use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
/// Seconds between scripted moves — slow enough to follow, fast enough
/// not to drag on a 100-move line.
const STEP_INTERVAL_SECS: f32 = 0.45;
/// Initial delay before the first scripted move, giving the pause modal
/// time to close and the player a beat to see what's happening.
const FIRST_STEP_DELAY_SECS: f32 = 0.9;
/// In-flight solver task plus the `move_count` snapshot used to detect
/// a stale result (player moved while the solver ran). Mirrors
/// `PendingHintTask`.
#[derive(Resource, Default)]
pub struct SolutionSolveTask {
inner: Option<(u32, Task<SolveTaskOutput>)>,
}
/// What the solver task carries back to the main thread.
enum SolveTaskOutput {
Line(Vec<KlondikeInstruction>),
Unwinnable,
Inconclusive,
}
/// Queue of instructions currently being auto-played, or empty when no
/// playback is active. HUD/UI may read `is_active` to badge the state.
#[derive(Resource, Default)]
pub struct SolutionPlayback {
queue: VecDeque<KlondikeInstruction>,
cooldown: f32,
}
impl SolutionPlayback {
/// `true` while a solution line is being auto-played.
pub fn is_active(&self) -> bool {
!self.queue.is_empty()
}
fn stop(&mut self) {
self.queue.clear();
}
}
/// Bevy plugin for the Show-solution flow. See the module docs.
pub struct SolutionPlaybackPlugin;
impl Plugin for SolutionPlaybackPlugin {
fn build(&self, app: &mut App) {
// add_message is idempotent — GamePlugin registers most of these
// too, but this plugin must also boot standalone under
// MinimalPlugins in tests.
app.init_resource::<SolutionSolveTask>()
.init_resource::<SolutionPlayback>()
.add_message::<ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<NewGameRequestEvent>()
.add_systems(
Update,
(
handle_show_solution_request,
poll_solution_task,
cancel_playback_on_interrupt,
drive_solution_playback,
)
.chain()
.before(GameMutation),
);
}
}
/// Starts a solver task from the live game state. A repeat request
/// while one is already in flight (or playback is running) is ignored
/// — the button is idempotent, not a queue.
fn handle_show_solution_request(
mut requests: MessageReader<ShowSolutionRequestEvent>,
game: Option<Res<GameStateResource>>,
mut task: ResMut<SolutionSolveTask>,
playback: Res<SolutionPlayback>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if requests.is_empty() {
return;
}
requests.clear();
if task.inner.is_some() || playback.is_active() {
return;
}
let Some(game) = game else { return };
if game.0.is_won() {
return;
}
toast.write(InfoToastEvent("Searching for a solution…".to_string()));
let snapshot: GameState = game.0.clone();
let move_count = snapshot.move_count();
let handle = AsyncComputeTaskPool::get().spawn(async move {
match snapshot.winning_line(DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET) {
Ok(Some(line)) => SolveTaskOutput::Line(line),
Ok(None) => SolveTaskOutput::Unwinnable,
Err(_) => SolveTaskOutput::Inconclusive,
}
});
task.inner = Some((move_count, handle));
}
/// Polls the solver; on completion either starts playback or explains
/// why there is nothing to play. A result computed for a position the
/// player has since moved past is discarded silently.
fn poll_solution_task(
mut task: ResMut<SolutionSolveTask>,
game: Option<Res<GameStateResource>>,
mut playback: ResMut<SolutionPlayback>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some((move_count_at_spawn, handle)) = task.inner.as_mut() else {
return;
};
let Some(output) = future::block_on(future::poll_once(handle)) else {
return;
};
let move_count_at_spawn = *move_count_at_spawn;
task.inner = None;
let Some(game) = game else { return };
if game.0.move_count() != move_count_at_spawn {
return; // Stale — the board moved while we were solving.
}
match output {
SolveTaskOutput::Line(line) if line.is_empty() => {}
SolveTaskOutput::Line(line) => {
toast.write(InfoToastEvent(format!(
"Solution found — playing {} moves. Press Esc to stop.",
line.len()
)));
playback.queue = line.into();
playback.cooldown = FIRST_STEP_DELAY_SECS;
}
SolveTaskOutput::Unwinnable => {
toast.write(InfoToastEvent(
"No winning line exists from this position.".to_string(),
));
}
SolveTaskOutput::Inconclusive => {
toast.write(InfoToastEvent(
"Couldn't find a solution within the search budget.".to_string(),
));
}
}
}
/// Stops playback on Esc, pause, undo / new-game requests, or a
/// rejected move (the signature of the player diverging the board
/// mid-line). Runs before `drive_solution_playback` so a cancel takes
/// effect without one extra scripted move slipping out.
fn cancel_playback_on_interrupt(
mut playback: ResMut<SolutionPlayback>,
keys: Option<Res<ButtonInput<KeyCode>>>,
paused: Option<Res<PausedResource>>,
mut rejected: MessageReader<MoveRejectedEvent>,
mut undos: MessageReader<UndoRequestEvent>,
mut new_games: MessageReader<NewGameRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if !playback.is_active() {
rejected.clear();
undos.clear();
new_games.clear();
return;
}
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape));
let interrupted = esc
|| paused.is_some_and(|p| p.0)
|| rejected.read().next().is_some()
|| undos.read().next().is_some()
|| new_games.read().next().is_some();
if interrupted {
playback.stop();
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
}
}
/// Emits the next scripted instruction every [`STEP_INTERVAL_SECS`]
/// while playback is active, translated to the same request events
/// player input produces. Instructions that no longer decode against
/// the live state stop the run instead of guessing.
fn drive_solution_playback(
mut playback: ResMut<SolutionPlayback>,
game: Option<Res<GameStateResource>>,
time: Res<Time>,
mut moves: MessageWriter<MoveRequestEvent>,
mut draws: MessageWriter<DrawRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if !playback.is_active() {
return;
}
let Some(game) = game else {
playback.stop();
return;
};
if game.0.is_won() {
playback.stop();
return;
}
playback.cooldown -= time.delta_secs();
if playback.cooldown > 0.0 {
return;
}
playback.cooldown = STEP_INTERVAL_SECS;
let Some(instruction) = playback.queue.pop_front() else {
return;
};
match instruction {
KlondikeInstruction::RotateStock => {
draws.write(DrawRequestEvent);
}
other => {
// Decode against the LIVE state — tableau run lengths depend on
// the current face-up counts, so this must happen at step time,
// not at solve time.
let Some((from, to, count)) = game.0.instruction_to_piles(other) else {
playback.stop();
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
return;
};
moves.write(MoveRequestEvent { from, to, count });
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use bevy::ecs::message::Messages;
use solitaire_core::DrawStockConfig;
/// Seed proven Winnable at 5k budgets by the core
/// `budget_is_passed_through_not_clamped` test. Solved here under
/// standard rules (no take-from-foundation) to match that baseline.
const WINNABLE_SEED: u64 = 0xD1FF_0000_0000_0012;
fn winnable_state() -> GameState {
let mut game = GameState::new(WINNABLE_SEED, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
game
}
/// Full pipeline: GamePlugin consumes the Move/Draw requests the
/// playback driver emits, exactly as in production.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(SolutionPlaybackPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn request_solution(app: &mut App) {
app.world_mut()
.resource_mut::<Messages<ShowSolutionRequestEvent>>()
.write(ShowSolutionRequestEvent);
}
/// Pump updates until the solver task resolves (wall-clock bounded,
/// mirroring `winnable_solver_emits_hint_after_async_completes`).
fn pump_until_solved(app: &mut App) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while app.world().resource::<SolutionSolveTask>().inner.is_some() {
app.update();
std::thread::yield_now();
if std::time::Instant::now() >= deadline {
break;
}
}
assert!(
app.world().resource::<SolutionSolveTask>().inner.is_none(),
"solver task should have completed within 30 s wall-clock",
);
}
#[test]
fn request_solves_and_arms_playback() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
request_solution(&mut app);
app.update();
assert!(
app.world().resource::<SolutionSolveTask>().inner.is_some(),
"request must spawn a solver task",
);
pump_until_solved(&mut app);
assert!(
app.world().resource::<SolutionPlayback>().is_active(),
"a winnable position must arm playback",
);
}
#[test]
fn playback_reaches_win_through_normal_pipeline() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
request_solution(&mut app);
app.update();
pump_until_solved(&mut app);
assert!(app.world().resource::<SolutionPlayback>().is_active());
// Force each step instead of waiting out the real cadence; a line
// is at most a few hundred instructions.
for _ in 0..600 {
app.world_mut().resource_mut::<SolutionPlayback>().cooldown = 0.0;
app.update();
if app.world().resource::<GameStateResource>().0.is_won() {
break;
}
}
assert!(
app.world().resource::<GameStateResource>().0.is_won(),
"auto-played line must drive the real game to a win",
);
assert!(
!app.world().resource::<SolutionPlayback>().is_active(),
"playback must deactivate once the game is won",
);
}
#[test]
fn escape_cancels_playback() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
app.world_mut()
.resource_mut::<SolutionPlayback>()
.queue
.push_back(KlondikeInstruction::RotateStock);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SolutionPlayback>().is_active(),
"Esc must stop playback",
);
}
#[test]
fn repeat_request_is_ignored_while_active() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
app.world_mut()
.resource_mut::<SolutionPlayback>()
.queue
.push_back(KlondikeInstruction::RotateStock);
request_solution(&mut app);
app.update();
assert!(
app.world().resource::<SolutionSolveTask>().inner.is_none(),
"a request during active playback must not spawn a solver task",
);
}
}
+12 -12
View File
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
return ret;
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61918, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 62000, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7364, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7446, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
return ret;
},
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
return ret;
},
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
return ret;
},
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7362, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7444, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
return ret;
},
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7363, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7445, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
return ret;
},
Binary file not shown.
Binary file not shown.