Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 374858ab6d | |||
| e1d91bee73 | |||
| 9dcd25b3e7 | |||
| 2ef0bce1ea | |||
| 326ef6894a | |||
| caaafe34e0 | |||
| 2254693a7a | |||
| 4ba646738c | |||
| 251d35bc28 | |||
| fe3c3aed31 | |||
| d0e4ce796b | |||
| 50d6d41d85 | |||
| 9e4d4a6716 | |||
| ea1014285d | |||
| 4f849a23b8 | |||
| 2c2f0b592a | |||
| 299f6bfea7 | |||
| 8c0eb3dfab |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 | 1–2 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 | 1–2 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 | Low–Medium | 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?
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod error;
|
||||
pub mod game_state;
|
||||
pub mod klondike_adapter;
|
||||
pub mod scoring;
|
||||
pub mod spider;
|
||||
|
||||
// Re-export the upstream types that cross the solitaire_core API boundary so
|
||||
// downstream crates (engine, wasm) can import from one place without a direct
|
||||
@@ -21,6 +22,13 @@ pub use klondike::{
|
||||
// former `solitaire_data::solver` wrapper module.
|
||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||
|
||||
// Spider rules (second `card_game::Game` implementation; engine UI is a
|
||||
// later phase — nothing outside solitaire_core consumes these yet).
|
||||
pub use spider::{
|
||||
SpiderConfig, SpiderGame, SpiderGameState, SpiderInstruction, SpiderScoring, SpiderStats,
|
||||
SpiderSuits,
|
||||
};
|
||||
|
||||
/// All four foundation slots, in slot order.
|
||||
///
|
||||
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
|
||||
|
||||
@@ -0,0 +1,952 @@
|
||||
//! Spider solitaire rules — a second [`card_game::Game`] implementation
|
||||
//! alongside the upstream Klondike.
|
||||
//!
|
||||
//! Built directly on the upstream `card_game` containers: two decks
|
||||
//! ([`Deck::Deck1`] + [`Deck::Deck2`], 104 cards) dealt into ten
|
||||
//! [`Pile`]s, exercising the multi-deck [`Card`] encoding and the
|
||||
//! `Stack`/`Pile` public API that `klondike` uses internally.
|
||||
//!
|
||||
//! ## Rules implemented
|
||||
//!
|
||||
//! - 10 tableau piles: the first 4 receive 6 cards, the last 6 receive
|
||||
//! 5 (top card face-up) — 54 dealt, 50 in stock.
|
||||
//! - Build down regardless of suit; only same-suit descending runs may
|
||||
//! be picked up and moved.
|
||||
//! - Empty piles accept any card or movable run.
|
||||
//! - The stock deals one card to every pile (10 total), only while no
|
||||
//! pile is empty.
|
||||
//! - A completed K→A same-suit run is removed automatically; the game
|
||||
//! is won when all 8 runs are removed.
|
||||
//! - Difficulty via suit count ([`SpiderSuits`]): 1, 2, or 4 suits
|
||||
//! spread over the same 104 cards.
|
||||
//!
|
||||
//! ## Determinism
|
||||
//!
|
||||
//! Deals are seeded with an inline SplitMix64 + Fisher–Yates shuffle:
|
||||
//! `solitaire_core` has no `rand` dependency (and adding one needs
|
||||
//! explicit approval), so Spider's seed space is deliberately
|
||||
//! self-contained rather than shared with Klondike's `StdRng` seeds.
|
||||
//! The same seed + suit count always produces the same deal.
|
||||
//!
|
||||
//! ## Card identity caveat (engine integration, later phase)
|
||||
//!
|
||||
//! [`Card`] packs deck/suit/rank into one byte, and 1- and 2-suit
|
||||
//! games need more than four copies of a suit, so *identical* `Card`
|
||||
//! values legitimately coexist (e.g. eight `♠A` in a 1-suit game,
|
||||
//! spread over `Deck1..Deck4` twice). Rules only compare suit/rank so
|
||||
//! core is unaffected, but the engine's `Card → Entity` mapping is
|
||||
//! keyed by card value and will need positional keys before a Spider
|
||||
//! UI lands.
|
||||
|
||||
use card_game::{Card, Deck, Game, Pile, Rank, Session, SessionConfig, Stack, Suit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::MoveError;
|
||||
|
||||
/// Number of tableau piles.
|
||||
pub const SPIDER_TABLEAUS: usize = 10;
|
||||
/// Total cards in play (two decks).
|
||||
pub const SPIDER_DECK_SIZE: usize = 104;
|
||||
/// Cards left in the stock after the opening deal (5 deals × 10).
|
||||
const STOCK_SIZE: usize = 50;
|
||||
/// Cards in one completed run (K → A).
|
||||
const RUN_LEN: usize = 13;
|
||||
/// Runs required to win.
|
||||
const TOTAL_RUNS: u8 = 8;
|
||||
/// Face-down cards in the deepest opening pile.
|
||||
const MAX_FACE_DOWN: usize = 5;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spider difficulty: how many distinct suits the 104 cards span.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
pub enum SpiderSuits {
|
||||
/// All spades — the beginner layout (default).
|
||||
#[default]
|
||||
One,
|
||||
/// Spades + hearts.
|
||||
Two,
|
||||
/// Full two-deck spread, the classic four-suit game.
|
||||
Four,
|
||||
}
|
||||
|
||||
impl SpiderSuits {
|
||||
/// Suits used at this difficulty.
|
||||
fn suits(self) -> &'static [Suit] {
|
||||
match self {
|
||||
Self::One => &[Suit::Spades],
|
||||
Self::Two => &[Suit::Spades, Suit::Hearts],
|
||||
Self::Four => &[Suit::Spades, Suit::Hearts, Suit::Clubs, Suit::Diamonds],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scoring knobs, mirroring the shape of `klondike::ScoringConfig`.
|
||||
///
|
||||
/// Defaults follow the familiar Microsoft formula: start at 500, −1
|
||||
/// per move, +100 per completed run (the upstream session adds
|
||||
/// `undos × undo_penalty` on top).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpiderScoring {
|
||||
/// Score a fresh deal starts from.
|
||||
pub base: i32,
|
||||
/// Added per move (conventionally negative).
|
||||
pub move_penalty: i32,
|
||||
/// Added per completed K→A run.
|
||||
pub run_bonus: i32,
|
||||
}
|
||||
|
||||
impl Default for SpiderScoring {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: 500,
|
||||
move_penalty: -1,
|
||||
run_bonus: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full Spider rules configuration (the `Game::Config` type).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SpiderConfig {
|
||||
/// Suit-count difficulty.
|
||||
pub suits: SpiderSuits,
|
||||
/// Scoring knobs.
|
||||
pub scoring: SpiderScoring,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-game counters (the `Game::Stats` type). Cumulative — they are
|
||||
/// not rolled back by undo, matching upstream `KlondikeStats`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SpiderStats {
|
||||
moves: u32,
|
||||
deals: u32,
|
||||
runs_completed: u32,
|
||||
}
|
||||
|
||||
impl SpiderStats {
|
||||
/// Total instructions processed (moves + deals).
|
||||
pub const fn moves(&self) -> u32 {
|
||||
self.moves
|
||||
}
|
||||
/// Stock deals performed.
|
||||
pub const fn deals(&self) -> u32 {
|
||||
self.deals
|
||||
}
|
||||
/// K→A runs completed over the whole game (not undone-adjusted).
|
||||
pub const fn runs_completed(&self) -> u32 {
|
||||
self.runs_completed
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instruction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One atomic Spider action (the `Game::Instruction` type).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SpiderInstruction {
|
||||
/// Deal one card from the stock onto every tableau pile.
|
||||
Deal,
|
||||
/// Move the top `count` face-up cards (a same-suit descending run)
|
||||
/// from pile `from` to pile `to`. Pile indices are `0..10`.
|
||||
Move {
|
||||
/// Source pile index.
|
||||
from: u8,
|
||||
/// Destination pile index.
|
||||
to: u8,
|
||||
/// Number of cards in the moved run (≥ 1).
|
||||
count: u8,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Game state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pure Spider game position: ten tableaus, the stock, and the count
|
||||
/// of completed runs. Everything else (undo, score bookkeeping) lives
|
||||
/// in the wrapping [`Session`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SpiderGame {
|
||||
tableaus: [Pile<MAX_FACE_DOWN, SPIDER_DECK_SIZE>; SPIDER_TABLEAUS],
|
||||
stock: Stack<STOCK_SIZE>,
|
||||
completed_runs: u8,
|
||||
}
|
||||
|
||||
/// SplitMix64 step — small, well-known, and dependency-free. Spider
|
||||
/// only needs a deterministic shuffle, not cryptographic quality.
|
||||
const fn splitmix64(state: u64) -> (u64, u64) {
|
||||
let state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
(state, z ^ (z >> 31))
|
||||
}
|
||||
|
||||
/// In-place Fisher–Yates driven by SplitMix64. The modulo bias is
|
||||
/// astronomically small for n ≤ 104 and irrelevant for gameplay.
|
||||
fn shuffle(cards: &mut [Card], seed: u64) {
|
||||
let mut state = seed;
|
||||
for i in (1..cards.len()).rev() {
|
||||
let (next_state, r) = splitmix64(state);
|
||||
state = next_state;
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let j = (r % (i as u64 + 1)) as usize;
|
||||
cards.swap(i, j);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the 104-card Spider deck for a suit-count difficulty.
|
||||
///
|
||||
/// Deck ids spread copies apart where possible (`Deck1..Deck4`), but
|
||||
/// 1- and 2-suit games necessarily contain identical `Card` values —
|
||||
/// see the module docs.
|
||||
fn build_deck(suits: SpiderSuits) -> Vec<Card> {
|
||||
let suit_set = suits.suits();
|
||||
let copies = SPIDER_DECK_SIZE / (suit_set.len() * RUN_LEN);
|
||||
let decks = [Deck::Deck1, Deck::Deck2, Deck::Deck3, Deck::Deck4];
|
||||
let mut cards = Vec::with_capacity(SPIDER_DECK_SIZE);
|
||||
for copy in 0..copies {
|
||||
for &suit in suit_set {
|
||||
for rank in Rank::RANKS {
|
||||
cards.push(Card::new(decks[copy % decks.len()], suit, rank));
|
||||
}
|
||||
}
|
||||
}
|
||||
cards
|
||||
}
|
||||
|
||||
impl SpiderGame {
|
||||
/// Deals a new seeded game at the given suit difficulty.
|
||||
pub fn with_seed(seed: u64, suits: SpiderSuits) -> Self {
|
||||
let mut deck = build_deck(suits);
|
||||
shuffle(&mut deck, seed);
|
||||
let mut cards = deck.into_iter();
|
||||
|
||||
let tableaus = core::array::from_fn(|index| {
|
||||
// Piles 0–3 open with 5 face-down cards, piles 4–9 with 4;
|
||||
// one face-up card lands on each afterwards.
|
||||
let down_count = if index < 4 { 5 } else { 4 };
|
||||
let stack: Stack<MAX_FACE_DOWN> = cards.by_ref().take(down_count).collect();
|
||||
let mut pile = Pile::new_face_down(stack);
|
||||
if let Some(card) = cards.next() {
|
||||
pile.push(card);
|
||||
}
|
||||
pile
|
||||
});
|
||||
let stock: Stack<STOCK_SIZE> = cards.collect();
|
||||
|
||||
Self {
|
||||
tableaus,
|
||||
stock,
|
||||
completed_runs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Face-up cards of pile `index` (bottom → top). Empty slice for
|
||||
/// out-of-range indices.
|
||||
pub fn tableau_face_up(&self, index: usize) -> &[Card] {
|
||||
self.tableaus.get(index).map_or(&[], |pile| pile.face_up())
|
||||
}
|
||||
|
||||
/// Face-down cards of pile `index` (bottom → top).
|
||||
pub fn tableau_face_down(&self, index: usize) -> &[Card] {
|
||||
self.tableaus
|
||||
.get(index)
|
||||
.map_or(&[], |pile| pile.face_down())
|
||||
}
|
||||
|
||||
/// Cards remaining in the stock.
|
||||
pub fn stock_len(&self) -> usize {
|
||||
self.stock.len()
|
||||
}
|
||||
|
||||
/// Completed K→A runs removed from play so far.
|
||||
pub const fn completed_runs(&self) -> u8 {
|
||||
self.completed_runs
|
||||
}
|
||||
|
||||
/// Length of the longest movable run on top of pile `index`: the
|
||||
/// maximal same-suit, strictly-descending face-up suffix.
|
||||
fn movable_run_len(&self, index: usize) -> usize {
|
||||
let Some(pile) = self.tableaus.get(index) else {
|
||||
return 0;
|
||||
};
|
||||
let up = pile.face_up();
|
||||
let mut len = usize::from(!up.is_empty());
|
||||
while len < up.len() {
|
||||
let above = &up[up.len() - len];
|
||||
let below = &up[up.len() - len - 1];
|
||||
let descends =
|
||||
below.suit() == above.suit() && below.rank() as u8 == above.rank() as u8 + 1;
|
||||
if !descends {
|
||||
break;
|
||||
}
|
||||
len += 1;
|
||||
}
|
||||
len
|
||||
}
|
||||
|
||||
/// Whether `Move { from, to, count }` is legal in this position.
|
||||
fn is_move_valid(&self, from: u8, to: u8, count: u8) -> bool {
|
||||
let (from, to, count) = (from as usize, to as usize, count as usize);
|
||||
if from == to || from >= SPIDER_TABLEAUS || to >= SPIDER_TABLEAUS || count == 0 {
|
||||
return false;
|
||||
}
|
||||
if count > self.movable_run_len(from) {
|
||||
return false;
|
||||
}
|
||||
let src_up = self.tableaus[from].face_up();
|
||||
// Bottom card of the moved run; `count <= movable_run_len <=
|
||||
// src_up.len()` guarantees the index is in range.
|
||||
let Some(moved_bottom) = src_up.get(src_up.len() - count) else {
|
||||
return false;
|
||||
};
|
||||
match self.tableaus[to].face_up().last() {
|
||||
// Build down regardless of suit.
|
||||
Some(dest_top) => dest_top.rank() as u8 == moved_bottom.rank() as u8 + 1,
|
||||
// Empty pile accepts anything (face-down remnant can't
|
||||
// exist without a face-up card — Pile flips eagerly).
|
||||
None => self.tableaus[to].is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the stock may deal: cards remain and no pile is empty.
|
||||
fn is_deal_valid(&self) -> bool {
|
||||
!self.stock.is_empty() && self.tableaus.iter().all(|pile| !pile.is_empty())
|
||||
}
|
||||
|
||||
/// Removes a completed K→A same-suit run from the top of pile
|
||||
/// `index`, if one is present. Returns `true` when a run was
|
||||
/// removed (and the next face-down card, if any, was flipped).
|
||||
fn sweep_completed_run(&mut self, index: usize) -> bool {
|
||||
let Some(pile) = self.tableaus.get_mut(index) else {
|
||||
return false;
|
||||
};
|
||||
let up = pile.face_up();
|
||||
if up.len() < RUN_LEN {
|
||||
return false;
|
||||
}
|
||||
let run = &up[up.len() - RUN_LEN..];
|
||||
let suit = run[0].suit();
|
||||
let is_complete = run.iter().enumerate().all(|(offset, card)| {
|
||||
card.suit() == suit && card.rank() as u8 == (RUN_LEN - offset) as u8
|
||||
});
|
||||
if !is_complete {
|
||||
return false;
|
||||
}
|
||||
let start = up.len() - RUN_LEN;
|
||||
let (_removed, _flipped) = pile.take_range_flip_up(start..);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Game for SpiderGame {
|
||||
type Score = i32;
|
||||
type Stats = SpiderStats;
|
||||
type Config = SpiderConfig;
|
||||
type Instruction = SpiderInstruction;
|
||||
|
||||
fn score(&self, stats: &Self::Stats, config: &Self::Config) -> Self::Score {
|
||||
let scoring = &config.scoring;
|
||||
let moves = i32::try_from(stats.moves).unwrap_or(i32::MAX);
|
||||
let runs = i32::from(self.completed_runs);
|
||||
scoring.base + moves.saturating_mul(scoring.move_penalty) + runs * scoring.run_bonus
|
||||
}
|
||||
|
||||
fn possible_instructions(
|
||||
&self,
|
||||
config: &Self::Config,
|
||||
) -> impl Iterator<Item = Self::Instruction> + use<> {
|
||||
let mut out = Vec::new();
|
||||
if self.is_deal_valid() {
|
||||
out.push(SpiderInstruction::Deal);
|
||||
}
|
||||
for from in 0..SPIDER_TABLEAUS {
|
||||
let max_run = self.movable_run_len(from);
|
||||
for count in 1..=max_run {
|
||||
for to in 0..SPIDER_TABLEAUS {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let instruction = SpiderInstruction::Move {
|
||||
from: from as u8,
|
||||
to: to as u8,
|
||||
count: count as u8,
|
||||
};
|
||||
if self.is_move_valid(from as u8, to as u8, count as u8) {
|
||||
out.push(instruction);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = config;
|
||||
out.into_iter()
|
||||
}
|
||||
|
||||
fn is_instruction_valid(&self, _config: &Self::Config, instruction: Self::Instruction) -> bool {
|
||||
match instruction {
|
||||
SpiderInstruction::Deal => self.is_deal_valid(),
|
||||
SpiderInstruction::Move { from, to, count } => self.is_move_valid(from, to, count),
|
||||
}
|
||||
}
|
||||
|
||||
fn process_instruction(
|
||||
&mut self,
|
||||
stats: &mut Self::Stats,
|
||||
config: &Self::Config,
|
||||
instruction: Self::Instruction,
|
||||
) {
|
||||
// The trait offers no error channel; an invalid instruction is
|
||||
// a no-op rather than a panic (error policy: no panics in game
|
||||
// logic). Callers route through `SpiderGameState`, which
|
||||
// validates first and surfaces `MoveError`.
|
||||
if !self.is_instruction_valid(config, instruction) {
|
||||
return;
|
||||
}
|
||||
stats.moves += 1;
|
||||
match instruction {
|
||||
SpiderInstruction::Deal => {
|
||||
stats.deals += 1;
|
||||
for index in 0..SPIDER_TABLEAUS {
|
||||
match self.stock.pop() {
|
||||
Some(card) => self.tableaus[index].push(card),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
for index in 0..SPIDER_TABLEAUS {
|
||||
if self.sweep_completed_run(index) {
|
||||
self.completed_runs += 1;
|
||||
stats.runs_completed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
SpiderInstruction::Move { from, to, count } => {
|
||||
let (from, to, count) = (from as usize, to as usize, count as usize);
|
||||
let src_len = self.tableaus[from].face_up().len();
|
||||
let (cards, _flipped) = self.tableaus[from].take_range_flip_up(src_len - count..);
|
||||
self.tableaus[to].extend(cards);
|
||||
if self.sweep_completed_run(to) {
|
||||
self.completed_runs += 1;
|
||||
stats.runs_completed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_win(&self) -> bool {
|
||||
self.completed_runs >= TOTAL_RUNS
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spider counterpart of [`crate::game_state::GameState`]: owns the
|
||||
/// upstream [`Session`] (undo history + score/undo bookkeeping) and
|
||||
/// exposes `Result<_, MoveError>` mutations, mirroring the Klondike
|
||||
/// wrapper's conventions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpiderGameState {
|
||||
seed: u64,
|
||||
session: Session<SpiderGame>,
|
||||
}
|
||||
|
||||
impl SpiderGameState {
|
||||
/// New seeded game with the default (1-suit) difficulty.
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self::new_with_suits(seed, SpiderSuits::default())
|
||||
}
|
||||
|
||||
/// New seeded game at an explicit suit difficulty.
|
||||
pub fn new_with_suits(seed: u64, suits: SpiderSuits) -> Self {
|
||||
let config = SessionConfig {
|
||||
inner: SpiderConfig {
|
||||
suits,
|
||||
scoring: SpiderScoring::default(),
|
||||
},
|
||||
// Undoing costs one move, matching the familiar Spider
|
||||
// scoring; the upstream formula applies `undos × penalty`.
|
||||
undo_penalty: -1,
|
||||
..SessionConfig::default()
|
||||
};
|
||||
Self {
|
||||
seed,
|
||||
session: Session::new(SpiderGame::with_seed(seed, suits), config),
|
||||
}
|
||||
}
|
||||
|
||||
/// The deal seed this game was created from.
|
||||
pub const fn seed(&self) -> u64 {
|
||||
self.seed
|
||||
}
|
||||
|
||||
/// Suit difficulty of this game.
|
||||
pub fn suits(&self) -> SpiderSuits {
|
||||
self.session.config().inner.suits
|
||||
}
|
||||
|
||||
/// Current position (read-only).
|
||||
pub fn game(&self) -> &SpiderGame {
|
||||
self.session.state().state()
|
||||
}
|
||||
|
||||
/// In-play score, clamped at 0 like the Klondike wrapper.
|
||||
pub fn score(&self) -> i32 {
|
||||
self.session
|
||||
.state()
|
||||
.score(self.session.stats(), self.session.config())
|
||||
.max(0)
|
||||
}
|
||||
|
||||
/// Whether all 8 runs are complete.
|
||||
pub fn is_won(&self) -> bool {
|
||||
self.session.is_win()
|
||||
}
|
||||
|
||||
/// Total instructions applied (deals + moves), from history.
|
||||
pub fn move_count(&self) -> u32 {
|
||||
u32::try_from(self.session.history().len()).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
/// Successful undos this session.
|
||||
pub fn undo_count(&self) -> u32 {
|
||||
self.session.stats().undos()
|
||||
}
|
||||
|
||||
/// Legal instructions in the current position.
|
||||
pub fn possible_instructions(&self) -> Vec<SpiderInstruction> {
|
||||
self.session.possible_instructions().collect()
|
||||
}
|
||||
|
||||
/// Applies one instruction. `Err` on illegal instructions and
|
||||
/// finished games; the session records the undo snapshot.
|
||||
pub fn apply_instruction(&mut self, instruction: SpiderInstruction) -> Result<(), MoveError> {
|
||||
if self.is_won() {
|
||||
return Err(MoveError::GameAlreadyWon);
|
||||
}
|
||||
let config = &self.session.config().inner;
|
||||
if !self
|
||||
.session
|
||||
.state()
|
||||
.state()
|
||||
.is_instruction_valid(config, instruction)
|
||||
{
|
||||
return Err(MoveError::RuleViolation("move violates rules".into()));
|
||||
}
|
||||
self.session.process_instruction(instruction);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restores the previous snapshot. Mirrors the Klondike wrapper's
|
||||
/// guards; the upstream session counts the undo for scoring.
|
||||
pub fn undo(&mut self) -> Result<(), MoveError> {
|
||||
if self.is_won() {
|
||||
return Err(MoveError::GameAlreadyWon);
|
||||
}
|
||||
if self.session.history().is_empty() {
|
||||
return Err(MoveError::UndoStackEmpty);
|
||||
}
|
||||
self.session.undo();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test support
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl SpiderGame {
|
||||
/// Builds an arbitrary position for tests: per-pile
|
||||
/// `(face_down, face_up)` card lists plus stock and completed-run
|
||||
/// count. No card-count invariants are enforced — stacked
|
||||
/// positions for rule tests routinely use partial layouts.
|
||||
pub fn from_test_layout(
|
||||
piles: [(Vec<Card>, Vec<Card>); SPIDER_TABLEAUS],
|
||||
stock: Vec<Card>,
|
||||
completed_runs: u8,
|
||||
) -> Self {
|
||||
let mut iter = piles.into_iter();
|
||||
let tableaus = core::array::from_fn(|_| {
|
||||
// `piles` has exactly SPIDER_TABLEAUS entries.
|
||||
let (down, up) = iter.next().unwrap_or_default();
|
||||
let mut pile = Pile::new_face_down(down.into_iter().collect());
|
||||
pile.extend(up);
|
||||
pile
|
||||
});
|
||||
Self {
|
||||
tableaus,
|
||||
stock: stock.into_iter().collect(),
|
||||
completed_runs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl SpiderGameState {
|
||||
/// Wraps an arbitrary position in a fresh session (empty history).
|
||||
pub fn from_test_game(game: SpiderGame, suits: SpiderSuits) -> Self {
|
||||
let config = SessionConfig {
|
||||
inner: SpiderConfig {
|
||||
suits,
|
||||
scoring: SpiderScoring::default(),
|
||||
},
|
||||
undo_penalty: -1,
|
||||
..SessionConfig::default()
|
||||
};
|
||||
Self {
|
||||
seed: 0,
|
||||
session: Session::new(game, config),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `Card::new` shorthand for stacked positions.
|
||||
fn card(suit: Suit, rank: Rank) -> Card {
|
||||
Card::new(Deck::Deck1, suit, rank)
|
||||
}
|
||||
|
||||
/// K→A same-suit run, bottom (King) first — the face-up order a
|
||||
/// completed run occupies on a pile.
|
||||
fn full_run(suit: Suit) -> Vec<Card> {
|
||||
let mut ranks: Vec<Rank> = Rank::RANKS.to_vec();
|
||||
ranks.reverse();
|
||||
ranks.into_iter().map(|rank| card(suit, rank)).collect()
|
||||
}
|
||||
|
||||
fn empty_layout() -> [(Vec<Card>, Vec<Card>); SPIDER_TABLEAUS] {
|
||||
core::array::from_fn(|_| (Vec::new(), Vec::new()))
|
||||
}
|
||||
|
||||
/// A layout where every pile is non-empty (single junk card), so
|
||||
/// deal-validity tests can toggle exactly one condition.
|
||||
fn junk_layout() -> [(Vec<Card>, Vec<Card>); SPIDER_TABLEAUS] {
|
||||
core::array::from_fn(|_| (Vec::new(), vec![card(Suit::Clubs, Rank::King)]))
|
||||
}
|
||||
|
||||
// -- dealing ----------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn opening_deal_shape_is_4x6_6x5_with_50_in_stock() {
|
||||
let game = SpiderGame::with_seed(42, SpiderSuits::Four);
|
||||
for index in 0..SPIDER_TABLEAUS {
|
||||
let expected_down = if index < 4 { 5 } else { 4 };
|
||||
assert_eq!(game.tableau_face_down(index).len(), expected_down);
|
||||
assert_eq!(game.tableau_face_up(index).len(), 1, "one card face-up");
|
||||
}
|
||||
assert_eq!(game.stock_len(), STOCK_SIZE);
|
||||
assert_eq!(game.completed_runs(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_seed_same_deal_different_seed_different_deal() {
|
||||
let a = SpiderGame::with_seed(7, SpiderSuits::Four);
|
||||
let b = SpiderGame::with_seed(7, SpiderSuits::Four);
|
||||
let c = SpiderGame::with_seed(8, SpiderSuits::Four);
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_difficulty_controls_suit_spread() {
|
||||
let one = build_deck(SpiderSuits::One);
|
||||
let two = build_deck(SpiderSuits::Two);
|
||||
let four = build_deck(SpiderSuits::Four);
|
||||
for deck in [&one, &two, &four] {
|
||||
assert_eq!(deck.len(), SPIDER_DECK_SIZE);
|
||||
}
|
||||
assert!(one.iter().all(|c| c.suit() == Suit::Spades));
|
||||
assert!(
|
||||
two.iter()
|
||||
.all(|c| matches!(c.suit(), Suit::Spades | Suit::Hearts))
|
||||
);
|
||||
for suit in Suit::SUITS {
|
||||
assert_eq!(
|
||||
four.iter().filter(|c| c.suit() == suit).count(),
|
||||
2 * RUN_LEN,
|
||||
"four-suit deck has exactly two copies per suit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- move rules -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn single_card_builds_down_regardless_of_suit() {
|
||||
let mut layout = empty_layout();
|
||||
layout[0].1 = vec![card(Suit::Hearts, Rank::Five)];
|
||||
layout[1].1 = vec![card(Suit::Spades, Rank::Six)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(game.is_move_valid(0, 1, 1), "5♥ onto 6♠ is legal");
|
||||
assert!(!game.is_move_valid(1, 0, 1), "6♠ onto 5♥ is not");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_same_suit_runs_are_movable_as_a_group() {
|
||||
let mut layout = empty_layout();
|
||||
// Pile 0: 7♠ 6♠ (movable run of 2). Pile 1: 7♥ 6♠ (mixed).
|
||||
layout[0].1 = vec![
|
||||
card(Suit::Spades, Rank::Seven),
|
||||
card(Suit::Spades, Rank::Six),
|
||||
];
|
||||
layout[1].1 = vec![
|
||||
card(Suit::Hearts, Rank::Seven),
|
||||
card(Suit::Spades, Rank::Six),
|
||||
];
|
||||
// Destinations: 8♣ (for the pair), 7♦ (for a lone six).
|
||||
layout[2].1 = vec![card(Suit::Clubs, Rank::Eight)];
|
||||
layout[3].1 = vec![card(Suit::Diamonds, Rank::Seven)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(game.is_move_valid(0, 2, 2), "same-suit pair moves");
|
||||
assert!(!game.is_move_valid(1, 2, 2), "mixed-suit pair does not");
|
||||
assert!(game.is_move_valid(1, 3, 1), "its top card alone does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pile_accepts_any_run_and_occupied_gap_rules_hold() {
|
||||
let mut layout = empty_layout();
|
||||
layout[0].1 = vec![
|
||||
card(Suit::Spades, Rank::Nine),
|
||||
card(Suit::Spades, Rank::Eight),
|
||||
];
|
||||
// Pile 1 deliberately left empty.
|
||||
layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(game.is_move_valid(0, 1, 2), "run onto empty pile");
|
||||
assert!(game.is_move_valid(2, 1, 1), "single onto empty pile");
|
||||
assert!(
|
||||
!game.is_move_valid(0, 2, 2),
|
||||
"9♠8♠ cannot land on 9♥ (needs a 10)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_moves_rejected_out_of_range_zero_count_self_move() {
|
||||
let mut layout = empty_layout();
|
||||
layout[0].1 = vec![card(Suit::Spades, Rank::Five)];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
assert!(!game.is_move_valid(0, 0, 1), "self-move");
|
||||
assert!(!game.is_move_valid(0, 1, 0), "zero count");
|
||||
assert!(!game.is_move_valid(0, 10, 1), "destination out of range");
|
||||
assert!(!game.is_move_valid(10, 0, 1), "source out of range");
|
||||
assert!(!game.is_move_valid(0, 1, 2), "count exceeds run");
|
||||
}
|
||||
|
||||
// -- dealing from stock -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn deal_requires_stock_and_no_empty_pile() {
|
||||
let stock = vec![card(Suit::Spades, Rank::Ace); 10];
|
||||
let game = SpiderGame::from_test_layout(junk_layout(), stock.clone(), 0);
|
||||
assert!(game.is_deal_valid());
|
||||
|
||||
let mut with_gap = junk_layout();
|
||||
with_gap[3].1.clear();
|
||||
let game = SpiderGame::from_test_layout(with_gap, stock, 0);
|
||||
assert!(!game.is_deal_valid(), "empty pile blocks the deal");
|
||||
|
||||
let game = SpiderGame::from_test_layout(junk_layout(), Vec::new(), 0);
|
||||
assert!(!game.is_deal_valid(), "empty stock blocks the deal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_puts_one_card_on_every_pile() {
|
||||
let mut state = SpiderGameState::new_with_suits(3, SpiderSuits::Two);
|
||||
let before: Vec<usize> = (0..SPIDER_TABLEAUS)
|
||||
.map(|i| state.game().tableau_face_up(i).len())
|
||||
.collect();
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Deal)
|
||||
.expect("deal is legal on a fresh game");
|
||||
for (index, previous) in before.iter().enumerate() {
|
||||
assert_eq!(state.game().tableau_face_up(index).len(), previous + 1);
|
||||
}
|
||||
assert_eq!(state.game().stock_len(), STOCK_SIZE - SPIDER_TABLEAUS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_supports_exactly_five_deals() {
|
||||
let mut state = SpiderGameState::new_with_suits(11, SpiderSuits::One);
|
||||
for _ in 0..5 {
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Deal)
|
||||
.expect("five deals must all be legal on untouched piles");
|
||||
}
|
||||
assert_eq!(state.game().stock_len(), 0);
|
||||
assert!(matches!(
|
||||
state.apply_instruction(SpiderInstruction::Deal),
|
||||
Err(MoveError::RuleViolation(_))
|
||||
));
|
||||
}
|
||||
|
||||
// -- run completion & win ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn completing_a_run_removes_it_and_flips_the_card_beneath() {
|
||||
let mut layout = empty_layout();
|
||||
// Pile 0: one face-down card under K..2 of spades; the ace
|
||||
// arrives from pile 1.
|
||||
let mut run = full_run(Suit::Spades);
|
||||
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
|
||||
layout[0] = (vec![card(Suit::Hearts, Rank::Nine)], run);
|
||||
layout[1].1 = vec![ace];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
|
||||
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
|
||||
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Move {
|
||||
from: 1,
|
||||
to: 0,
|
||||
count: 1,
|
||||
})
|
||||
.expect("ace onto two completes the run");
|
||||
|
||||
assert_eq!(state.game().completed_runs(), 1);
|
||||
assert_eq!(
|
||||
state.game().tableau_face_up(0),
|
||||
&[card(Suit::Hearts, Rank::Nine)],
|
||||
"run removed and the buried card flipped face-up"
|
||||
);
|
||||
assert!(state.game().tableau_face_up(1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eighth_run_wins_the_game() {
|
||||
let mut layout = empty_layout();
|
||||
let mut run = full_run(Suit::Spades);
|
||||
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
|
||||
layout[0].1 = run;
|
||||
layout[1].1 = vec![ace];
|
||||
let game = SpiderGame::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1);
|
||||
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
|
||||
|
||||
assert!(!state.is_won());
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Move {
|
||||
from: 1,
|
||||
to: 0,
|
||||
count: 1,
|
||||
})
|
||||
.expect("winning move is legal");
|
||||
assert!(state.is_won());
|
||||
assert!(matches!(
|
||||
state.apply_instruction(SpiderInstruction::Deal),
|
||||
Err(MoveError::GameAlreadyWon)
|
||||
));
|
||||
}
|
||||
|
||||
// -- session wrapper -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn undo_restores_position_and_counts() {
|
||||
let mut state = SpiderGameState::new_with_suits(21, SpiderSuits::Two);
|
||||
let fresh = state.game().clone();
|
||||
|
||||
assert!(matches!(state.undo(), Err(MoveError::UndoStackEmpty)));
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Deal)
|
||||
.expect("deal");
|
||||
assert_ne!(*state.game(), fresh);
|
||||
|
||||
state.undo().expect("one snapshot to restore");
|
||||
assert_eq!(*state.game(), fresh);
|
||||
assert_eq!(state.undo_count(), 1);
|
||||
assert_eq!(state.move_count(), 0, "undo pops the history entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_follows_base_move_penalty_and_run_bonus() {
|
||||
let mut state = SpiderGameState::new_with_suits(5, SpiderSuits::One);
|
||||
assert_eq!(state.score(), 500, "fresh game starts at base");
|
||||
state
|
||||
.apply_instruction(SpiderInstruction::Deal)
|
||||
.expect("deal");
|
||||
assert_eq!(state.score(), 499, "one move costs one point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_violation_surfaces_move_error() {
|
||||
let mut state = SpiderGameState::new_with_suits(9, SpiderSuits::One);
|
||||
let result = state.apply_instruction(SpiderInstruction::Move {
|
||||
from: 0,
|
||||
to: 0,
|
||||
count: 1,
|
||||
});
|
||||
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
||||
}
|
||||
|
||||
// -- generated instructions ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn possible_instructions_are_all_valid_and_include_deal() {
|
||||
let state = SpiderGameState::new_with_suits(13, SpiderSuits::Four);
|
||||
let config = SpiderConfig::default();
|
||||
let instructions = state.possible_instructions();
|
||||
assert!(
|
||||
instructions.contains(&SpiderInstruction::Deal),
|
||||
"fresh game has no empty pile, so Deal must be offered"
|
||||
);
|
||||
for instruction in instructions {
|
||||
assert!(state.game().is_instruction_valid(&config, instruction));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod proptests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// Total cards across tableaus + stock + removed runs must always
|
||||
/// equal 104, and every generated instruction must validate — for
|
||||
/// any seed, difficulty, and random walk through legal moves.
|
||||
fn card_conservation(game: &SpiderGame) -> usize {
|
||||
let on_piles: usize = (0..SPIDER_TABLEAUS)
|
||||
.map(|i| game.tableau_face_up(i).len() + game.tableau_face_down(i).len())
|
||||
.sum();
|
||||
on_piles + game.stock_len() + usize::from(game.completed_runs()) * RUN_LEN
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn random_walks_conserve_cards_and_stay_valid(
|
||||
seed in any::<u64>(),
|
||||
suit_pick in 0u8..3,
|
||||
steps in 0usize..40,
|
||||
choices in proptest::collection::vec(any::<u32>(), 40),
|
||||
) {
|
||||
let suits = match suit_pick {
|
||||
0 => SpiderSuits::One,
|
||||
1 => SpiderSuits::Two,
|
||||
_ => SpiderSuits::Four,
|
||||
};
|
||||
let mut state = SpiderGameState::new_with_suits(seed, suits);
|
||||
prop_assert_eq!(card_conservation(state.game()), SPIDER_DECK_SIZE);
|
||||
|
||||
for choice in choices.iter().take(steps) {
|
||||
let legal = state.possible_instructions();
|
||||
if legal.is_empty() {
|
||||
break;
|
||||
}
|
||||
let instruction = legal[*choice as usize % legal.len()];
|
||||
prop_assert!(state.apply_instruction(instruction).is_ok());
|
||||
prop_assert_eq!(card_conservation(state.game()), SPIDER_DECK_SIZE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 body — contains 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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
Reference in New Issue
Block a user