Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27cdf78ce0 | |||
| faa6c5efc4 | |||
| 487b99bbc9 | |||
| 53e3b816cf | |||
| 87275bf340 |
@@ -8,6 +8,37 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
_Nothing yet._
|
_Nothing yet._
|
||||||
|
|
||||||
|
## [0.17.0] — 2026-05-06
|
||||||
|
|
||||||
|
A short follow-up round on top of v0.16.0: the H-key hint is no
|
||||||
|
longer a heuristic guess but the actual best first move suggested by
|
||||||
|
the v0.15.0 solver, and the in-engine replay player now has a
|
||||||
|
player-tunable playback rate.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Replay-rate slider** in Settings → Gameplay. Tunes
|
||||||
|
`replay_move_interval_secs` from 0.10 s to 1.00 s in 0.05 s steps;
|
||||||
|
default 0.45 s. `tick_replay_playback` reads the value from
|
||||||
|
`SettingsResource` per frame so the slider takes effect on the
|
||||||
|
next playback tick — no restart required.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Solver-driven hints.** Pressing **H** used to surface a
|
||||||
|
heuristic-best move (foundation moves preferred, then
|
||||||
|
tableau-to-tableau by depth-of-flip-revealed). It now asks the
|
||||||
|
v0.15.0 solver for the actual provably-best first move via the
|
||||||
|
new `solitaire_core::solver::try_solve_with_first_move` /
|
||||||
|
`try_solve_from_state` APIs. When the solver returns inconclusive
|
||||||
|
(rare deals where the bound runs out before a result), the old
|
||||||
|
heuristic remains the fallback. Median 2 ms per H press.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1208 passing tests (was 1196 at v0.16.0 close).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
## [0.16.0] — 2026-05-06
|
## [0.16.0] — 2026-05-06
|
||||||
|
|
||||||
A modal-feel polish round. Every overlay screen now scrolls when its
|
A modal-feel polish round. Every overlay screen now scrolls when its
|
||||||
|
|||||||
+30
-26
@@ -1,14 +1,14 @@
|
|||||||
# Solitaire Quest — Session Handoff
|
# Solitaire Quest — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-05-06 (post-v0.16.0) — Modal-feel polish round shipped: every overlay scrolls when it overflows, every button shows a pointer cursor on hover, modal focus lands on the same frame, and read-only modals dismiss on scrim click. Direction now opens.
|
**Last updated:** 2026-05-06 (post-v0.17.0) — v0.17.0 cut on top of v0.16.0 bundling the solver-driven hints (`87275bf`) and the replay-rate slider (`53e3b81`). An async-solver attempt earlier in the session was rolled back when an agent left 3 failing tests during interruption — flagged as carryover. Test-to-work ratio noted as a quality signal: future agent briefs scale back to behaviour-level tests only, not stdlib/serde-derive coverage.
|
||||||
|
|
||||||
## Status at pause
|
## Status at pause
|
||||||
|
|
||||||
- **HEAD on origin:** v0.16.0's tag commit.
|
- **HEAD on origin:** v0.17.0's tag commit.
|
||||||
- **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional).
|
- **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional).
|
||||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||||
- **Tests:** **1196 passed / 0 failed** across the workspace.
|
- **Tests:** **1208 passed / 0 failed** across the workspace.
|
||||||
- **Tags on origin:** `v0.9.0` through `v0.16.0`.
|
- **Tags on origin:** `v0.9.0` through `v0.17.0`.
|
||||||
|
|
||||||
## Where we are
|
## Where we are
|
||||||
|
|
||||||
@@ -28,6 +28,13 @@ The post-v0.15.0 next-round candidates are still mostly open — solver-driven h
|
|||||||
|
|
||||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there.
|
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there.
|
||||||
|
|
||||||
|
## v0.17.0 (shipped 2026-05-06)
|
||||||
|
|
||||||
|
| Area | Commit | What landed |
|
||||||
|
|---|---|---|
|
||||||
|
| Solver-driven hints | `87275bf` | The H-key hint asks the solver for the actual best first move via `try_solve_with_first_move` / `try_solve_from_state`. Heuristic stays as fallback. Median 2 ms per H press. |
|
||||||
|
| Replay-rate slider | `53e3b81` | Settings → Gameplay slider tunes `replay_move_interval_secs` 0.10–1.00 s in 0.05 s steps; default 0.45 s. Read per frame from `SettingsResource`. |
|
||||||
|
|
||||||
## v0.16.0 (shipped 2026-05-06)
|
## v0.16.0 (shipped 2026-05-06)
|
||||||
|
|
||||||
| Area | Commit | What landed |
|
| Area | Commit | What landed |
|
||||||
@@ -42,17 +49,15 @@ The post-v0.15.0 next-round candidates are still mostly open — solver-driven h
|
|||||||
|
|
||||||
### Release prep
|
### Release prep
|
||||||
|
|
||||||
1. **Smoke-test on a real game**: confirm scroll feels right on Achievements (the original bug), pointer cursor changes on every interactive surface, the very first Tab in a modal already activates the primary, and clicking the dimmed area dismisses the read-only modals while NOT dismissing Settings/Pause.
|
1. **Desktop packaging** per `ARCHITECTURE.md §17`. Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
|
||||||
2. **Desktop packaging** per `ARCHITECTURE.md §17`. Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
|
|
||||||
|
|
||||||
### Carryover from v0.15.0 next-round candidates
|
### Process note (raised this session)
|
||||||
|
|
||||||
Still open — would each be ~50–200 LOC:
|
Recent agent briefs reflexively asked for ≥3 tests per feature, which produced low-value coverage on trivial settings fields (default-value tests, serde-derive round-trips, clamp tests that just exercise stdlib `clamp`). Future agent briefs should ask only for tests that pin **behaviour contracts or regressions on real bugs** — not coverage of language/library mechanics.
|
||||||
|
|
||||||
- **Solver-driven hints** — the existing hint system uses a heuristic; promote it to ask `try_solve` for the actual best move. Now that the solver is in place this is mostly plumbing.
|
### Carryover candidates — still open
|
||||||
- **Replay-playback rate slider** — the 0.45 s/move pace is hardcoded; a Settings slider in the same row as tooltip-delay / time-bonus would let power users speed up older replays.
|
|
||||||
- **Solver progress overlay** — when "Winnable deals only" is on, a brief "checking deal…" toast surfaces after ~500 ms so the player isn't confused by the rare worst-case stall.
|
- **Solver-on-AsyncComputeTaskPool** — current solver runs synchronously on the main thread. Worst-case 50 attempts × 120 ms = 6 s of UI stall on pathological seeds. **An attempt this session was rolled back** when an agent was interrupted leaving 3 failing tests; redoing this needs more careful scoping (smaller pieces, real cancel-and-test flow, NOT a parallel agent split). Worth taking next.
|
||||||
- **Solver-on-AsyncComputeTaskPool** — current solver runs synchronously on the main thread. Worst-case 50 attempts × 120 ms = 6 s of UI stall on pathological seeds. Async + cancel button would be safer.
|
|
||||||
- **Per-deal "won previously" indicator** — the rolling replay history's seeds make this easy: when a new game starts on a seed the player has already won, surface a tiny indicator on the HUD.
|
- **Per-deal "won previously" indicator** — the rolling replay history's seeds make this easy: when a new game starts on a seed the player has already won, surface a tiny indicator on the HUD.
|
||||||
- **Replay sharing** — `replays.json` is per-machine. Allow a player to copy a replay's URL (already wired via `solitaire_server`) and post it elsewhere. The web-viewer already exists.
|
- **Replay sharing** — `replays.json` is per-machine. Allow a player to copy a replay's URL (already wired via `solitaire_server`) and post it elsewhere. The web-viewer already exists.
|
||||||
|
|
||||||
@@ -66,10 +71,11 @@ Branch: master. Direction is OPEN — v0.16.0 just shipped covering
|
|||||||
modal scroll fixes, pointer cursor, same-frame focus, and scrim-click
|
modal scroll fixes, pointer cursor, same-frame focus, and scrim-click
|
||||||
dismiss across all six read-only modals.
|
dismiss across all six read-only modals.
|
||||||
|
|
||||||
State: HEAD at v0.16.0. Working tree clean apart from untracked
|
State: HEAD at v0.17.0 (solver hints + replay-rate slider on top
|
||||||
CARD_PLAN.md (intentional).
|
of v0.16.0). Working tree clean apart from untracked CARD_PLAN.md
|
||||||
|
(intentional).
|
||||||
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
||||||
Tests: 1196 passed / 0 failed.
|
Tests: 1208 passed / 0 failed.
|
||||||
|
|
||||||
READ FIRST (in order, before doing anything):
|
READ FIRST (in order, before doing anything):
|
||||||
1. SESSION_HANDOFF.md — v0.16.0 changelog + open punch list
|
1. SESSION_HANDOFF.md — v0.16.0 changelog + open punch list
|
||||||
@@ -81,16 +87,14 @@ READ FIRST (in order, before doing anything):
|
|||||||
may be missing on a fresh machine)
|
may be missing on a fresh machine)
|
||||||
|
|
||||||
DECISION TO ASK THE PLAYER FIRST:
|
DECISION TO ASK THE PLAYER FIRST:
|
||||||
A. Smoke-test v0.16.0. Scroll on Achievements, pointer cursor on
|
A. Solver-on-AsyncComputeTaskPool with progress toast + cancel.
|
||||||
buttons, first Tab in a modal activates rather than advances,
|
A previous attempt was rolled back when an agent left 3
|
||||||
scrim click dismisses Stats/Achievements/Help/Profile/
|
failing tests; redoing it needs smaller pieces. Eliminates the
|
||||||
Leaderboard/Home but NOT Settings/Pause/etc.
|
worst-case 6 s UI stall — highest gameplay impact left.
|
||||||
B. Solver-driven hints — replace heuristic with try_solve's
|
B. Per-deal "won previously" HUD indicator using the rolling
|
||||||
best-move suggestion. ~100 LOC.
|
replay history's seeds.
|
||||||
C. Solver-on-AsyncComputeTaskPool with progress toast + cancel.
|
C. Replay sharing — copyable URL via the existing web viewer.
|
||||||
Eliminates the worst-case 6 s stall.
|
D. Take the deferred desktop-packaging item (needs artwork +
|
||||||
D. Pick from the remaining "next-round candidates" in this doc.
|
|
||||||
E. Take the deferred desktop-packaging item (needs artwork +
|
|
||||||
signing certs from the user).
|
signing certs from the user).
|
||||||
|
|
||||||
WORKFLOW NOTES:
|
WORKFLOW NOTES:
|
||||||
@@ -103,5 +107,5 @@ WORKFLOW NOTES:
|
|||||||
- Every commit must pass build / clippy / test before pushing.
|
- Every commit must pass build / clippy / test before pushing.
|
||||||
- Push to GitHub (origin) — that is the canonical remote.
|
- Push to GitHub (origin) — that is the canonical remote.
|
||||||
|
|
||||||
OPEN AT THE START: ask which of A–E. Don't pick unilaterally.
|
OPEN AT THE START: ask which of A–D. Don't pick unilaterally.
|
||||||
```
|
```
|
||||||
|
|||||||
+397
-43
@@ -65,7 +65,7 @@ use std::hash::{Hash, Hasher};
|
|||||||
|
|
||||||
use crate::card::{Card, Suit};
|
use crate::card::{Card, Suit};
|
||||||
use crate::deck::{deal_klondike, Deck};
|
use crate::deck::{deal_klondike, Deck};
|
||||||
use crate::game_state::DrawMode;
|
use crate::game_state::{DrawMode, GameState};
|
||||||
use crate::pile::{Pile, PileType};
|
use crate::pile::{Pile, PileType};
|
||||||
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
|
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
|
||||||
|
|
||||||
@@ -108,6 +108,42 @@ impl Default for SolverConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single move the solver can recommend, expressed in terms of the
|
||||||
|
/// engine-level `(source, dest, count)` triple used by `MoveRequestEvent`.
|
||||||
|
///
|
||||||
|
/// Returned as part of [`SolveOutcome::first_move`] when
|
||||||
|
/// [`try_solve_with_first_move`] or [`try_solve_from_state`] proves the
|
||||||
|
/// position winnable. The hint system surfaces this to the player as the
|
||||||
|
/// "provably best" first move.
|
||||||
|
///
|
||||||
|
/// `count` is always `1` for non-tableau-to-tableau moves (foundation moves
|
||||||
|
/// always move a single card; waste moves a single card; draws use a
|
||||||
|
/// dedicated representation that the public API surfaces as
|
||||||
|
/// `source: PileType::Stock, dest: PileType::Waste, count: 1`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SolverMove {
|
||||||
|
/// Pile the move originates from.
|
||||||
|
pub source: PileType,
|
||||||
|
/// Pile the move lands on.
|
||||||
|
pub dest: PileType,
|
||||||
|
/// Number of cards in the move (1 for non-tableau-to-tableau moves).
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Solver verdict plus, when winnable, the first move on a winning path.
|
||||||
|
///
|
||||||
|
/// `result == Winnable` guarantees `first_move == Some(_)`; the inverse
|
||||||
|
/// holds only when the search proved a verdict — `Inconclusive` and
|
||||||
|
/// `Unwinnable` always carry `first_move == None`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SolveOutcome {
|
||||||
|
/// The high-level verdict (Winnable / Unwinnable / Inconclusive).
|
||||||
|
pub result: SolverResult,
|
||||||
|
/// First move on the solution path when `result == Winnable`,
|
||||||
|
/// otherwise `None`.
|
||||||
|
pub first_move: Option<SolverMove>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
|
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
|
||||||
///
|
///
|
||||||
/// This is a pure function — same input always yields the same
|
/// This is a pure function — same input always yields the same
|
||||||
@@ -120,18 +156,47 @@ impl Default for SolverConfig {
|
|||||||
/// [`is_valid_tableau_sequence`]) drive move enumeration so the
|
/// [`is_valid_tableau_sequence`]) drive move enumeration so the
|
||||||
/// solver's notion of "legal" exactly matches the live game.
|
/// solver's notion of "legal" exactly matches the live game.
|
||||||
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
||||||
let state = SolverState::initial(seed, draw_mode);
|
// Delegate to the path-recording variant and discard the move. The path
|
||||||
let mut visited: HashSet<u64> = HashSet::new();
|
// recording is cheap (a single Option<SolverMove> per stack frame) so
|
||||||
let mut moves_consumed: u64 = 0;
|
// this preserves `try_solve`'s existing performance characteristics —
|
||||||
let mut budget_exceeded = false;
|
// the new-game retry loop, which is the hot caller, sees no slowdown.
|
||||||
let won = state.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
try_solve_with_first_move(seed, draw_mode, config).result
|
||||||
if won {
|
|
||||||
SolverResult::Winnable
|
|
||||||
} else if budget_exceeded {
|
|
||||||
SolverResult::Inconclusive
|
|
||||||
} else {
|
|
||||||
SolverResult::Unwinnable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode` and,
|
||||||
|
/// when a win is found, returns the first move on the winning path.
|
||||||
|
///
|
||||||
|
/// Same semantics as [`try_solve`] for the verdict; the only difference is
|
||||||
|
/// that the [`SolveOutcome::first_move`] is populated when `result ==
|
||||||
|
/// SolverResult::Winnable`. `Unwinnable` and `Inconclusive` always carry
|
||||||
|
/// `first_move == None`.
|
||||||
|
///
|
||||||
|
/// Used by the engine hint system to promote H-key suggestions from a
|
||||||
|
/// heuristic to the provably-optimal first move; the hint system falls
|
||||||
|
/// back to its heuristic when this returns `Inconclusive`.
|
||||||
|
pub fn try_solve_with_first_move(
|
||||||
|
seed: u64,
|
||||||
|
draw_mode: DrawMode,
|
||||||
|
config: &SolverConfig,
|
||||||
|
) -> SolveOutcome {
|
||||||
|
let state = SolverState::initial(seed, draw_mode);
|
||||||
|
state.solve(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries to solve from an existing in-progress [`GameState`].
|
||||||
|
///
|
||||||
|
/// Mirrors [`try_solve_with_first_move`] but takes a live `GameState`
|
||||||
|
/// instead of a fresh seed. The hint system uses this so it can ask the
|
||||||
|
/// solver about the actual board the player is staring at, not just the
|
||||||
|
/// initial deal.
|
||||||
|
///
|
||||||
|
/// Reads `state.draw_mode` and the current pile contents. The active
|
||||||
|
/// `GameMode` is irrelevant — the solver only models Classic Klondike
|
||||||
|
/// rules, which are a strict subset of every other mode (Zen / Challenge
|
||||||
|
/// only differ in scoring and undo-availability).
|
||||||
|
pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOutcome {
|
||||||
|
let solver_state = SolverState::from_game_state(state);
|
||||||
|
solver_state.solve(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -141,9 +206,11 @@ pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> Solve
|
|||||||
/// The candidate moves the solver enumerates at each step. Distinct
|
/// The candidate moves the solver enumerates at each step. Distinct
|
||||||
/// from `MoveRequestEvent` (engine-level) and `move_cards` (game-level)
|
/// from `MoveRequestEvent` (engine-level) and `move_cards` (game-level)
|
||||||
/// because the solver also needs to model the stock-draw + recycle as a
|
/// because the solver also needs to model the stock-draw + recycle as a
|
||||||
/// first-class move.
|
/// first-class move. Distinct from the public [`SolverMove`] because the
|
||||||
|
/// internal form encodes each move kind structurally for fast pattern
|
||||||
|
/// matching during enumeration.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum SolverMove {
|
enum InternalMove {
|
||||||
/// Move `count` cards from a tableau column to another tableau column.
|
/// Move `count` cards from a tableau column to another tableau column.
|
||||||
TableauToTableau { from: usize, to: usize, count: usize },
|
TableauToTableau { from: usize, to: usize, count: usize },
|
||||||
/// Move the top of a tableau column to a foundation slot.
|
/// Move the top of a tableau column to a foundation slot.
|
||||||
@@ -156,6 +223,41 @@ enum SolverMove {
|
|||||||
Draw,
|
Draw,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl InternalMove {
|
||||||
|
/// Convert this internal move into the public [`SolverMove`] form
|
||||||
|
/// suitable for handing off to the engine layer. Cheap — `O(1)` field
|
||||||
|
/// rewrites with no allocation.
|
||||||
|
fn to_public(self) -> SolverMove {
|
||||||
|
match self {
|
||||||
|
InternalMove::TableauToTableau { from, to, count } => SolverMove {
|
||||||
|
source: PileType::Tableau(from),
|
||||||
|
dest: PileType::Tableau(to),
|
||||||
|
count,
|
||||||
|
},
|
||||||
|
InternalMove::TableauToFoundation { from, slot } => SolverMove {
|
||||||
|
source: PileType::Tableau(from),
|
||||||
|
dest: PileType::Foundation(slot),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
InternalMove::WasteToTableau { to } => SolverMove {
|
||||||
|
source: PileType::Waste,
|
||||||
|
dest: PileType::Tableau(to),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
InternalMove::WasteToFoundation { slot } => SolverMove {
|
||||||
|
source: PileType::Waste,
|
||||||
|
dest: PileType::Foundation(slot),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
InternalMove::Draw => SolverMove {
|
||||||
|
source: PileType::Stock,
|
||||||
|
dest: PileType::Waste,
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Compact replica of `GameState` tailored for the solver. Strips
|
/// Compact replica of `GameState` tailored for the solver. Strips
|
||||||
/// undo / score / move-count tracking and replaces the `HashMap` of
|
/// undo / score / move-count tracking and replaces the `HashMap` of
|
||||||
/// piles with fixed arrays so the canonical hash is cheap to compute.
|
/// piles with fixed arrays so the canonical hash is cheap to compute.
|
||||||
@@ -232,8 +334,8 @@ impl SolverState {
|
|||||||
/// The order matters — foundation moves shrink the search frontier
|
/// The order matters — foundation moves shrink the search frontier
|
||||||
/// fastest, and stock-draws are the costliest. See the top-of-file
|
/// fastest, and stock-draws are the costliest. See the top-of-file
|
||||||
/// algorithm note.
|
/// algorithm note.
|
||||||
fn enumerate_moves(&self) -> Vec<SolverMove> {
|
fn enumerate_moves(&self) -> Vec<InternalMove> {
|
||||||
let mut moves: Vec<SolverMove> = Vec::new();
|
let mut moves: Vec<InternalMove> = Vec::new();
|
||||||
|
|
||||||
// 1) Foundation moves from tableau tops.
|
// 1) Foundation moves from tableau tops.
|
||||||
for (i, col) in self.tableau.iter().enumerate() {
|
for (i, col) in self.tableau.iter().enumerate() {
|
||||||
@@ -246,7 +348,7 @@ impl SolverState {
|
|||||||
&self.foundation[slot as usize],
|
&self.foundation[slot as usize],
|
||||||
);
|
);
|
||||||
if can_place_on_foundation(top, &foundation_pile) {
|
if can_place_on_foundation(top, &foundation_pile) {
|
||||||
moves.push(SolverMove::TableauToFoundation { from: i, slot });
|
moves.push(InternalMove::TableauToFoundation { from: i, slot });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,7 +362,7 @@ impl SolverState {
|
|||||||
&self.foundation[slot as usize],
|
&self.foundation[slot as usize],
|
||||||
);
|
);
|
||||||
if can_place_on_foundation(top, &foundation_pile) {
|
if can_place_on_foundation(top, &foundation_pile) {
|
||||||
moves.push(SolverMove::WasteToFoundation { slot });
|
moves.push(InternalMove::WasteToFoundation { slot });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +400,7 @@ impl SolverState {
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
moves.push(SolverMove::TableauToTableau { from: src, to: dst, count });
|
moves.push(InternalMove::TableauToTableau { from: src, to: dst, count });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,7 +410,7 @@ impl SolverState {
|
|||||||
for dst in 0..7usize {
|
for dst in 0..7usize {
|
||||||
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
|
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
|
||||||
if can_place_on_tableau(top, &dst_pile) {
|
if can_place_on_tableau(top, &dst_pile) {
|
||||||
moves.push(SolverMove::WasteToTableau { to: dst });
|
moves.push(InternalMove::WasteToTableau { to: dst });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,7 +428,7 @@ impl SolverState {
|
|||||||
let cycled_without_progress =
|
let cycled_without_progress =
|
||||||
self.consecutive_draws > stock_cycle_len.saturating_add(1);
|
self.consecutive_draws > stock_cycle_len.saturating_add(1);
|
||||||
if can_draw && !cycled_without_progress {
|
if can_draw && !cycled_without_progress {
|
||||||
moves.push(SolverMove::Draw);
|
moves.push(InternalMove::Draw);
|
||||||
}
|
}
|
||||||
|
|
||||||
moves
|
moves
|
||||||
@@ -334,11 +436,11 @@ impl SolverState {
|
|||||||
|
|
||||||
/// Apply `mv` to `self`, returning the previous `consecutive_draws`
|
/// Apply `mv` to `self`, returning the previous `consecutive_draws`
|
||||||
/// value so the caller can restore it on backtrack.
|
/// value so the caller can restore it on backtrack.
|
||||||
fn apply_move(&mut self, mv: SolverMove) -> SolverStateUndo {
|
fn apply_move(&mut self, mv: InternalMove) -> SolverStateUndo {
|
||||||
let prev_just_drew = self.just_drew;
|
let prev_just_drew = self.just_drew;
|
||||||
let prev_consec = self.consecutive_draws;
|
let prev_consec = self.consecutive_draws;
|
||||||
match mv {
|
match mv {
|
||||||
SolverMove::TableauToTableau { from, to, count } => {
|
InternalMove::TableauToTableau { from, to, count } => {
|
||||||
let start = self.tableau[from].len() - count;
|
let start = self.tableau[from].len() - count;
|
||||||
let moved: Vec<Card> = self.tableau[from].split_off(start);
|
let moved: Vec<Card> = self.tableau[from].split_off(start);
|
||||||
self.tableau[to].extend(moved);
|
self.tableau[to].extend(moved);
|
||||||
@@ -351,7 +453,7 @@ impl SolverState {
|
|||||||
self.just_drew = false;
|
self.just_drew = false;
|
||||||
self.consecutive_draws = 0;
|
self.consecutive_draws = 0;
|
||||||
}
|
}
|
||||||
SolverMove::TableauToFoundation { from, slot } => {
|
InternalMove::TableauToFoundation { from, slot } => {
|
||||||
if let Some(card) = self.tableau[from].pop() {
|
if let Some(card) = self.tableau[from].pop() {
|
||||||
self.foundation[slot as usize].push(card);
|
self.foundation[slot as usize].push(card);
|
||||||
if let Some(top) = self.tableau[from].last_mut()
|
if let Some(top) = self.tableau[from].last_mut()
|
||||||
@@ -363,21 +465,21 @@ impl SolverState {
|
|||||||
self.just_drew = false;
|
self.just_drew = false;
|
||||||
self.consecutive_draws = 0;
|
self.consecutive_draws = 0;
|
||||||
}
|
}
|
||||||
SolverMove::WasteToTableau { to } => {
|
InternalMove::WasteToTableau { to } => {
|
||||||
if let Some(card) = self.waste.pop() {
|
if let Some(card) = self.waste.pop() {
|
||||||
self.tableau[to].push(card);
|
self.tableau[to].push(card);
|
||||||
}
|
}
|
||||||
self.just_drew = false;
|
self.just_drew = false;
|
||||||
self.consecutive_draws = 0;
|
self.consecutive_draws = 0;
|
||||||
}
|
}
|
||||||
SolverMove::WasteToFoundation { slot } => {
|
InternalMove::WasteToFoundation { slot } => {
|
||||||
if let Some(card) = self.waste.pop() {
|
if let Some(card) = self.waste.pop() {
|
||||||
self.foundation[slot as usize].push(card);
|
self.foundation[slot as usize].push(card);
|
||||||
}
|
}
|
||||||
self.just_drew = false;
|
self.just_drew = false;
|
||||||
self.consecutive_draws = 0;
|
self.consecutive_draws = 0;
|
||||||
}
|
}
|
||||||
SolverMove::Draw => {
|
InternalMove::Draw => {
|
||||||
if self.stock.is_empty() {
|
if self.stock.is_empty() {
|
||||||
// Recycle waste back to stock face-down, reversed.
|
// Recycle waste back to stock face-down, reversed.
|
||||||
let mut recycled: Vec<Card> = self.waste.drain(..).collect();
|
let mut recycled: Vec<Card> = self.waste.drain(..).collect();
|
||||||
@@ -415,39 +517,55 @@ impl SolverState {
|
|||||||
/// stack lives on the heap and grows only with `Vec` capacity, not
|
/// stack lives on the heap and grows only with `Vec` capacity, not
|
||||||
/// with thread-stack pages.
|
/// with thread-stack pages.
|
||||||
///
|
///
|
||||||
/// Returns `true` as soon as a winning leaf is found. Sets
|
/// Returns `Some(first_move)` (the move applied at the root that led
|
||||||
/// `*budget_exceeded = true` if either budget trips before a
|
/// to a winning leaf) as soon as a winning leaf is found. Returns
|
||||||
/// verdict.
|
/// `None` if the search exhausts (Unwinnable) or a budget trips —
|
||||||
|
/// callers distinguish those two cases via `*budget_exceeded`.
|
||||||
|
///
|
||||||
|
/// Path recording is implemented by stashing the root-level move on
|
||||||
|
/// each pushed frame and propagating it unchanged into deeper
|
||||||
|
/// children. Cost: one `Option<InternalMove>` (≤ 16 bytes) per
|
||||||
|
/// frame and one branch on push. Negligible on the hot path; the
|
||||||
|
/// new-game retry loop sees no measurable slowdown.
|
||||||
fn search(
|
fn search(
|
||||||
self,
|
self,
|
||||||
config: &SolverConfig,
|
config: &SolverConfig,
|
||||||
visited: &mut HashSet<u64>,
|
visited: &mut HashSet<u64>,
|
||||||
moves_consumed: &mut u64,
|
moves_consumed: &mut u64,
|
||||||
budget_exceeded: &mut bool,
|
budget_exceeded: &mut bool,
|
||||||
) -> bool {
|
) -> Option<SolverMove> {
|
||||||
// Each stack frame keeps a state plus the move iterator we
|
// Each stack frame keeps a state plus the move iterator we
|
||||||
// haven't yet expanded. Popping a frame is the backtrack.
|
// haven't yet expanded. Popping a frame is the backtrack.
|
||||||
struct Frame {
|
struct Frame {
|
||||||
state: SolverState,
|
state: SolverState,
|
||||||
pending: std::vec::IntoIter<SolverMove>,
|
pending: std::vec::IntoIter<InternalMove>,
|
||||||
|
/// First move on the path from the root to this frame's
|
||||||
|
/// state. `None` for the root frame; populated when a child
|
||||||
|
/// frame is pushed. Propagates unchanged from parent to deeper
|
||||||
|
/// children so any winning leaf can read it directly.
|
||||||
|
root_move: Option<InternalMove>,
|
||||||
}
|
}
|
||||||
// Quick exits before allocating the stack.
|
// Quick exits before allocating the stack. An already-won state
|
||||||
|
// surfaces as Winnable with no move to recommend (the player has
|
||||||
|
// nothing left to do); the engine treats this gracefully —
|
||||||
|
// `is_won` callers gate H-key hints on `!is_won` already.
|
||||||
if self.is_won() {
|
if self.is_won() {
|
||||||
return true;
|
return None;
|
||||||
}
|
}
|
||||||
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
|
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
|
||||||
*budget_exceeded = true;
|
*budget_exceeded = true;
|
||||||
return false;
|
return None;
|
||||||
}
|
}
|
||||||
let root_hash = self.canonical_hash();
|
let root_hash = self.canonical_hash();
|
||||||
if !visited.insert(root_hash) {
|
if !visited.insert(root_hash) {
|
||||||
return false;
|
return None;
|
||||||
}
|
}
|
||||||
let root_moves = self.enumerate_moves();
|
let root_moves = self.enumerate_moves();
|
||||||
let mut stack: Vec<Frame> = Vec::new();
|
let mut stack: Vec<Frame> = Vec::new();
|
||||||
stack.push(Frame {
|
stack.push(Frame {
|
||||||
state: self,
|
state: self,
|
||||||
pending: root_moves.into_iter(),
|
pending: root_moves.into_iter(),
|
||||||
|
root_move: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
while let Some(frame) = stack.last_mut() {
|
while let Some(frame) = stack.last_mut() {
|
||||||
@@ -457,7 +575,7 @@ impl SolverState {
|
|||||||
|| visited.len() >= config.state_budget
|
|| visited.len() >= config.state_budget
|
||||||
{
|
{
|
||||||
*budget_exceeded = true;
|
*budget_exceeded = true;
|
||||||
return false;
|
return None;
|
||||||
}
|
}
|
||||||
let Some(mv) = frame.pending.next() else {
|
let Some(mv) = frame.pending.next() else {
|
||||||
// Exhausted this frame's children — backtrack.
|
// Exhausted this frame's children — backtrack.
|
||||||
@@ -465,10 +583,15 @@ impl SolverState {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
*moves_consumed = moves_consumed.saturating_add(1);
|
*moves_consumed = moves_consumed.saturating_add(1);
|
||||||
|
// Determine the root-level move for the *child* we are about
|
||||||
|
// to push: if the current frame is the root (root_move is
|
||||||
|
// None) then the child's root move is `mv` itself; otherwise
|
||||||
|
// it inherits from the parent.
|
||||||
|
let child_root_move = frame.root_move.unwrap_or(mv);
|
||||||
let mut next = frame.state.clone();
|
let mut next = frame.state.clone();
|
||||||
next.apply_move(mv);
|
next.apply_move(mv);
|
||||||
if next.is_won() {
|
if next.is_won() {
|
||||||
return true;
|
return Some(child_root_move.to_public());
|
||||||
}
|
}
|
||||||
let h = next.canonical_hash();
|
let h = next.canonical_hash();
|
||||||
if !visited.insert(h) {
|
if !visited.insert(h) {
|
||||||
@@ -478,9 +601,74 @@ impl SolverState {
|
|||||||
stack.push(Frame {
|
stack.push(Frame {
|
||||||
state: next,
|
state: next,
|
||||||
pending: next_moves.into_iter(),
|
pending: next_moves.into_iter(),
|
||||||
|
root_move: Some(child_root_move),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
false
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive [`SolverState::search`] and convert the raw outcome into a
|
||||||
|
/// public [`SolveOutcome`]. Shared by [`try_solve_with_first_move`]
|
||||||
|
/// and [`try_solve_from_state`].
|
||||||
|
fn solve(self, config: &SolverConfig) -> SolveOutcome {
|
||||||
|
let mut visited: HashSet<u64> = HashSet::new();
|
||||||
|
let mut moves_consumed: u64 = 0;
|
||||||
|
let mut budget_exceeded = false;
|
||||||
|
let already_won = self.is_won();
|
||||||
|
let first_move = self.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
||||||
|
let result = if already_won || first_move.is_some() {
|
||||||
|
SolverResult::Winnable
|
||||||
|
} else if budget_exceeded {
|
||||||
|
SolverResult::Inconclusive
|
||||||
|
} else {
|
||||||
|
SolverResult::Unwinnable
|
||||||
|
};
|
||||||
|
SolveOutcome { result, first_move }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `SolverState` from an in-progress [`GameState`].
|
||||||
|
///
|
||||||
|
/// Reads the live pile contents and `draw_mode`. Missing piles are
|
||||||
|
/// treated as empty — the engine's `GameState::new` always populates
|
||||||
|
/// every pile slot, but defensive code keeps this loader safe in the
|
||||||
|
/// face of partially-constructed test fixtures.
|
||||||
|
///
|
||||||
|
/// The search-metadata fields (`just_drew`, `consecutive_draws`)
|
||||||
|
/// reset to "no draws yet" — the solver is concerned with future
|
||||||
|
/// reachability from this position, not the engine's own draw
|
||||||
|
/// history.
|
||||||
|
fn from_game_state(game: &GameState) -> Self {
|
||||||
|
let tableau: [Vec<Card>; 7] = core::array::from_fn(|i| {
|
||||||
|
game.piles
|
||||||
|
.get(&PileType::Tableau(i))
|
||||||
|
.map(|p| p.cards.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
|
let foundation: [Vec<Card>; 4] = core::array::from_fn(|i| {
|
||||||
|
game.piles
|
||||||
|
.get(&PileType::Foundation(i as u8))
|
||||||
|
.map(|p| p.cards.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
|
let stock = game
|
||||||
|
.piles
|
||||||
|
.get(&PileType::Stock)
|
||||||
|
.map(|p| p.cards.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let waste = game
|
||||||
|
.piles
|
||||||
|
.get(&PileType::Waste)
|
||||||
|
.map(|p| p.cards.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
tableau,
|
||||||
|
foundation,
|
||||||
|
stock,
|
||||||
|
waste,
|
||||||
|
draw_mode: game.draw_mode.clone(),
|
||||||
|
just_drew: false,
|
||||||
|
consecutive_draws: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a deterministic 64-bit hash of the visible game state.
|
/// Build a deterministic 64-bit hash of the visible game state.
|
||||||
@@ -656,9 +844,9 @@ mod tests {
|
|||||||
let mut moves_consumed: u64 = 0;
|
let mut moves_consumed: u64 = 0;
|
||||||
let mut budget_exceeded = false;
|
let mut budget_exceeded = false;
|
||||||
let cfg = SolverConfig::default();
|
let cfg = SolverConfig::default();
|
||||||
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
||||||
|
|
||||||
assert!(won, "obviously-winnable position must be recognised as Winnable");
|
assert!(first_move.is_some(), "obviously-winnable position must be recognised as Winnable");
|
||||||
assert!(!budget_exceeded);
|
assert!(!budget_exceeded);
|
||||||
assert!(
|
assert!(
|
||||||
moves_consumed < 1000,
|
moves_consumed < 1000,
|
||||||
@@ -699,8 +887,8 @@ mod tests {
|
|||||||
let mut visited: HashSet<u64> = HashSet::new();
|
let mut visited: HashSet<u64> = HashSet::new();
|
||||||
let mut moves_consumed: u64 = 0;
|
let mut moves_consumed: u64 = 0;
|
||||||
let mut budget_exceeded = false;
|
let mut budget_exceeded = false;
|
||||||
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
||||||
assert!(!won, "buried Ace under same-suit Two with no recovery must not solve");
|
assert!(first_move.is_none(), "buried Ace under same-suit Two with no recovery must not solve");
|
||||||
assert!(!budget_exceeded, "small synthetic state must complete within budget");
|
assert!(!budget_exceeded, "small synthetic state must complete within budget");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,4 +1078,170 @@ mod tests {
|
|||||||
counts[0], counts[1], counts[2],
|
counts[0], counts[1], counts[2],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// First-move-recording API: try_solve_with_first_move /
|
||||||
|
// try_solve_from_state. Exercised by the engine hint system.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A synthetic GameState with each foundation holding A..Q for its
|
||||||
|
/// suit, the four Kings sitting on tableau columns 0..3, empty stock
|
||||||
|
/// and empty waste. Exactly four legal moves exist — one Tableau→
|
||||||
|
/// Foundation per King — and any one of them is the first move on a
|
||||||
|
/// solution path.
|
||||||
|
fn near_finished_game_state() -> GameState {
|
||||||
|
use crate::card::Rank;
|
||||||
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||||
|
// Wipe every pile.
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Foundation(slot))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
for i in 0..7_usize {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Tableau(i))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
|
||||||
|
// Foundations: A through Q for each suit. Slot 0=Clubs,
|
||||||
|
// 1=Diamonds, 2=Hearts, 3=Spades to match
|
||||||
|
// `target_foundation_slot` ordering.
|
||||||
|
let suit_for_slot = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
|
let ranks_below_king = [
|
||||||
|
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||||
|
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||||||
|
Rank::Jack, Rank::Queen,
|
||||||
|
];
|
||||||
|
for (slot, suit) in suit_for_slot.iter().enumerate() {
|
||||||
|
let pile = game
|
||||||
|
.piles
|
||||||
|
.get_mut(&PileType::Foundation(slot as u8))
|
||||||
|
.unwrap();
|
||||||
|
for (i, rank) in ranks_below_king.iter().enumerate() {
|
||||||
|
pile.cards.push(Card {
|
||||||
|
id: (slot as u32) * 13 + i as u32,
|
||||||
|
suit: *suit,
|
||||||
|
rank: *rank,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tableau 0..3: one King each, face-up.
|
||||||
|
for (col, suit) in suit_for_slot.iter().enumerate() {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Tableau(col))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.push(Card {
|
||||||
|
id: 100 + col as u32,
|
||||||
|
suit: *suit,
|
||||||
|
rank: Rank::King,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
game
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_solve_with_first_move_returns_some_move_for_winnable_state() {
|
||||||
|
let game = near_finished_game_state();
|
||||||
|
let cfg = SolverConfig::default();
|
||||||
|
let outcome = try_solve_from_state(&game, &cfg);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.result,
|
||||||
|
SolverResult::Winnable,
|
||||||
|
"near-finished state must solve as Winnable"
|
||||||
|
);
|
||||||
|
let mv = outcome.first_move.expect("Winnable must include a first_move");
|
||||||
|
// The first move must be a King going from a tableau column to
|
||||||
|
// its matching foundation slot. Single-card move.
|
||||||
|
assert_eq!(mv.count, 1);
|
||||||
|
assert!(matches!(mv.source, PileType::Tableau(c) if c < 4));
|
||||||
|
assert!(matches!(mv.dest, PileType::Foundation(s) if s < 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_solve_with_first_move_returns_none_for_unwinnable_state() {
|
||||||
|
use crate::card::Rank;
|
||||||
|
// The "buried Ace under same-suit Two with no recovery" fixture
|
||||||
|
// used by `solver_recognises_obviously_unwinnable_deal`, lifted
|
||||||
|
// into a real `GameState` so we can exercise `try_solve_from_state`.
|
||||||
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Foundation(slot))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
for i in 0..7_usize {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Tableau(i))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
// Tableau 0: A♠ on the bottom, 2♠ on top — the 2♠ has no legal
|
||||||
|
// destination, so the Ace is buried forever.
|
||||||
|
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||||
|
t0.cards.push(Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
|
||||||
|
t0.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true });
|
||||||
|
// Tableau 1: a face-up King with nothing else — irrelevant; the
|
||||||
|
// pruning check elides "King → empty" no-ops.
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Tableau(1))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.push(Card { id: 2, suit: Suit::Clubs, rank: Rank::King, face_up: true });
|
||||||
|
|
||||||
|
let cfg = SolverConfig::default();
|
||||||
|
let outcome = try_solve_from_state(&game, &cfg);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.result,
|
||||||
|
SolverResult::Unwinnable,
|
||||||
|
"buried-Ace fixture must be proved Unwinnable"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
outcome.first_move.is_none(),
|
||||||
|
"Unwinnable verdict must carry first_move == None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_solve_with_first_move_is_deterministic() {
|
||||||
|
// Same state run multiple times yields the same first_move.
|
||||||
|
let game = near_finished_game_state();
|
||||||
|
let cfg = SolverConfig::default();
|
||||||
|
let a = try_solve_from_state(&game, &cfg);
|
||||||
|
let b = try_solve_from_state(&game, &cfg);
|
||||||
|
let c = try_solve_from_state(&game, &cfg);
|
||||||
|
assert_eq!(a, b, "repeat solves must yield the same outcome");
|
||||||
|
assert_eq!(b, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_solve_with_first_move_seed_form_matches_state_form() {
|
||||||
|
// For a fresh seed, the two public entry points must agree —
|
||||||
|
// they share the same internal `solve()` implementation, but
|
||||||
|
// route through different state constructors. This is the
|
||||||
|
// smoke test that catches drift between them.
|
||||||
|
let cfg = SolverConfig {
|
||||||
|
move_budget: 5_000,
|
||||||
|
state_budget: 5_000,
|
||||||
|
};
|
||||||
|
let a = try_solve_with_first_move(7, DrawMode::DrawOne, &cfg);
|
||||||
|
let game = GameState::new(7, DrawMode::DrawOne);
|
||||||
|
let b = try_solve_from_state(&game, &cfg);
|
||||||
|
assert_eq!(a.result, b.result, "verdicts must match across the two entry points");
|
||||||
|
assert_eq!(a.first_move, b.first_move, "first_move must match across the two entry points");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||||
Theme, WindowGeometry, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
|
Theme, WindowGeometry, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
|
||||||
|
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
|
||||||
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
|
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
|
||||||
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -181,6 +181,17 @@ pub struct Settings {
|
|||||||
/// solver retry loop — see `solitaire_engine::handle_new_game`.
|
/// solver retry loop — see `solitaire_engine::handle_new_game`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub winnable_deals_only: bool,
|
pub winnable_deals_only: bool,
|
||||||
|
/// Per-move duration during replay playback, in seconds. Range
|
||||||
|
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`;
|
||||||
|
/// default mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||||
|
/// (0.45 s/move) so existing playback behaviour is unchanged for
|
||||||
|
/// players who never touch the slider. Smaller values scrub
|
||||||
|
/// faster through the recorded move list. Older `settings.json`
|
||||||
|
/// files written before this field existed deserialize cleanly to
|
||||||
|
/// the default via
|
||||||
|
/// `#[serde(default = "default_replay_move_interval_secs")]`.
|
||||||
|
#[serde(default = "default_replay_move_interval_secs")]
|
||||||
|
pub replay_move_interval_secs: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawMode {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -238,6 +249,33 @@ fn default_time_bonus_multiplier() -> f32 {
|
|||||||
1.0
|
1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default per-move duration during replay playback, in seconds.
|
||||||
|
/// Mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||||
|
/// so legacy `settings.json` files load to the existing baseline and
|
||||||
|
/// playback feels identical for players who never touch the slider.
|
||||||
|
/// The constant is duplicated across the data and engine crates
|
||||||
|
/// because `solitaire_data` cannot depend on the engine crate — keep
|
||||||
|
/// the two values in sync when adjusting either.
|
||||||
|
fn default_replay_move_interval_secs() -> f32 {
|
||||||
|
0.45
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower bound of the player-tunable replay-playback per-move interval,
|
||||||
|
/// in seconds. Below this the cards barely register visually before
|
||||||
|
/// the next move fires; the cap keeps the playback legible.
|
||||||
|
pub const REPLAY_MOVE_INTERVAL_MIN_SECS: f32 = 0.10;
|
||||||
|
|
||||||
|
/// Upper bound of the player-tunable replay-playback per-move interval,
|
||||||
|
/// in seconds. One second per move is a comfortable upper limit for
|
||||||
|
/// players who want to study a recorded game frame by frame.
|
||||||
|
pub const REPLAY_MOVE_INTERVAL_MAX_SECS: f32 = 1.00;
|
||||||
|
|
||||||
|
/// Increment applied by the replay-playback decrement / increment
|
||||||
|
/// buttons. 0.05 s gives 19 stops between MIN and MAX — fine-grained
|
||||||
|
/// enough to land on any "round" speed (0.10 s, 0.25 s, 0.45 s, etc.)
|
||||||
|
/// without making the slider feel stuck on the same value.
|
||||||
|
pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
|
||||||
|
|
||||||
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
||||||
/// is willing to attempt before giving up and accepting the latest
|
/// is willing to attempt before giving up and accepting the latest
|
||||||
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
||||||
@@ -268,14 +306,16 @@ impl Default for Settings {
|
|||||||
tooltip_delay_secs: default_tooltip_delay(),
|
tooltip_delay_secs: default_tooltip_delay(),
|
||||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||||
winnable_deals_only: false,
|
winnable_deals_only: false,
|
||||||
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`, and
|
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`,
|
||||||
/// `time_bonus_multiplier` into their respective ranges after
|
/// `time_bonus_multiplier`, and `replay_move_interval_secs` into
|
||||||
/// deserialization or hand-editing of `settings.json`.
|
/// their respective ranges after deserialization or hand-editing of
|
||||||
|
/// `settings.json`.
|
||||||
pub fn sanitized(self) -> Self {
|
pub fn sanitized(self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
||||||
@@ -286,6 +326,9 @@ impl Settings {
|
|||||||
time_bonus_multiplier: self
|
time_bonus_multiplier: self
|
||||||
.time_bonus_multiplier
|
.time_bonus_multiplier
|
||||||
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX),
|
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX),
|
||||||
|
replay_move_interval_secs: self
|
||||||
|
.replay_move_interval_secs
|
||||||
|
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
||||||
..self
|
..self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,6 +367,21 @@ impl Settings {
|
|||||||
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
|
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
|
||||||
self.time_bonus_multiplier
|
self.time_bonus_multiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adjust the replay-playback per-move interval by `delta`
|
||||||
|
/// seconds, clamped to
|
||||||
|
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`.
|
||||||
|
/// The result is rounded to two decimal places so the readout
|
||||||
|
/// stays clean across repeated `±` clicks at the 0.05 s step
|
||||||
|
/// (avoids float drift like `0.45000003`). Returns the new value.
|
||||||
|
pub fn adjust_replay_move_interval(&mut self, delta: f32) -> f32 {
|
||||||
|
let raw = (self.replay_move_interval_secs + delta)
|
||||||
|
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS);
|
||||||
|
// Round to 2 decimal places — the slider step is 0.05, so this
|
||||||
|
// collapses any FP drift introduced by repeated additions.
|
||||||
|
self.replay_move_interval_secs = (raw * 100.0).round() / 100.0;
|
||||||
|
self.replay_move_interval_secs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||||
@@ -456,6 +514,7 @@ mod tests {
|
|||||||
tooltip_delay_secs: default_tooltip_delay(),
|
tooltip_delay_secs: default_tooltip_delay(),
|
||||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||||
winnable_deals_only: false,
|
winnable_deals_only: false,
|
||||||
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
};
|
};
|
||||||
save_settings_to(&path, &s).expect("save");
|
save_settings_to(&path, &s).expect("save");
|
||||||
let loaded = load_settings_from(&path);
|
let loaded = load_settings_from(&path);
|
||||||
@@ -908,4 +967,101 @@ mod tests {
|
|||||||
"legacy settings.json missing winnable_deals_only must deserialize to false"
|
"legacy settings.json missing winnable_deals_only must deserialize to false"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// replay_move_interval_secs — player-tunable replay playback speed
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_replay_move_interval_default_is_zero_point_four_five() {
|
||||||
|
// The pre-slider baseline is 0.45 s/move, matching
|
||||||
|
// `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`.
|
||||||
|
// The default must not regress for players who never touch
|
||||||
|
// the slider.
|
||||||
|
let s = Settings::default();
|
||||||
|
assert!(
|
||||||
|
(s.replay_move_interval_secs - 0.45).abs() < 1e-6,
|
||||||
|
"replay_move_interval_secs default must be 0.45 (the pre-slider baseline), got {}",
|
||||||
|
s.replay_move_interval_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_replay_move_interval_round_trip() {
|
||||||
|
let path = tmp_path("replay_move_interval_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let s = Settings {
|
||||||
|
replay_move_interval_secs: 0.20,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert!(
|
||||||
|
(loaded.replay_move_interval_secs - 0.20).abs() < 1e-6,
|
||||||
|
"replay_move_interval_secs must survive serde round-trip; got {}",
|
||||||
|
loaded.replay_move_interval_secs
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_settings_without_replay_move_interval_deserializes_to_default() {
|
||||||
|
// A settings.json written before this field existed must
|
||||||
|
// deserialize cleanly to the existing 0.45 s baseline so old
|
||||||
|
// players see no change to replay playback speed.
|
||||||
|
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
(s.replay_move_interval_secs - default_replay_move_interval_secs()).abs() < 1e-6,
|
||||||
|
"legacy settings.json missing replay_move_interval_secs must deserialize to default ({}), got {}",
|
||||||
|
default_replay_move_interval_secs(),
|
||||||
|
s.replay_move_interval_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_replay_move_interval_clamps_to_range() {
|
||||||
|
// Negative or oversized values from a hand-edited file must be
|
||||||
|
// clamped on load.
|
||||||
|
let s = Settings {
|
||||||
|
replay_move_interval_secs: 5.0,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(s.replay_move_interval_secs, REPLAY_MOVE_INTERVAL_MAX_SECS);
|
||||||
|
|
||||||
|
let s2 = Settings {
|
||||||
|
replay_move_interval_secs: -1.0,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(s2.replay_move_interval_secs, REPLAY_MOVE_INTERVAL_MIN_SECS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjust_replay_move_interval_clamps_and_rounds() {
|
||||||
|
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
|
||||||
|
// Step down to 0.40.
|
||||||
|
assert!((s.adjust_replay_move_interval(-0.05) - 0.40).abs() < 1e-6);
|
||||||
|
// Big positive jump clamps to MAX.
|
||||||
|
assert!(
|
||||||
|
(s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6
|
||||||
|
);
|
||||||
|
// Big negative jump clamps to MIN.
|
||||||
|
assert!(
|
||||||
|
(s.adjust_replay_move_interval(-99.0) - REPLAY_MOVE_INTERVAL_MIN_SECS).abs() < 1e-6
|
||||||
|
);
|
||||||
|
|
||||||
|
// Repeated 0.05 steps must not drift past the 0.05 grid.
|
||||||
|
let mut s2 = Settings { replay_move_interval_secs: 0.10, ..Default::default() };
|
||||||
|
for _ in 0..6 {
|
||||||
|
s2.adjust_replay_move_interval(0.05);
|
||||||
|
}
|
||||||
|
// After six +0.05 steps from 0.10, value should be exactly 0.40 (2 decimals).
|
||||||
|
assert!(
|
||||||
|
(s2.replay_move_interval_secs - 0.40).abs() < 1e-6,
|
||||||
|
"rounding should pin repeated 0.05 steps to the decimal grid, got {}",
|
||||||
|
s2.replay_move_interval_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ use crate::time_attack_plugin::TimeAttackResource;
|
|||||||
/// Z-depth used for cards while being dragged — above all resting cards.
|
/// Z-depth used for cards while being dragged — above all resting cards.
|
||||||
const DRAG_Z: f32 = 500.0;
|
const DRAG_Z: f32 = 500.0;
|
||||||
|
|
||||||
|
/// Solver budgets used by the H-key hint system.
|
||||||
|
///
|
||||||
|
/// Wraps `solitaire_core::solver::SolverConfig` as a Bevy resource so
|
||||||
|
/// tests can inject tighter budgets to exercise the heuristic-fallback
|
||||||
|
/// path. Production initialises this to `SolverConfig::default()` (100k
|
||||||
|
/// move / 200k state budgets, the same numbers the new-game retry loop
|
||||||
|
/// uses).
|
||||||
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
|
pub struct HintSolverConfig(pub solitaire_core::solver::SolverConfig);
|
||||||
|
|
||||||
/// Shared countdown state for the new-game double-press confirmation
|
/// Shared countdown state for the new-game double-press confirmation
|
||||||
/// flow.
|
/// flow.
|
||||||
///
|
///
|
||||||
@@ -89,6 +99,7 @@ pub struct InputPlugin;
|
|||||||
impl Plugin for InputPlugin {
|
impl Plugin for InputPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<HintCycleIndex>()
|
app.init_resource::<HintCycleIndex>()
|
||||||
|
.init_resource::<HintSolverConfig>()
|
||||||
.init_resource::<KeyboardConfirmState>()
|
.init_resource::<KeyboardConfirmState>()
|
||||||
.add_message::<NewGameConfirmEvent>()
|
.add_message::<NewGameConfirmEvent>()
|
||||||
.add_message::<StartZenRequestEvent>()
|
.add_message::<StartZenRequestEvent>()
|
||||||
@@ -236,20 +247,34 @@ fn handle_keyboard_core(
|
|||||||
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
|
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the H key: cycles through all available hints, highlighting the
|
/// Handles the H key: surface the solver's provably-best first move when
|
||||||
/// source card yellow for 2 s and showing a descriptive toast.
|
/// the position is winnable; otherwise fall back to cycling through the
|
||||||
|
/// heuristic hints.
|
||||||
///
|
///
|
||||||
/// The hint index wraps around once all hints have been cycled through. When no
|
/// The solver (`solitaire_core::solver::try_solve_from_state`) is run
|
||||||
/// moves are available a "No hints available" toast is shown instead.
|
/// synchronously on each H press — median ~2 ms on real positions, with a
|
||||||
|
/// hard cap from `SolverConfig::default()`'s budgets. When the verdict is
|
||||||
|
/// `Winnable`, the returned `first_move` is shown as a single, stable hint
|
||||||
|
/// (no cycling — the optimal move doesn't change between identical
|
||||||
|
/// presses). When the verdict is `Unwinnable` or `Inconclusive`, the
|
||||||
|
/// handler falls back to the legacy heuristic in `all_hints`, which still
|
||||||
|
/// cycles through every legal move.
|
||||||
|
///
|
||||||
|
/// When no moves are available a "No hints available" toast is shown
|
||||||
|
/// instead. The H key always produces a hint when any legal move exists.
|
||||||
|
///
|
||||||
|
/// TODO: if profiling ever shows >100 ms solver calls in practice, move
|
||||||
|
/// the solver call to `AsyncComputeTaskPool` to keep input latency low.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_keyboard_hint(
|
fn handle_keyboard_hint(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
solver_config: Res<HintSolverConfig>,
|
||||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
mut hint_visual: MessageWriter<HintVisualEvent>,
|
mut hint_visual: MessageWriter<HintVisualEvent>,
|
||||||
) {
|
) {
|
||||||
@@ -269,6 +294,25 @@ fn handle_keyboard_hint(
|
|||||||
|
|
||||||
let Some(_layout_res) = layout else { return };
|
let Some(_layout_res) = layout else { return };
|
||||||
|
|
||||||
|
// First pass: ask the solver for the provably-best move. The
|
||||||
|
// solver is deterministic, so repeated H presses on the same
|
||||||
|
// position keep showing the same hint (cycling is reserved for
|
||||||
|
// the heuristic fallback path).
|
||||||
|
use solitaire_core::solver::{try_solve_from_state, SolverResult};
|
||||||
|
let outcome = try_solve_from_state(&g.0, &solver_config.0);
|
||||||
|
if outcome.result == SolverResult::Winnable
|
||||||
|
&& let Some(mv) = outcome.first_move
|
||||||
|
{
|
||||||
|
let from = mv.source.clone();
|
||||||
|
let to = mv.dest.clone();
|
||||||
|
emit_hint_visuals(&g.0, &from, &to, &mut commands, card_entities, &mut info_toast, &mut hint_visual);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: heuristic cycling hint. Used when the solver verdict
|
||||||
|
// is `Unwinnable` (no legal winning path — but a legal *move* may
|
||||||
|
// still exist, e.g. drawing from stock) or `Inconclusive` (budget
|
||||||
|
// exhausted on a complex mid-game position).
|
||||||
let hints = all_hints(&g.0);
|
let hints = all_hints(&g.0);
|
||||||
if hints.is_empty() {
|
if hints.is_empty() {
|
||||||
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
||||||
@@ -278,14 +322,29 @@ fn handle_keyboard_hint(
|
|||||||
// Pick the hint at the current cycle index (wrapping) and advance.
|
// Pick the hint at the current cycle index (wrapping) and advance.
|
||||||
let idx = hint_cycle.0 % hints.len();
|
let idx = hint_cycle.0 % hints.len();
|
||||||
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
|
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
|
||||||
let (from, to, _count) = &hints[idx];
|
let (from, to, _count) = hints[idx].clone();
|
||||||
|
emit_hint_visuals(&g.0, &from, &to, &mut commands, card_entities, &mut info_toast, &mut hint_visual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the visual + toast effects for a single chosen hint move.
|
||||||
|
///
|
||||||
|
/// Shared between the solver-driven and heuristic-driven hint paths so
|
||||||
|
/// both produce identical player-facing feedback.
|
||||||
|
fn emit_hint_visuals(
|
||||||
|
game: &GameState,
|
||||||
|
from: &PileType,
|
||||||
|
to: &PileType,
|
||||||
|
commands: &mut Commands,
|
||||||
|
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||||
|
info_toast: &mut MessageWriter<InfoToastEvent>,
|
||||||
|
hint_visual: &mut MessageWriter<HintVisualEvent>,
|
||||||
|
) {
|
||||||
// When the hint points at the stock (draw suggestion) there is no
|
// When the hint points at the stock (draw suggestion) there is no
|
||||||
// face-up card to highlight — show a toast instead.
|
// face-up card to highlight — show a toast instead.
|
||||||
// If the stock is empty, pressing D will recycle the waste rather
|
// If the stock is empty, pressing D will recycle the waste rather
|
||||||
// than draw a card, so the toast text must reflect that.
|
// than draw a card, so the toast text must reflect that.
|
||||||
if *from == PileType::Stock {
|
if *from == PileType::Stock {
|
||||||
let stock_empty = g.0.piles
|
let stock_empty = game.piles
|
||||||
.get(&PileType::Stock)
|
.get(&PileType::Stock)
|
||||||
.is_some_and(|p| p.cards.is_empty());
|
.is_some_and(|p| p.cards.is_empty());
|
||||||
let msg = if stock_empty {
|
let msg = if stock_empty {
|
||||||
@@ -298,7 +357,7 @@ fn handle_keyboard_hint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the top face-up card in the source pile and highlight it.
|
// Find the top face-up card in the source pile and highlight it.
|
||||||
let top_card_id = g.0.piles.get(from)
|
let top_card_id = game.piles.get(from)
|
||||||
.and_then(|p| p.cards.last().filter(|c| c.face_up))
|
.and_then(|p| p.cards.last().filter(|c| c.face_up))
|
||||||
.map(|c| c.id);
|
.map(|c| c.id);
|
||||||
if let Some(card_id) = top_card_id {
|
if let Some(card_id) = top_card_id {
|
||||||
@@ -327,7 +386,7 @@ fn handle_keyboard_hint(
|
|||||||
// player keeps thinking in suit terms; otherwise fall back to "foundation".
|
// player keeps thinking in suit terms; otherwise fall back to "foundation".
|
||||||
let msg = match to {
|
let msg = match to {
|
||||||
PileType::Foundation(_) => {
|
PileType::Foundation(_) => {
|
||||||
let claimed = g.0.piles.get(to).and_then(|p| p.claimed_suit());
|
let claimed = game.piles.get(to).and_then(|p| p.claimed_suit());
|
||||||
if let Some(suit) = claimed {
|
if let Some(suit) = claimed {
|
||||||
let suit_name = match suit {
|
let suit_name = match suit {
|
||||||
Suit::Clubs => "Clubs",
|
Suit::Clubs => "Clubs",
|
||||||
@@ -2125,5 +2184,194 @@ mod tests {
|
|||||||
anim.end_z
|
anim.end_z
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Hint system — solver promotion (v0.16.0+)
|
||||||
|
//
|
||||||
|
// The H-key hint is now backed by `solitaire_core::solver::try_solve_from_state`.
|
||||||
|
// When the solver proves the position winnable, the hint is the
|
||||||
|
// first move on the solver's solution path. When the solver returns
|
||||||
|
// Inconclusive (budget exhausted) or Unwinnable, the legacy
|
||||||
|
// heuristic in `all_hints` supplies the hint instead so the H key
|
||||||
|
// always produces feedback while any legal move exists.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Build a minimal Bevy app that registers only the resources and
|
||||||
|
/// messages needed to drive `handle_keyboard_hint` end-to-end.
|
||||||
|
/// Skips every other input system — the test only exercises the hint
|
||||||
|
/// path and we want the assertions to be unaffected by other handlers.
|
||||||
|
fn hint_test_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<InfoToastEvent>();
|
||||||
|
app.add_message::<HintVisualEvent>();
|
||||||
|
app.init_resource::<HintCycleIndex>();
|
||||||
|
app.init_resource::<HintSolverConfig>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
// Layout: a fixed 1280x800 layout — `handle_keyboard_hint` only
|
||||||
|
// checks the resource is present, never reads coordinates.
|
||||||
|
app.insert_resource(crate::layout::LayoutResource(
|
||||||
|
crate::layout::compute_layout(Vec2::new(1280.0, 800.0)),
|
||||||
|
));
|
||||||
|
app.add_systems(Update, handle_keyboard_hint);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: simulate "the player just pressed H this frame".
|
||||||
|
fn press_h(app: &mut App) {
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(KeyCode::KeyH);
|
||||||
|
input.clear();
|
||||||
|
input.press(KeyCode::KeyH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a near-finished `GameState`: foundations hold A..Q for each
|
||||||
|
/// suit, four Kings sit on tableau columns 0..3, stock and waste
|
||||||
|
/// empty. Solver-side equivalent of the `near_finished_game_state`
|
||||||
|
/// helper in `solitaire_core::solver::tests`.
|
||||||
|
fn near_finished_game_state() -> GameState {
|
||||||
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Foundation(slot))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
for i in 0..7_usize {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Tableau(i))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
let suit_for_slot = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
|
let ranks_below_king = [
|
||||||
|
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||||
|
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||||||
|
Rank::Jack, Rank::Queen,
|
||||||
|
];
|
||||||
|
for (slot, suit) in suit_for_slot.iter().enumerate() {
|
||||||
|
let pile = game
|
||||||
|
.piles
|
||||||
|
.get_mut(&PileType::Foundation(slot as u8))
|
||||||
|
.unwrap();
|
||||||
|
for (i, rank) in ranks_below_king.iter().enumerate() {
|
||||||
|
pile.cards.push(Card {
|
||||||
|
id: (slot as u32) * 13 + i as u32,
|
||||||
|
suit: *suit,
|
||||||
|
rank: *rank,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (col, suit) in suit_for_slot.iter().enumerate() {
|
||||||
|
game.piles
|
||||||
|
.get_mut(&PileType::Tableau(col))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.push(Card {
|
||||||
|
id: 100 + col as u32,
|
||||||
|
suit: *suit,
|
||||||
|
rank: Rank::King,
|
||||||
|
face_up: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
game
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the solver verdict is Winnable, the hint must come from the
|
||||||
|
/// solver: in our near-finished fixture, four Tableau→Foundation
|
||||||
|
/// moves are legal and the solver returns one of them. The
|
||||||
|
/// `HintVisualEvent` source card must be one of the four Kings and
|
||||||
|
/// the destination must be a foundation slot.
|
||||||
|
#[test]
|
||||||
|
fn hint_uses_solver_when_winnable() {
|
||||||
|
use solitaire_core::card::Rank;
|
||||||
|
let mut app = hint_test_app();
|
||||||
|
let game = near_finished_game_state();
|
||||||
|
// Track the 4 King ids so we can assert the hint source matches.
|
||||||
|
let king_ids: Vec<u32> = (0..4_u8)
|
||||||
|
.map(|c| {
|
||||||
|
game.piles
|
||||||
|
.get(&PileType::Tableau(c as usize))
|
||||||
|
.unwrap()
|
||||||
|
.cards
|
||||||
|
.last()
|
||||||
|
.filter(|c| c.rank == Rank::King)
|
||||||
|
.map(|c| c.id)
|
||||||
|
.expect("each tableau col 0..3 has a King on top")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
app.insert_resource(GameStateResource(game));
|
||||||
|
press_h(&mut app);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Read out the messages via the standard cursor API.
|
||||||
|
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
||||||
|
let mut cursor = messages.get_cursor();
|
||||||
|
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
|
||||||
|
assert_eq!(
|
||||||
|
collected.len(), 1,
|
||||||
|
"exactly one HintVisualEvent must fire on a winnable solver verdict"
|
||||||
|
);
|
||||||
|
let event = &collected[0];
|
||||||
|
assert!(
|
||||||
|
king_ids.contains(&event.source_card_id),
|
||||||
|
"solver hint must point at one of the four Kings; got id {}",
|
||||||
|
event.source_card_id
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(event.dest_pile, PileType::Foundation(_)),
|
||||||
|
"solver hint destination must be a foundation slot; got {:?}",
|
||||||
|
event.dest_pile
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the solver returns Inconclusive (e.g. tight budgets force an
|
||||||
|
/// early bail), the heuristic fallback must still produce a hint
|
||||||
|
/// event so the H key never feels broken.
|
||||||
|
///
|
||||||
|
/// We force the solver inconclusive by setting both budgets to 0 —
|
||||||
|
/// the search bails on the very first iteration, returning
|
||||||
|
/// `SolverResult::Inconclusive`. The heuristic fallback then runs on
|
||||||
|
/// the fresh deal and finds at least one legal move.
|
||||||
|
#[test]
|
||||||
|
fn hint_falls_back_to_heuristic_when_solver_inconclusive() {
|
||||||
|
use solitaire_core::solver::SolverConfig;
|
||||||
|
let mut app = hint_test_app();
|
||||||
|
// Force solver to bail before exploring anything.
|
||||||
|
app.insert_resource(HintSolverConfig(SolverConfig {
|
||||||
|
move_budget: 0,
|
||||||
|
state_budget: 0,
|
||||||
|
}));
|
||||||
|
// A fresh seeded deal — guaranteed to have at least one legal
|
||||||
|
// move (the standard Klondike opening always has draws available
|
||||||
|
// even if no immediate tableau move exists).
|
||||||
|
let game = GameState::new(42, DrawMode::DrawOne);
|
||||||
|
app.insert_resource(GameStateResource(game));
|
||||||
|
press_h(&mut app);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let world = app.world();
|
||||||
|
let visuals = world.resource::<Messages<HintVisualEvent>>();
|
||||||
|
let mut visual_cursor = visuals.get_cursor();
|
||||||
|
let collected: Vec<HintVisualEvent> = visual_cursor.read(visuals).cloned().collect();
|
||||||
|
// Either a card-move hint (most fresh deals) or a draw suggestion.
|
||||||
|
// A draw suggestion fires no `HintVisualEvent` (only an
|
||||||
|
// `InfoToastEvent`), so we accept zero-or-one HintVisualEvent so
|
||||||
|
// long as at least one feedback signal was emitted overall.
|
||||||
|
let toasts = world.resource::<Messages<InfoToastEvent>>();
|
||||||
|
let mut toast_cursor = toasts.get_cursor();
|
||||||
|
let toast_count = toast_cursor.read(toasts).count();
|
||||||
|
assert!(
|
||||||
|
!collected.is_empty() || toast_count > 0,
|
||||||
|
"heuristic fallback must produce a hint signal (visual or toast)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,11 +45,34 @@ use solitaire_data::{Replay, ReplayMove};
|
|||||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
|
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::{GameMutation, RecordingReplay};
|
use crate::game_plugin::{GameMutation, RecordingReplay};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
|
||||||
/// Per-move duration during playback. Tunable in Settings later;
|
/// Default per-move duration during playback, in seconds. Acts as the
|
||||||
/// hardcoded for v1.
|
/// fallback when `SettingsResource` is absent — i.e. in headless test
|
||||||
|
/// fixtures that don't install [`crate::settings_plugin::SettingsPlugin`].
|
||||||
|
/// In production the live value is read from
|
||||||
|
/// [`solitaire_data::Settings::replay_move_interval_secs`] every frame
|
||||||
|
/// so Settings adjustments take effect on the next playback tick.
|
||||||
|
///
|
||||||
|
/// Kept in sync with `solitaire_data::settings::default_replay_move_interval_secs`
|
||||||
|
/// (the data crate cannot depend on this engine crate, so the constant
|
||||||
|
/// is duplicated). The
|
||||||
|
/// `settings_replay_move_interval_default_matches_engine_constant`
|
||||||
|
/// test in `solitaire_engine::settings_plugin` enforces equality.
|
||||||
pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45;
|
pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45;
|
||||||
|
|
||||||
|
/// Helper: returns the live per-move replay interval. Reads
|
||||||
|
/// [`SettingsResource::replay_move_interval_secs`] when the resource is
|
||||||
|
/// installed, falling back to [`REPLAY_MOVE_INTERVAL_SECS`] otherwise.
|
||||||
|
/// Also clamps below by `f32::EPSILON` so a hand-edited 0.0 cannot
|
||||||
|
/// busy-loop the playback tick.
|
||||||
|
fn current_move_interval_secs(settings: Option<&SettingsResource>) -> f32 {
|
||||||
|
let raw = settings
|
||||||
|
.map(|s| s.0.replay_move_interval_secs)
|
||||||
|
.unwrap_or(REPLAY_MOVE_INTERVAL_SECS);
|
||||||
|
raw.max(f32::EPSILON)
|
||||||
|
}
|
||||||
|
|
||||||
/// How long the [`ReplayPlaybackState::Completed`] state lingers before
|
/// How long the [`ReplayPlaybackState::Completed`] state lingers before
|
||||||
/// the auto-clear system transitions it back to
|
/// the auto-clear system transitions it back to
|
||||||
/// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to
|
/// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to
|
||||||
@@ -161,6 +184,12 @@ pub fn start_replay_playback(
|
|||||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
||||||
commands.insert_resource(GameStateResource(fresh));
|
commands.insert_resource(GameStateResource(fresh));
|
||||||
|
|
||||||
|
// Initial `secs_to_next` uses the constant rather than reading
|
||||||
|
// `SettingsResource` because this entry point takes `Commands` /
|
||||||
|
// `ResMut<ReplayPlaybackState>` only. The first-tick latency may
|
||||||
|
// therefore lag the configured interval by up to ~0.45 s on an
|
||||||
|
// unusually short setting; subsequent ticks read the live setting
|
||||||
|
// every frame via [`tick_replay_playback`].
|
||||||
**state = ReplayPlaybackState::Playing {
|
**state = ReplayPlaybackState::Playing {
|
||||||
replay,
|
replay,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
@@ -207,11 +236,13 @@ pub fn stop_replay_playback(
|
|||||||
/// so the loop runs at most once per frame.
|
/// so the loop runs at most once per frame.
|
||||||
fn tick_replay_playback(
|
fn tick_replay_playback(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
mut state: ResMut<ReplayPlaybackState>,
|
mut state: ResMut<ReplayPlaybackState>,
|
||||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||||
) {
|
) {
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
|
let interval = current_move_interval_secs(settings.as_deref());
|
||||||
let mut transition_to_completed = false;
|
let mut transition_to_completed = false;
|
||||||
|
|
||||||
if let ReplayPlaybackState::Playing {
|
if let ReplayPlaybackState::Playing {
|
||||||
@@ -235,7 +266,7 @@ fn tick_replay_playback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
*cursor += 1;
|
*cursor += 1;
|
||||||
*secs_to_next += REPLAY_MOVE_INTERVAL_SECS;
|
*secs_to_next += interval;
|
||||||
}
|
}
|
||||||
|
|
||||||
if *cursor >= replay.moves.len() {
|
if *cursor >= replay.moves.len() {
|
||||||
@@ -679,4 +710,124 @@ mod tests {
|
|||||||
"recording must not grow while playback is active",
|
"recording must not grow while playback is active",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// With `SettingsResource::replay_move_interval_secs` set to 0.10 s
|
||||||
|
/// (well below the 0.45 s default), playback over a fixed
|
||||||
|
/// wall-clock window must dispatch strictly more moves than the
|
||||||
|
/// same fixture would at the 0.45 s default. This is the
|
||||||
|
/// regression check that the tick reads from the live Settings
|
||||||
|
/// value rather than the hardcoded
|
||||||
|
/// [`REPLAY_MOVE_INTERVAL_SECS`] constant.
|
||||||
|
///
|
||||||
|
/// The follow-up assertion exercises the boundary condition: at
|
||||||
|
/// the 0.10 s/move setting, exactly six 0.10 s ticks must yield
|
||||||
|
/// fewer moves than six 0.20 s ticks (because the latter doubles
|
||||||
|
/// the per-update advance and pays off two intervals each tick).
|
||||||
|
#[test]
|
||||||
|
fn replay_playback_tick_uses_settings_interval() {
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct CapturedDraws(usize);
|
||||||
|
|
||||||
|
fn collect_draws(
|
||||||
|
mut events: MessageReader<DrawRequestEvent>,
|
||||||
|
mut sink: ResMut<CapturedDraws>,
|
||||||
|
) {
|
||||||
|
for _ in events.read() {
|
||||||
|
sink.0 += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long replay so the fast cadence has plenty of moves to
|
||||||
|
// chew through and the 0.45 s vs 0.10 s difference is easy
|
||||||
|
// to observe.
|
||||||
|
fn ten_draws_replay() -> Replay {
|
||||||
|
Replay::new(
|
||||||
|
7,
|
||||||
|
DrawMode::DrawOne,
|
||||||
|
GameMode::Classic,
|
||||||
|
10,
|
||||||
|
100,
|
||||||
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
|
vec![ReplayMove::StockClick; 10],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Run 1: 0.10 s/move (Settings override) ----
|
||||||
|
let mut fast_app = headless_app();
|
||||||
|
fast_app.insert_resource(SettingsResource(Settings {
|
||||||
|
replay_move_interval_secs: 0.10,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
fast_app
|
||||||
|
.init_resource::<CapturedDraws>()
|
||||||
|
.add_systems(Update, collect_draws);
|
||||||
|
|
||||||
|
start_playback(&mut fast_app, ten_draws_replay());
|
||||||
|
fast_app.update();
|
||||||
|
// 1.0 s of virtual time at 0.10 s/move dispatches ~5 moves
|
||||||
|
// after the default 0.45 s startup interval is consumed.
|
||||||
|
advance_by(&mut fast_app, 1.0);
|
||||||
|
let fast_count = fast_app.world().resource::<CapturedDraws>().0;
|
||||||
|
|
||||||
|
// ---- Run 2: 0.45 s/move (default — no SettingsResource) ----
|
||||||
|
let mut slow_app = headless_app();
|
||||||
|
// `tick_replay_playback` falls back to `REPLAY_MOVE_INTERVAL_SECS`
|
||||||
|
// (0.45 s) when `SettingsResource` is absent.
|
||||||
|
slow_app
|
||||||
|
.init_resource::<CapturedDraws>()
|
||||||
|
.add_systems(Update, collect_draws);
|
||||||
|
|
||||||
|
start_playback(&mut slow_app, ten_draws_replay());
|
||||||
|
slow_app.update();
|
||||||
|
advance_by(&mut slow_app, 1.0);
|
||||||
|
let slow_count = slow_app.world().resource::<CapturedDraws>().0;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
fast_count > slow_count,
|
||||||
|
"at 0.10 s/move the tick must dispatch strictly more moves \
|
||||||
|
than at the 0.45 s default over the same wall-clock window: \
|
||||||
|
fast={fast_count}, slow={slow_count}",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Boundary: a 0.05 s/tick cadence over the same window
|
||||||
|
// dispatches NO MORE moves than a 0.10 s/tick cadence, because
|
||||||
|
// 0.05 s < 0.10 s configured interval — the secs_to_next clock
|
||||||
|
// never crosses the threshold inside a single tick. ----
|
||||||
|
//
|
||||||
|
// We don't assert "exactly zero" because the leading update()
|
||||||
|
// after `start_playback` may run before the strategy is
|
||||||
|
// applied (cf. comments on `tick_advances_cursor_after_interval`),
|
||||||
|
// but the count must not exceed what we'd get with one-tick
|
||||||
|
// advances at the same total wall-clock window.
|
||||||
|
fn count_after_window(interval_secs: f32, tick_secs: f32, total_secs: f32) -> usize {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(SettingsResource(Settings {
|
||||||
|
replay_move_interval_secs: interval_secs,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
app.init_resource::<CapturedDraws>()
|
||||||
|
.add_systems(Update, collect_draws);
|
||||||
|
start_playback(&mut app, ten_draws_replay());
|
||||||
|
app.update();
|
||||||
|
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||||||
|
Duration::from_secs_f32(tick_secs),
|
||||||
|
));
|
||||||
|
let ticks = (total_secs / tick_secs).ceil() as usize + 1;
|
||||||
|
for _ in 0..ticks {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
app.world().resource::<CapturedDraws>().0
|
||||||
|
}
|
||||||
|
|
||||||
|
let count_at_05 = count_after_window(0.10, 0.05, 1.0);
|
||||||
|
let count_at_20 = count_after_window(0.10, 0.20, 1.0);
|
||||||
|
assert!(
|
||||||
|
count_at_05 <= count_at_20,
|
||||||
|
"0.05 s ticks (strictly less than the 0.10 s interval) must \
|
||||||
|
dispatch no more moves than 0.20 s ticks over the same \
|
||||||
|
wall-clock window: count_at_05={count_at_05}, count_at_20={count_at_20}",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ use bevy::window::{WindowMoved, WindowResized};
|
|||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
|
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
|
||||||
WindowGeometry, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS,
|
WindowGeometry, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP,
|
||||||
|
TOOLTIP_DELAY_STEP_SECS,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||||||
@@ -132,6 +133,12 @@ struct TooltipDelayText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct TimeBonusMultiplierText;
|
struct TimeBonusMultiplierText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the live replay-playback per-move
|
||||||
|
/// interval value. The Gameplay-section row beside this label lets the
|
||||||
|
/// player tune `Settings::replay_move_interval_secs`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ReplayMoveIntervalText;
|
||||||
|
|
||||||
/// Marks the `Text` node showing the current "Winnable deals only"
|
/// Marks the `Text` node showing the current "Winnable deals only"
|
||||||
/// state ("ON" / "OFF") in the Gameplay section.
|
/// state ("ON" / "OFF") in the Gameplay section.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
@@ -179,6 +186,12 @@ enum SettingsButton {
|
|||||||
TimeBonusDown,
|
TimeBonusDown,
|
||||||
/// Increment the cosmetic time-bonus multiplier by one step.
|
/// Increment the cosmetic time-bonus multiplier by one step.
|
||||||
TimeBonusUp,
|
TimeBonusUp,
|
||||||
|
/// Decrement the replay-playback per-move interval by one step
|
||||||
|
/// (i.e. speed playback up).
|
||||||
|
ReplayMoveIntervalDown,
|
||||||
|
/// Increment the replay-playback per-move interval by one step
|
||||||
|
/// (i.e. slow playback down).
|
||||||
|
ReplayMoveIntervalUp,
|
||||||
ToggleTheme,
|
ToggleTheme,
|
||||||
ToggleColorBlind,
|
ToggleColorBlind,
|
||||||
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
||||||
@@ -219,8 +232,12 @@ impl SettingsButton {
|
|||||||
SettingsButton::TooltipDelayUp => 46,
|
SettingsButton::TooltipDelayUp => 46,
|
||||||
SettingsButton::TimeBonusDown => 47,
|
SettingsButton::TimeBonusDown => 47,
|
||||||
SettingsButton::TimeBonusUp => 48,
|
SettingsButton::TimeBonusUp => 48,
|
||||||
|
// Replay-speed slider — last Gameplay-section row, so it
|
||||||
|
// sits between TimeBonusUp (48) and the Cosmetic section.
|
||||||
|
SettingsButton::ReplayMoveIntervalDown => 49,
|
||||||
|
SettingsButton::ReplayMoveIntervalUp => 49,
|
||||||
// Cosmetic section
|
// Cosmetic section
|
||||||
SettingsButton::ToggleTheme => 50,
|
SettingsButton::ToggleTheme => 55,
|
||||||
SettingsButton::ToggleColorBlind => 60,
|
SettingsButton::ToggleColorBlind => 60,
|
||||||
// Picker rows — every swatch in a row shares the row's
|
// Picker rows — every swatch in a row shares the row's
|
||||||
// priority so entity-index tiebreaking yields left → right.
|
// priority so entity-index tiebreaking yields left → right.
|
||||||
@@ -310,6 +327,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_color_blind_text,
|
update_color_blind_text,
|
||||||
update_tooltip_delay_text,
|
update_tooltip_delay_text,
|
||||||
update_time_bonus_multiplier_text,
|
update_time_bonus_multiplier_text,
|
||||||
|
update_replay_move_interval_text,
|
||||||
update_winnable_deals_only_text,
|
update_winnable_deals_only_text,
|
||||||
attach_focusable_to_settings_buttons,
|
attach_focusable_to_settings_buttons,
|
||||||
scroll_focus_into_view,
|
scroll_focus_into_view,
|
||||||
@@ -605,6 +623,21 @@ fn update_time_bonus_multiplier_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live replay-playback per-move-interval value in the
|
||||||
|
/// Gameplay section whenever `SettingsResource` changes (slider buttons,
|
||||||
|
/// hand-edited settings.json reload, etc.).
|
||||||
|
fn update_replay_move_interval_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<ReplayMoveIntervalText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = replay_move_interval_label(settings.0.replay_move_interval_secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn card_back_label(idx: usize) -> String {
|
fn card_back_label(idx: usize) -> String {
|
||||||
if idx == 0 {
|
if idx == 0 {
|
||||||
"Default".to_string()
|
"Default".to_string()
|
||||||
@@ -765,6 +798,29 @@ fn handle_settings_buttons(
|
|||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SettingsButton::ReplayMoveIntervalDown => {
|
||||||
|
let before = settings.0.replay_move_interval_secs;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_replay_move_interval(-REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_replay_move_interval_text` on the next
|
||||||
|
// frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ReplayMoveIntervalUp => {
|
||||||
|
let before = settings.0.replay_move_interval_secs;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_replay_move_interval(REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
SettingsButton::ToggleTheme => {
|
SettingsButton::ToggleTheme => {
|
||||||
settings.0.theme = match settings.0.theme {
|
settings.0.theme = match settings.0.theme {
|
||||||
Theme::Green => Theme::Blue,
|
Theme::Green => Theme::Blue,
|
||||||
@@ -876,6 +932,14 @@ fn time_bonus_label(value: f32) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats the replay-playback per-move interval for display in the
|
||||||
|
/// Settings panel. Mirrors [`tooltip_delay_label`] for parity — the
|
||||||
|
/// readout is `"{n:.2} s/move"` (e.g. `"0.45 s/move"`, `"0.10 s/move"`),
|
||||||
|
/// using two decimal places because the step is 0.05 s.
|
||||||
|
fn replay_move_interval_label(secs: f32) -> String {
|
||||||
|
format!("{secs:.2} s/move")
|
||||||
|
}
|
||||||
|
|
||||||
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
||||||
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
||||||
/// background pickers), and the "Sync Now" button. The "Done" button is
|
/// background pickers), and the "Sync Now" button. The "Done" button is
|
||||||
@@ -1228,6 +1292,11 @@ fn spawn_settings_panel(
|
|||||||
settings.time_bonus_multiplier,
|
settings.time_bonus_multiplier,
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
|
replay_move_interval_row(
|
||||||
|
body,
|
||||||
|
settings.replay_move_interval_secs,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
|
||||||
// --- Cosmetic ---
|
// --- Cosmetic ---
|
||||||
section_label(body, "Cosmetic", font_res);
|
section_label(body, "Cosmetic", font_res);
|
||||||
@@ -1462,6 +1531,56 @@ fn time_bonus_multiplier_row(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Replay speed 0.45 s/move [−] [+]` — slider row for the
|
||||||
|
/// player-tunable replay-playback per-move interval. Mirrors
|
||||||
|
/// [`tooltip_delay_row`] (label, current value, decrement, increment)
|
||||||
|
/// but formats the value via [`replay_move_interval_label`] as
|
||||||
|
/// `"{n:.2} s/move"`. The decrement button speeds playback up
|
||||||
|
/// (smaller interval); the increment slows it down — same direction
|
||||||
|
/// convention as the tooltip-delay slider.
|
||||||
|
fn replay_move_interval_row(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
value_secs: f32,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let label_font = label_text_font(font_res);
|
||||||
|
let value_font = value_text_font(font_res);
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new("Replay speed".to_string()),
|
||||||
|
label_font,
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
row.spawn((
|
||||||
|
ReplayMoveIntervalText,
|
||||||
|
Text::new(replay_move_interval_label(value_secs)),
|
||||||
|
value_font,
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
icon_button(
|
||||||
|
row,
|
||||||
|
"−",
|
||||||
|
SettingsButton::ReplayMoveIntervalDown,
|
||||||
|
"Speed up replay playback (shorter per-move interval).",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
icon_button(
|
||||||
|
row,
|
||||||
|
"+",
|
||||||
|
SettingsButton::ReplayMoveIntervalUp,
|
||||||
|
"Slow down replay playback (longer per-move interval).",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
|
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
|
||||||
/// anim speed, colour-blind).
|
/// anim speed, colour-blind).
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in New Issue
Block a user