Compare commits

...

5 Commits

Author SHA1 Message Date
funman300 27cdf78ce0 docs: cut v0.17.0 — solver-driven hints + replay-rate slider
Two follow-up commits on top of v0.16.0:
- 87275bf: H-key hint asks the v0.15.0 solver for the actual best
  first move, with the existing heuristic kept as fallback.
- 53e3b81: Settings → Gameplay slider tunes replay playback rate
  (0.10–1.00 s, default 0.45 s) read per frame from SettingsResource.

Adds the [0.17.0] CHANGELOG section, folds the post-v0.16.0
provisional table into a v0.17.0 shipped table in SESSION_HANDOFF,
prunes the now-stale "Cut v0.17.0" item from the punch list, and
re-letters the resume-prompt decision options A–D.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:11:08 +00:00
funman300 faa6c5efc4 docs: reconcile SESSION_HANDOFF with actually-shipped state
The post-v0.16.0 table marked the replay-rate slider as `(pending)`
but 53e3b81 already shipped it. Resume prompt said "HEAD at v0.16.0
/ 1196 tests" while the same doc above said HEAD was post-v0.16.0
with two follow-ups and 1208 tests.

Updates the slider row to reference 53e3b81, refreshes the resume
prompt's HEAD/test counts, and rewrites the "DECISION TO ASK THE
PLAYER FIRST" list — drops the smoke-test and "solver hints" bullets
(both already covered) and pulls forward the actual open items
(cut v0.17.0, solver-on-AsyncComputeTaskPool, won-previously,
replay sharing, packaging).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:05:03 +00:00
funman300 487b99bbc9 docs: SESSION_HANDOFF refresh — solver hints + replay slider, async deferred
Documents the two follow-ups landed on top of v0.16.0 (solver-driven
hints in 87275bf, replay-rate slider in this commit's parent) and
notes that an async-solver attempt was rolled back when a sub-agent
was interrupted leaving 3 failing tests. Async-solver is still
worth doing but needs smaller scoping next round.

Also records the process note raised this session: agent briefs had
been mandating ≥3 tests per feature, which produced low-value
coverage on trivial settings fields (Default trait arithmetic,
serde derive round-trips, stdlib clamp). Future briefs should ask
only for tests that pin behaviour contracts or regressions on real
bugs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:00:59 +00:00
funman300 53e3b816cf feat(settings,engine): replay-playback rate slider in Settings → Gameplay
The replay overlay's per-move tick rate has been hardcoded at
REPLAY_MOVE_INTERVAL_SECS = 0.45 s/move since the in-engine
playback shipped. Power users want to scrub faster through older
wins. Adds a Settings slider that tunes the interval 0.10–1.00 s in
0.05 s steps; default 0.45 s preserves existing feel.

Settings.replay_move_interval_secs uses #[serde(default)] so legacy
files load to 0.45. sanitized() clamps out-of-range values.
tick_replay_playback now reads SettingsResource per frame and falls
back to the constant when the resource is absent (test fixtures).
The slider takes effect on the very next playback tick — no need to
restart playback.

Mirrors the existing tooltip-delay slider exactly: SettingsButton::
ReplayMoveIntervalUp/Down variants, the same `slider_row` pattern,
the same per-tick repaint system shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:00:59 +00:00
funman300 87275bf340 feat(core,engine): solver-driven hints with heuristic fallback
The H-key hint now asks the v0.15.0 Klondike solver for the actual
best first move from the current game state instead of the existing
heuristic. The heuristic stays as the fallback path so hints still
work when the solver bails Inconclusive on the player's budget.

solitaire_core::solver gains a path-recording variant. The internal
DFS already enumerated moves on each frame; recording the root_move
on the stack frame is +16 bytes and one unwrap_or per expansion —
the new-game retry loop sees no measurable slowdown.

New public API (additive — try_solve unchanged):

  pub struct SolverMove { source, dest, count }
  pub struct SolveOutcome { result: SolverResult, first_move: Option<SolverMove> }
  pub fn try_solve_with_first_move(seed, draw_mode, &cfg) -> SolveOutcome
  pub fn try_solve_from_state(&GameState, &cfg) -> SolveOutcome

The internal solver-move enum was renamed InternalMove so the public
SolverMove can use engine-friendly (source, dest, count) types
instead of the compact internal form.

Engine wiring: handle_keyboard_hint calls try_solve_from_state on
the live GameStateResource. On Winnable + first_move, the hint
surfaces that exact move (no cycling — a single, optimal hint).
Unwinnable or Inconclusive falls through to the existing all_hints
cycling heuristic so hints remain useful in deals the solver gives
up on.

A new HintSolverConfig resource lets tests inject tight budgets to
force the fallback path; production uses SolverConfig::default()
and median solve time stays at 2 ms per H press.

Six new tests pin the contract: 4 in solitaire_core (Winnable
returns first_move, Unwinnable returns None, deterministic, seed
and state forms agree); 2 in solitaire_engine (hint uses solver
when Winnable, falls back to heuristic when Inconclusive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 01:10:02 +00:00
8 changed files with 1151 additions and 87 deletions
+31
View File
@@ -8,6 +8,37 @@ project follows [Semantic Versioning](https://semver.org/).
_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
A modal-feel polish round. Every overlay screen now scrolls when its
+30 -26
View File
@@ -1,14 +1,14 @@
# 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
- **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).
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
- **Tests:** **1196 passed / 0 failed** across the workspace.
- **Tags on origin:** `v0.9.0` through `v0.16.0`.
- **Tests:** **1208 passed / 0 failed** across the workspace.
- **Tags on origin:** `v0.9.0` through `v0.17.0`.
## 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.
## 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.101.00 s in 0.05 s steps; default 0.45 s. Read per frame from `SettingsResource`. |
## v0.16.0 (shipped 2026-05-06)
| 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
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.
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.
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.
### Carryover from v0.15.0 next-round candidates
### Process note (raised this session)
Still open — would each be ~50200 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.
- **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. Async + cancel button would be safer.
### Carryover candidates — still open
- **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.
- **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.
@@ -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
dismiss across all six read-only modals.
State: HEAD at v0.16.0. Working tree clean apart from untracked
CARD_PLAN.md (intentional).
State: HEAD at v0.17.0 (solver hints + replay-rate slider on top
of v0.16.0). Working tree clean apart from untracked CARD_PLAN.md
(intentional).
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):
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)
DECISION TO ASK THE PLAYER FIRST:
A. Smoke-test v0.16.0. Scroll on Achievements, pointer cursor on
buttons, first Tab in a modal activates rather than advances,
scrim click dismisses Stats/Achievements/Help/Profile/
Leaderboard/Home but NOT Settings/Pause/etc.
B. Solver-driven hints — replace heuristic with try_solve's
best-move suggestion. ~100 LOC.
C. Solver-on-AsyncComputeTaskPool with progress toast + cancel.
Eliminates the worst-case 6 s stall.
D. Pick from the remaining "next-round candidates" in this doc.
E. Take the deferred desktop-packaging item (needs artwork +
A. Solver-on-AsyncComputeTaskPool with progress toast + cancel.
A previous attempt was rolled back when an agent left 3
failing tests; redoing it needs smaller pieces. Eliminates the
worst-case 6 s UI stall — highest gameplay impact left.
B. Per-deal "won previously" HUD indicator using the rolling
replay history's seeds.
C. Replay sharing — copyable URL via the existing web viewer.
D. Take the deferred desktop-packaging item (needs artwork +
signing certs from the user).
WORKFLOW NOTES:
@@ -103,5 +107,5 @@ WORKFLOW NOTES:
- Every commit must pass build / clippy / test before pushing.
- Push to GitHub (origin) — that is the canonical remote.
OPEN AT THE START: ask which of AE. Don't pick unilaterally.
OPEN AT THE START: ask which of AD. Don't pick unilaterally.
```
+397 -43
View File
@@ -65,7 +65,7 @@ use std::hash::{Hash, Hasher};
use crate::card::{Card, Suit};
use crate::deck::{deal_klondike, Deck};
use crate::game_state::DrawMode;
use crate::game_state::{DrawMode, GameState};
use crate::pile::{Pile, PileType};
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`.
///
/// 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
/// solver's notion of "legal" exactly matches the live game.
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
// Delegate to the path-recording variant and discard the move. The path
// recording is cheap (a single Option<SolverMove> per stack frame) so
// this preserves `try_solve`'s existing performance characteristics —
// the new-game retry loop, which is the hot caller, sees no slowdown.
try_solve_with_first_move(seed, draw_mode, config).result
}
/// 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);
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let won = state.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
if won {
SolverResult::Winnable
} else if budget_exceeded {
SolverResult::Inconclusive
} else {
SolverResult::Unwinnable
}
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
/// from `MoveRequestEvent` (engine-level) and `move_cards` (game-level)
/// 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)]
enum SolverMove {
enum InternalMove {
/// Move `count` cards from a tableau column to another tableau column.
TableauToTableau { from: usize, to: usize, count: usize },
/// Move the top of a tableau column to a foundation slot.
@@ -156,6 +223,41 @@ enum SolverMove {
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
/// undo / score / move-count tracking and replaces the `HashMap` of
/// 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
/// fastest, and stock-draws are the costliest. See the top-of-file
/// algorithm note.
fn enumerate_moves(&self) -> Vec<SolverMove> {
let mut moves: Vec<SolverMove> = Vec::new();
fn enumerate_moves(&self) -> Vec<InternalMove> {
let mut moves: Vec<InternalMove> = Vec::new();
// 1) Foundation moves from tableau tops.
for (i, col) in self.tableau.iter().enumerate() {
@@ -246,7 +348,7 @@ impl SolverState {
&self.foundation[slot as usize],
);
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],
);
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;
}
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 {
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
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 =
self.consecutive_draws > stock_cycle_len.saturating_add(1);
if can_draw && !cycled_without_progress {
moves.push(SolverMove::Draw);
moves.push(InternalMove::Draw);
}
moves
@@ -334,11 +436,11 @@ impl SolverState {
/// Apply `mv` to `self`, returning the previous `consecutive_draws`
/// 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_consec = self.consecutive_draws;
match mv {
SolverMove::TableauToTableau { from, to, count } => {
InternalMove::TableauToTableau { from, to, count } => {
let start = self.tableau[from].len() - count;
let moved: Vec<Card> = self.tableau[from].split_off(start);
self.tableau[to].extend(moved);
@@ -351,7 +453,7 @@ impl SolverState {
self.just_drew = false;
self.consecutive_draws = 0;
}
SolverMove::TableauToFoundation { from, slot } => {
InternalMove::TableauToFoundation { from, slot } => {
if let Some(card) = self.tableau[from].pop() {
self.foundation[slot as usize].push(card);
if let Some(top) = self.tableau[from].last_mut()
@@ -363,21 +465,21 @@ impl SolverState {
self.just_drew = false;
self.consecutive_draws = 0;
}
SolverMove::WasteToTableau { to } => {
InternalMove::WasteToTableau { to } => {
if let Some(card) = self.waste.pop() {
self.tableau[to].push(card);
}
self.just_drew = false;
self.consecutive_draws = 0;
}
SolverMove::WasteToFoundation { slot } => {
InternalMove::WasteToFoundation { slot } => {
if let Some(card) = self.waste.pop() {
self.foundation[slot as usize].push(card);
}
self.just_drew = false;
self.consecutive_draws = 0;
}
SolverMove::Draw => {
InternalMove::Draw => {
if self.stock.is_empty() {
// Recycle waste back to stock face-down, reversed.
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
/// with thread-stack pages.
///
/// Returns `true` as soon as a winning leaf is found. Sets
/// `*budget_exceeded = true` if either budget trips before a
/// verdict.
/// Returns `Some(first_move)` (the move applied at the root that led
/// to a winning leaf) as soon as a winning leaf is found. Returns
/// `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(
self,
config: &SolverConfig,
visited: &mut HashSet<u64>,
moves_consumed: &mut u64,
budget_exceeded: &mut bool,
) -> bool {
) -> Option<SolverMove> {
// Each stack frame keeps a state plus the move iterator we
// haven't yet expanded. Popping a frame is the backtrack.
struct Frame {
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() {
return true;
return None;
}
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
*budget_exceeded = true;
return false;
return None;
}
let root_hash = self.canonical_hash();
if !visited.insert(root_hash) {
return false;
return None;
}
let root_moves = self.enumerate_moves();
let mut stack: Vec<Frame> = Vec::new();
stack.push(Frame {
state: self,
pending: root_moves.into_iter(),
root_move: None,
});
while let Some(frame) = stack.last_mut() {
@@ -457,7 +575,7 @@ impl SolverState {
|| visited.len() >= config.state_budget
{
*budget_exceeded = true;
return false;
return None;
}
let Some(mv) = frame.pending.next() else {
// Exhausted this frame's children — backtrack.
@@ -465,10 +583,15 @@ impl SolverState {
continue;
};
*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();
next.apply_move(mv);
if next.is_won() {
return true;
return Some(child_root_move.to_public());
}
let h = next.canonical_hash();
if !visited.insert(h) {
@@ -478,9 +601,74 @@ impl SolverState {
stack.push(Frame {
state: next,
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.
@@ -656,9 +844,9 @@ mod tests {
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
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!(
moves_consumed < 1000,
@@ -699,8 +887,8 @@ mod tests {
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let won = 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");
let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
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");
}
@@ -890,4 +1078,170 @@ mod tests {
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");
}
}
+2 -1
View File
@@ -141,7 +141,8 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
pub mod settings;
pub use settings::{
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,
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
};
+159 -3
View File
@@ -181,6 +181,17 @@ pub struct Settings {
/// solver retry loop — see `solitaire_engine::handle_new_game`.
#[serde(default)]
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 {
@@ -238,6 +249,33 @@ fn default_time_bonus_multiplier() -> f32 {
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`]
/// is willing to attempt before giving up and accepting the latest
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
@@ -268,14 +306,16 @@ impl Default for Settings {
tooltip_delay_secs: default_tooltip_delay(),
time_bonus_multiplier: default_time_bonus_multiplier(),
winnable_deals_only: false,
replay_move_interval_secs: default_replay_move_interval_secs(),
}
}
}
impl Settings {
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`, and
/// `time_bonus_multiplier` into their respective ranges after
/// deserialization or hand-editing of `settings.json`.
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`,
/// `time_bonus_multiplier`, and `replay_move_interval_secs` into
/// their respective ranges after deserialization or hand-editing of
/// `settings.json`.
pub fn sanitized(self) -> Self {
Self {
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
@@ -286,6 +326,9 @@ impl Settings {
time_bonus_multiplier: self
.time_bonus_multiplier
.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
}
}
@@ -324,6 +367,21 @@ impl Settings {
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
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
@@ -456,6 +514,7 @@ mod tests {
tooltip_delay_secs: default_tooltip_delay(),
time_bonus_multiplier: default_time_bonus_multiplier(),
winnable_deals_only: false,
replay_move_interval_secs: default_replay_move_interval_secs(),
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
@@ -908,4 +967,101 @@ mod tests {
"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
);
}
}
+257 -9
View File
@@ -54,6 +54,16 @@ use crate::time_attack_plugin::TimeAttackResource;
/// Z-depth used for cards while being dragged — above all resting cards.
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
/// flow.
///
@@ -89,6 +99,7 @@ pub struct InputPlugin;
impl Plugin for InputPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<HintCycleIndex>()
.init_resource::<HintSolverConfig>()
.init_resource::<KeyboardConfirmState>()
.add_message::<NewGameConfirmEvent>()
.add_message::<StartZenRequestEvent>()
@@ -236,20 +247,34 @@ fn handle_keyboard_core(
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
}
/// Handles the H key: cycles through all available hints, highlighting the
/// source card yellow for 2 s and showing a descriptive toast.
/// Handles the H key: surface the solver's provably-best first move when
/// 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
/// moves are available a "No hints available" toast is shown instead.
/// The solver (`solitaire_core::solver::try_solve_from_state`) is run
/// 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)]
fn handle_keyboard_hint(
keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>,
game: Option<Res<GameStateResource>>,
layout: Option<Res<LayoutResource>>,
solver_config: Res<HintSolverConfig>,
mut hint_cycle: ResMut<HintCycleIndex>,
mut commands: Commands,
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
mut info_toast: MessageWriter<InfoToastEvent>,
mut hint_visual: MessageWriter<HintVisualEvent>,
) {
@@ -269,6 +294,25 @@ fn handle_keyboard_hint(
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);
if hints.is_empty() {
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.
let idx = hint_cycle.0 % hints.len();
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
// face-up card to highlight — show a toast instead.
// If the stock is empty, pressing D will recycle the waste rather
// than draw a card, so the toast text must reflect that.
if *from == PileType::Stock {
let stock_empty = g.0.piles
let stock_empty = game.piles
.get(&PileType::Stock)
.is_some_and(|p| p.cards.is_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.
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))
.map(|c| c.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".
let msg = match to {
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 {
let suit_name = match suit {
Suit::Clubs => "Clubs",
@@ -2125,5 +2184,194 @@ mod tests {
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)"
);
}
}
+154 -3
View File
@@ -45,11 +45,34 @@ use solitaire_data::{Replay, ReplayMove};
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::{GameMutation, RecordingReplay};
use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource;
/// Per-move duration during playback. Tunable in Settings later;
/// hardcoded for v1.
/// Default per-move duration during playback, in seconds. Acts as the
/// 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;
/// 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
/// the auto-clear system transitions it back 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);
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 {
replay,
cursor: 0,
@@ -207,11 +236,13 @@ pub fn stop_replay_playback(
/// so the loop runs at most once per frame.
fn tick_replay_playback(
time: Res<Time>,
settings: Option<Res<SettingsResource>>,
mut state: ResMut<ReplayPlaybackState>,
mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>,
) {
let dt = time.delta_secs();
let interval = current_move_interval_secs(settings.as_deref());
let mut transition_to_completed = false;
if let ReplayPlaybackState::Playing {
@@ -235,7 +266,7 @@ fn tick_replay_playback(
}
}
*cursor += 1;
*secs_to_next += REPLAY_MOVE_INTERVAL_SECS;
*secs_to_next += interval;
}
if *cursor >= replay.moves.len() {
@@ -679,4 +710,124 @@ mod tests {
"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}",
);
}
}
+121 -2
View File
@@ -18,7 +18,8 @@ use bevy::window::{WindowMoved, WindowResized};
use solitaire_core::game_state::DrawMode;
use solitaire_data::{
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};
@@ -132,6 +133,12 @@ struct TooltipDelayText;
#[derive(Component, Debug)]
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"
/// state ("ON" / "OFF") in the Gameplay section.
#[derive(Component, Debug)]
@@ -179,6 +186,12 @@ enum SettingsButton {
TimeBonusDown,
/// Increment the cosmetic time-bonus multiplier by one step.
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,
ToggleColorBlind,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
@@ -219,8 +232,12 @@ impl SettingsButton {
SettingsButton::TooltipDelayUp => 46,
SettingsButton::TimeBonusDown => 47,
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
SettingsButton::ToggleTheme => 50,
SettingsButton::ToggleTheme => 55,
SettingsButton::ToggleColorBlind => 60,
// Picker rows — every swatch in a row shares the row's
// priority so entity-index tiebreaking yields left → right.
@@ -310,6 +327,7 @@ impl Plugin for SettingsPlugin {
update_color_blind_text,
update_tooltip_delay_text,
update_time_bonus_multiplier_text,
update_replay_move_interval_text,
update_winnable_deals_only_text,
attach_focusable_to_settings_buttons,
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 {
if idx == 0 {
"Default".to_string()
@@ -765,6 +798,29 @@ fn handle_settings_buttons(
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 => {
settings.0.theme = match settings.0.theme {
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
/// buttons (volume +/, toggle, cycle), swatch buttons (card-back,
/// background pickers), and the "Sync Now" button. The "Done" button is
@@ -1228,6 +1292,11 @@ fn spawn_settings_panel(
settings.time_bonus_multiplier,
font_res,
);
replay_move_interval_row(
body,
settings.replay_move_interval_secs,
font_res,
);
// --- Cosmetic ---
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,
/// anim speed, colour-blind).
///