refactor(core): card_game redundancy cleanup + derive scoring from upstream stats #88

Open
funman300 wants to merge 7 commits from refactor/strip-card_game-redundancies into master
24 changed files with 478 additions and 610 deletions
+11
View File
@@ -30,3 +30,14 @@ solitaire_server/e2e/test-results/
deploy/matomo-secret.yaml
deploy/*-secret.yaml
deploy/*-auth-secret.yaml
# Local agent-tooling artifacts (Codex / claude-flow) — keep out of the repo
/.agents/
/.codex/
/AGENTS.md
# claude-flow scratch dirs, anywhere in the tree (e.g. solitaire_engine/src/)
.claude-flow/
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
scripts/*.go
@@ -20,7 +20,7 @@
//! --help Print this message
use solitaire_core::DrawMode;
use solitaire_data::solver::try_solve;
use solitaire_core::game_state::GameState;
// Budget boundaries defining each tier. A seed belongs to the lowest tier
// whose budget proves it Winnable.
@@ -99,7 +99,7 @@ fn main() {
if buckets[i].len() >= per_tier {
continue;
}
match try_solve(seed, draw_mode, move_budget, state_budget) {
match GameState::solve_fresh_deal(seed, draw_mode, move_budget, state_budget) {
Ok(Some(_)) => {
buckets[i].push(seed);
eprintln!(
+3 -2
View File
@@ -18,7 +18,8 @@
//! --help Print this message
use solitaire_core::DrawMode;
use solitaire_data::solver::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, try_solve};
use solitaire_core::game_state::GameState;
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
fn main() {
let mut args = std::env::args().skip(1).peekable();
@@ -77,7 +78,7 @@ fn main() {
while found.len() < count {
tried += 1;
if matches!(
try_solve(
GameState::solve_fresh_deal(
seed,
draw_mode,
DEFAULT_SOLVE_MOVES_BUDGET,
+300 -230
View File
@@ -1,12 +1,11 @@
use crate::error::MoveError;
use crate::klondike_adapter::{
DrawMode, KlondikeAdapter, SavedInstruction,
compute_time_bonus as scoring_time_bonus,
foundation_from_slot as adapter_foundation_from_slot,
skip_cards_from_count as adapter_skip_cards_from_count,
tableau_from_index as adapter_tableau_from_index,
};
use card_game::{Card, Game as _, Session, SessionConfig};
use card_game::{Card, Game as _, Session, SessionConfig, SolveError};
use klondike::{
DrawStockConfig, DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig,
KlondikeInstruction, KlondikePile, KlondikePileStack, SkipCards, Tableau, TableauStack,
@@ -22,10 +21,30 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
/// - v3: session-backed save files using local `SavedInstruction` mirror types
/// with u8 indices for enum variants.
/// - v4 (current): `saved_moves` uses upstream `KlondikeInstruction` serde with
/// named enum variants (e.g. `"Foundation1"` instead of `0`). v3 files are
/// auto-migrated on load via `AnyInstruction` transparent deserialization.
pub const GAME_STATE_SCHEMA_VERSION: u32 = 4;
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
/// variants (e.g. `"Foundation1"` instead of `0`). v3 files are auto-migrated
/// on load via `AnyInstruction` transparent deserialization.
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
/// persisted. They are derived from the upstream `card_game`/`klondike` session
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
/// still carry those keys load fine — the extra fields are ignored.
pub const GAME_STATE_SCHEMA_VERSION: u32 = 5;
/// Default move budget for a solvability check. Matches the winnable-deal retry
/// loop in the engine.
pub const DEFAULT_SOLVE_MOVES_BUDGET: u64 = 100_000;
/// Default unique-state budget for a solvability check.
pub const DEFAULT_SOLVE_STATES_BUDGET: u64 = 200_000;
/// Outcome of a solvability check ([`GameState::solve_first_move`]):
///
/// * `Ok(Some(instruction))` — winnable; `instruction` is the first useful move
/// on a winning path (used by the hint system).
/// * `Ok(None)` — provably unwinnable (search exhausted with no solution, or the
/// game is already won so no next move exists).
/// * `Err(SolveError)` — inconclusive; the move/state budget was exceeded before
/// a verdict was reached.
pub type SolveOutcome = Result<Option<KlondikeInstruction>, SolveError>;
/// Default value for `GameState::schema_version` when deserialising older
/// save files that pre-date the field.
@@ -84,11 +103,8 @@ pub enum GameMode {
struct PersistedGameState {
pub draw_mode: DrawMode,
pub mode: GameMode,
pub score: i32,
pub elapsed_seconds: u64,
pub seed: u64,
pub undo_count: u32,
pub recycle_count: u32,
pub take_from_foundation: bool,
pub schema_version: u32,
pub saved_moves: Vec<KlondikeInstruction>,
@@ -110,20 +126,19 @@ enum AnyInstruction {
V3(SavedInstruction),
}
/// Input struct that accepts both schema v3 and v4 `saved_moves` formats.
/// Input struct that accepts schema v3, v4, and v5 `saved_moves` formats.
///
/// `recycle_count` is intentionally absent: the value is rebuilt from the
/// instruction replay so that stale counts (from the pre-Phase-3 undo drift
/// bug) are corrected on load. Serde ignores the field in the JSON.
/// `score`, `undo_count`, and `recycle_count` are intentionally absent: all
/// three are rebuilt by replaying the instruction history through the upstream
/// session stats. Older save files (v3/v4) still carry those keys; serde ignores
/// them.
#[derive(Debug, Clone, Deserialize)]
struct PersistedGameStateIn {
pub draw_mode: DrawMode,
#[serde(default)]
pub mode: GameMode,
pub score: i32,
pub elapsed_seconds: u64,
pub seed: u64,
pub undo_count: u32,
#[serde(default)]
pub take_from_foundation: bool,
#[serde(default = "schema_v1")]
@@ -162,33 +177,25 @@ pub struct TestPileState {
}
/// Full state of an in-progress Klondike Solitaire game.
///
/// Score, undo count, and recycle count are **not** stored here. They are
/// derived on demand from the upstream `card_game`/`klondike` session stats via
/// [`GameState::score`], [`GameState::undo_count`], and
/// [`GameState::recycle_count`]. The session is the single source of truth; the
/// 15 undo penalty is configured on the session ([`Self::session_config`]) and
/// applied by the upstream score formula.
#[derive(Debug, Clone)]
pub struct GameState {
/// Top-level mode (Classic / Zen).
pub mode: GameMode,
/// Current game score. Can be negative (undo penalties subtract from score).
pub score: i32,
/// Seconds elapsed since the game started, used for time-bonus scoring.
pub elapsed_seconds: u64,
/// RNG seed used to deal this game. Same seed always produces the same layout.
pub seed: u64,
/// Number of times `undo()` has been successfully invoked this game.
pub undo_count: u32,
/// Number of times the waste pile has been recycled back to stock this game.
pub recycle_count: u32,
/// When `true`, the player may move the top card of a foundation pile back
/// onto a compatible tableau column.
pub take_from_foundation: bool,
pub(crate) session: Session<Klondike>,
/// Score recorded immediately before each instruction was applied.
/// Parallel to `session.history()` during live play; used by `undo()` to
/// correctly restore the pre-move score before applying the undo penalty.
/// Empty after a load (can't be reconstructed from history alone).
score_history: Vec<i32>,
/// Whether each entry in `session.history()` was a stock recycle.
/// Parallel to `session.history()`; rebuilt from replay on load so that
/// `undo()` correctly decrements `recycle_count` even across save/load cycles.
is_recycle_history: Vec<bool>,
#[cfg(feature = "test-support")]
/// Test pile overrides. Always `None` in production runtime code.
pub test_pile_state: Option<TestPileState>,
@@ -198,14 +205,14 @@ impl PartialEq for GameState {
fn eq(&self, other: &Self) -> bool {
self.draw_mode() == other.draw_mode()
&& self.mode == other.mode
&& self.score == other.score
&& self.score() == other.score()
&& self.move_count() == other.move_count()
&& self.elapsed_seconds == other.elapsed_seconds
&& self.seed == other.seed
&& self.is_won() == other.is_won()
&& self.is_auto_completable() == other.is_auto_completable()
&& self.undo_count == other.undo_count
&& self.recycle_count == other.recycle_count
&& self.undo_count() == other.undo_count()
&& self.recycle_count() == other.recycle_count()
&& self.take_from_foundation == other.take_from_foundation
&& self.stock_cards() == other.stock_cards()
&& self.waste_cards() == other.waste_cards()
@@ -228,11 +235,8 @@ impl Serialize for GameState {
PersistedGameState {
draw_mode: self.draw_mode(),
mode: self.mode,
score: self.score,
elapsed_seconds: self.elapsed_seconds,
seed: self.seed,
undo_count: self.undo_count,
recycle_count: self.recycle_count,
take_from_foundation: self.take_from_foundation,
schema_version: GAME_STATE_SCHEMA_VERSION,
saved_moves: self.saved_moves(),
@@ -245,10 +249,10 @@ impl<'de> Deserialize<'de> for GameState {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
// Accept v3 (legacy u8-index format, auto-migrated) and v4 (current,
// upstream named-variant serde). Reject everything else.
// Accept v3 (legacy u8-index format, auto-migrated), v4 (upstream
// named-variant serde), and v5 (current, derived stats). Reject the rest.
match persisted.schema_version {
3 | 4 => {}
3..=5 => {}
v => {
return Err(serde::de::Error::custom(format!(
"unsupported GameState schema version {v}"
@@ -258,30 +262,22 @@ impl<'de> Deserialize<'de> for GameState {
let mut game = Self {
mode: persisted.mode,
score: persisted.score,
elapsed_seconds: persisted.elapsed_seconds,
seed: persisted.seed,
undo_count: persisted.undo_count,
// Rebuilt from the replay loop below; persisted value may be stale
// due to the pre-Phase-3 undo drift bug.
recycle_count: 0,
take_from_foundation: persisted.take_from_foundation,
session: Self::new_session(persisted.seed, persisted.draw_mode),
// score_history cannot be faithfully rebuilt from the instruction
// history because live-play undo penalties are not recorded in
// saved_moves. Leave empty; undo() falls back to old behaviour for
// any move made before this load (see undo() for details).
score_history: Vec::new(),
// is_recycle_history IS rebuilt: recycle detection only needs the
// pre-instruction session state, which is available during replay.
is_recycle_history: Vec::new(),
#[cfg(feature = "test-support")]
test_pile_state: None,
};
// Replay the saved instruction history. The upstream session tracks
// score components and recycle_count as it processes each move, so the
// derived stats are correct once replay completes. `undo_count()` resets
// to 0 across save/load because undone moves are not part of the saved
// forward history.
let replay_config = Self::replay_config(persisted.draw_mode);
for any in persisted.saved_moves {
// AnyInstruction::V4 arrives directly from upstream serde (schema v4).
// AnyInstruction::V4 arrives directly from upstream serde (schema v4+).
// AnyInstruction::V3 was serialised with u8 indices (schema v3) and is
// converted here via the existing TryFrom impl.
let instruction = match any {
@@ -291,12 +287,6 @@ impl<'de> Deserialize<'de> for GameState {
}
};
// Detect recycle BEFORE processing so that the pre-instruction
// session state (face-down stock) is still available.
let is_recycle = matches!(instruction, KlondikeInstruction::RotateStock)
&& game.stock_cards().is_empty()
&& !game.waste_cards().is_empty();
if !game
.session
.state()
@@ -308,11 +298,6 @@ impl<'de> Deserialize<'de> for GameState {
));
}
game.session.process_instruction(instruction);
game.is_recycle_history.push(is_recycle);
if is_recycle {
game.recycle_count = game.recycle_count.saturating_add(1);
}
}
Ok(game)
@@ -329,15 +314,10 @@ impl GameState {
pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self {
Self {
mode,
score: 0,
elapsed_seconds: 0,
seed,
undo_count: 0,
recycle_count: 0,
take_from_foundation: true,
session: Self::new_session(seed, draw_mode),
score_history: Vec::new(),
is_recycle_history: Vec::new(),
#[cfg(feature = "test-support")]
test_pile_state: None,
}
@@ -352,6 +332,43 @@ impl GameState {
}
}
/// Current game score, derived from the upstream session stats.
///
/// The upstream score is a linear sum of move-type counts (foundation/
/// tableau/flip deltas) plus `undos * undo_penalty` (15 each). Floored at 0
/// so the displayed score is never negative. Returns 0 in [`GameMode::Zen`],
/// where scoring is suppressed entirely.
///
/// Note: the win-time bonus (`compute_time_bonus`) is layered on by the
/// engine's win-summary, not included here — this is the in-play base score.
pub fn score(&self) -> i32 {
if self.mode == GameMode::Zen {
return 0;
}
self.session
.state()
.score(self.session.stats(), self.session.config())
.max(0)
}
/// Number of times `undo()` has been successfully invoked this game, read
/// from the upstream session stats.
///
/// Resets to 0 across a save/load cycle: only the forward instruction
/// history is persisted, so undone moves leave no trace to replay.
pub fn undo_count(&self) -> u32 {
self.session.stats().undos()
}
/// Number of times the waste pile has been recycled back to stock this game,
/// read from the upstream session stats.
///
/// This is a **cumulative** count — the upstream stat is not rolled back when
/// a recycle is undone, so it reflects total recycles ever performed.
pub fn recycle_count(&self) -> u32 {
self.session.stats().stats().recycle_count()
}
/// Total moves made this game (draws, recycles, and card moves), derived
/// from the session's instruction history length.
pub fn move_count(&self) -> u32 {
@@ -395,7 +412,9 @@ impl GameState {
fn session_config(draw_mode: DrawMode) -> SessionConfig<KlondikeConfig> {
SessionConfig {
inner: Self::replay_config(draw_mode),
undo_penalty: 0,
// The 15 WXP undo penalty is now applied by the upstream score
// formula (`undos * undo_penalty`) rather than by hand in `undo()`.
undo_penalty: -15,
..SessionConfig::default()
}
}
@@ -602,6 +621,67 @@ impl GameState {
state.move_count = Some(move_count);
}
/// Test-support helper: perform `n` real undos so [`Self::undo_count`]
/// reports `n`. Each iteration draws a card then immediately undoes it,
/// leaving the board unchanged but advancing the upstream `undos` counter.
///
/// Since `score`/`undo_count`/`recycle_count` are now derived from the
/// session stats rather than stored fields, tests drive the real session to
/// reach a desired stat instead of assigning the value directly.
#[cfg(feature = "test-support")]
pub fn force_test_undos(&mut self, n: u32) {
for _ in 0..n {
if self.draw().is_ok() {
let _ = self.undo();
}
}
}
/// Test-support helper: perform `n` real stock recycles so
/// [`Self::recycle_count`] reports `n`. Draws until the stock empties, then
/// draws once more to recycle, repeated `n` times.
#[cfg(feature = "test-support")]
pub fn force_test_recycles(&mut self, n: u32) {
for _ in 0..n {
let mut guard = 0;
while !self.stock_cards().is_empty() && guard < 200 {
guard += 1;
if self.draw().is_err() {
break;
}
}
// Stock now empty (waste full) — this draw recycles waste → stock.
let _ = self.draw();
}
}
/// Test-support helper: drive real moves until [`Self::score`] reaches at
/// least `target`, returning the resulting score. Prefers foundation moves
/// (+10 each) and falls back to the solver-priority move, so a modest target
/// is reached within a handful of moves on a typical deal.
#[cfg(feature = "test-support")]
pub fn force_test_score(&mut self, target: i32) -> i32 {
let mut guard = 0;
while self.score() < target && !self.is_won() && guard < 4000 {
guard += 1;
let instructions = self.possible_instructions();
let next = instructions
.iter()
.copied()
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)))
.or_else(|| instructions.into_iter().next());
match next {
Some(instruction) => {
if self.apply_instruction(instruction).is_err() {
break;
}
}
None => break,
}
}
self.score()
}
/// Test-support helper: override face-down stock cards returned by
/// [`Self::stock_cards`].
#[cfg(feature = "test-support")]
@@ -673,79 +753,6 @@ impl GameState {
.ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
}
fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool {
let KlondikePile::Tableau(_) = from else {
return false;
};
let pile = self.pile(from);
if pile.is_empty() {
return false;
}
pile.len() > count && !pile[pile.len() - count - 1].1
}
/// Returns `(score_delta, is_recycle)` for `instruction` given the *current*
/// game state. Must be called **before** the instruction is applied to the
/// session; the helper reads pre-instruction pile state from `self`.
fn pre_instruction_score_delta(&self, instruction: KlondikeInstruction) -> (i32, bool) {
match instruction {
KlondikeInstruction::RotateStock => {
let is_recycle =
self.stock_cards().is_empty() && !self.waste_cards().is_empty();
if is_recycle {
let next_count = self.recycle_count.saturating_add(1);
let penalty = KlondikeAdapter::score_for_recycle_with_mode(
next_count,
self.draw_mode() == DrawMode::DrawThree,
self.mode,
);
(penalty, true)
} else {
(0, false)
}
}
KlondikeInstruction::DstFoundation(dst_foundation) => {
let from = dst_foundation.src;
let to = KlondikePile::Foundation(dst_foundation.foundation);
let move_delta =
KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
// DstFoundation always moves exactly 1 card.
let flip_bonus = if self.will_flip_tableau_source(from, 1) {
KlondikeAdapter::score_for_flip_with_mode(self.mode)
} else {
0
};
(move_delta + flip_bonus, false)
}
KlondikeInstruction::DstTableau(dst_tableau) => {
let (from, count) = match dst_tableau.src {
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
KlondikePileStack::Foundation(f) => (KlondikePile::Foundation(f), 1),
KlondikePileStack::Tableau(ts) => {
let face_up_count = self
.session
.state()
.state()
.state()
.tableau_face_up_cards(ts.tableau)
.len();
let count = face_up_count.saturating_sub(ts.skip_cards as usize);
(KlondikePile::Tableau(ts.tableau), count)
}
};
let to = KlondikePile::Tableau(dst_tableau.tableau);
let move_delta =
KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
let flip_bonus = if self.will_flip_tableau_source(from, count) {
KlondikeAdapter::score_for_flip_with_mode(self.mode)
} else {
0
};
(move_delta + flip_bonus, false)
}
}
}
fn instruction_for_move(
&self,
from: KlondikePile,
@@ -900,19 +907,10 @@ impl GameState {
return Err(MoveError::StockEmpty);
}
let (score_delta, is_recycle) =
self.pre_instruction_score_delta(KlondikeInstruction::RotateStock);
self.score_history.push(self.score);
self.is_recycle_history.push(is_recycle);
// The session tracks score components and recycle_count as it processes
// the instruction; no local bookkeeping required.
self.session
.process_instruction(KlondikeInstruction::RotateStock);
if is_recycle {
self.recycle_count = self.recycle_count.saturating_add(1);
}
self.score = (self.score + score_delta).max(0);
Ok(())
}
@@ -950,8 +948,9 @@ impl GameState {
/// instruction form — solver hints, auto-complete, replay, and the property
/// tests. User drag-and-drop enters through [`Self::move_cards`], which is a
/// thin adapter that converts pile coordinates to an instruction and
/// delegates here, so the move bookkeeping (rule validation, score history,
/// recycle accounting, undo snapshot) lives in exactly one place.
/// delegates here, so the move bookkeeping (rule validation, the undo
/// snapshot, and the session's score/recycle stats) lives in exactly one
/// place.
///
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
@@ -973,21 +972,17 @@ impl GameState {
return Err(MoveError::RuleViolation("move violates rules".into()));
}
let (score_delta, is_recycle) = self.pre_instruction_score_delta(instruction);
self.score_history.push(self.score);
self.is_recycle_history.push(is_recycle);
// The session records the move snapshot and updates score/recycle stats.
self.session.process_instruction(instruction);
if is_recycle {
self.recycle_count = self.recycle_count.saturating_add(1);
}
self.score = (self.score + score_delta).max(0);
Ok(())
}
/// Restore the most recent undo snapshot and apply the undo score penalty (-15).
/// Restore the most recent undo snapshot.
///
/// The 15 undo penalty is applied by the upstream score formula
/// (`undos * undo_penalty`), and the session increments its `undos` counter,
/// so this method only has to delegate to [`Session::undo`] after the mode
/// guards. See [`Self::score`] / [`Self::undo_count`] for the derived values.
pub fn undo(&mut self) -> Result<(), MoveError> {
if self.is_won() {
return Err(MoveError::GameAlreadyWon);
@@ -1001,23 +996,7 @@ impl GameState {
return Err(MoveError::UndoStackEmpty);
}
// Pop the pre-instruction score for the move being undone. Falls back
// to self.score (= old behaviour) when score_history is empty, which
// happens for moves made before a save/load cycle because undo
// penalties aren't reflected in the saved instruction history.
let pre_move_score = self.score_history.pop().unwrap_or(self.score);
let was_recycle = self.is_recycle_history.pop().unwrap_or(false);
self.session.undo();
if was_recycle {
self.recycle_count = self.recycle_count.saturating_sub(1);
}
// Apply the undo penalty to the pre-move score, not the post-move score.
// This correctly reverses any recycle or move penalty that was applied
// before adding the 15 undo penalty.
self.score = KlondikeAdapter::apply_undo_score(pre_move_score, self.mode);
self.undo_count = self.undo_count.saturating_add(1);
Ok(())
}
@@ -1118,11 +1097,6 @@ impl GameState {
})
}
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
pub fn compute_time_bonus(&self) -> i32 {
scoring_time_bonus(self.elapsed_seconds)
}
/// Read-only access to the underlying [`card_game::Session`] for this deal.
///
/// Exposes `session.history()` (deterministic replay) and `session.solve()`
@@ -1132,6 +1106,56 @@ impl GameState {
pub fn session(&self) -> &Session<Klondike> {
&self.session
}
/// Solvability of the current position: the first useful move on a winning
/// path, `Ok(None)` if unwinnable (or already won), or `Err` if the solver
/// hit its budget before reaching a verdict. See [`SolveOutcome`].
///
/// Delegates the search to upstream [`card_game::Session::solve`] on a
/// solve-budgeted copy of the current board, then extracts the first
/// non-useless instruction from the returned solution. Backs the hint system
/// and the Play-by-seed verdict badge.
pub fn solve_first_move(&self, moves_budget: u64, states_budget: u64) -> SolveOutcome {
// An already-won game has no "next move"; report it as unwinnable so the
// winnable contract (`Some(_)` ⇒ a real move exists) holds.
if self.is_won() {
return Ok(None);
}
let config = SessionConfig {
inner: KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation),
undo_penalty: 0,
solve_moves_budget: moves_budget,
solve_states_budget: states_budget,
};
let session = Session::new(self.session.state().state().clone(), config);
session.solve().map(|solution| {
solution.and_then(|solution| {
solution
.raw_solution()
.iter()
.map(|snapshot| *snapshot.instruction())
.find(|instruction| !instruction.is_useless())
})
})
}
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
///
/// Fresh-deal solving models standard Klondike rules, so the non-standard
/// take-from-foundation house rule stays disabled. Backs the
/// "Winnable deals only" retry loop.
pub fn solve_fresh_deal(
seed: u64,
draw_mode: DrawMode,
moves_budget: u64,
states_budget: u64,
) -> SolveOutcome {
let mut game = Self::new(seed, draw_mode);
game.take_from_foundation = false;
game.solve_first_move(moves_budget, states_budget)
}
}
#[cfg(test)]
@@ -1216,56 +1240,40 @@ mod tests {
}
#[test]
fn recycle_count_decrements_when_recycle_is_undone() {
fn recycle_count_is_cumulative_and_not_rolled_back_on_undo() {
// Upstream `KlondikeStats::recycle_count` counts every recycle ever
// performed; it is intentionally NOT decremented when a recycle is
// undone (the session restores the board but leaves the stat). This is
// the post-migration semantics: a cumulative count, not a net count.
let mut game = game_at_first_recycle().expect("could not reach recycle");
let count_after_recycle = game.recycle_count;
assert_eq!(count_after_recycle, 1, "first recycle should give count=1");
assert_eq!(game.recycle_count(), 1, "first recycle should give count=1");
game.undo().expect("undo should succeed");
assert_eq!(
game.recycle_count, 0,
"recycle_count must decrement back to 0 after undoing the recycle",
game.recycle_count(),
1,
"recycle_count is cumulative: undoing a recycle does not roll it back",
);
}
#[test]
fn score_recycle_penalty_is_reversed_on_undo() {
// Reach the second recycle (count=2, Draw-1) so there is a 100 penalty.
let mut game = game_at_first_recycle().expect("could not reach first recycle");
// Draw until stock is empty again so we can do a second recycle.
let mut second_recycle_done = false;
for _ in 0..200 {
if game.stock_cards().is_empty() && !game.waste_cards().is_empty() {
let score_before_second_recycle = game.score;
game.draw().expect("second recycle should succeed");
assert_eq!(game.recycle_count, 2);
// The second recycle in Draw-1 mode costs 100.
let expected_after = (score_before_second_recycle - 100).max(0);
assert_eq!(
game.score, expected_after,
"second Draw-1 recycle must apply 100 penalty",
);
// Undo: score should recover to (score_before_second_recycle 15).max(0),
// NOT to (score_after_recycle 15).max(0).
game.undo().expect("undo of second recycle should succeed");
let expected_after_undo = (score_before_second_recycle - 15).max(0);
assert_eq!(
game.score, expected_after_undo,
"undoing a penalised recycle must reverse the recycle penalty \
before applying the 15 undo penalty",
);
assert_eq!(
game.recycle_count, 1,
"recycle_count must also be decremented on undo",
);
second_recycle_done = true;
break;
}
let _ = game.draw();
fn undo_applies_minus_15_penalty_via_upstream_score() {
// A foundation move scores +10 upstream; undoing it nets the move score
// back to 0 and adds the 15 undo penalty, which `score()` floors at 0.
let mut game = GameState::new(1, DrawMode::DrawOne);
// Find and play any scoring move, then undo it.
let scoring_move = game
.possible_instructions()
.into_iter()
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)));
if let Some(instruction) = scoring_move {
game.apply_instruction(instruction)
.expect("scoring move should apply");
assert!(game.score() > 0, "a foundation move should raise the score");
game.undo().expect("undo should succeed");
assert_eq!(game.undo_count(), 1, "undo increments the upstream counter");
// base score returns to 0, minus 15 undo penalty, floored at 0.
assert_eq!(game.score(), 0, "score floors at 0 after the undo penalty");
}
assert!(second_recycle_done, "could not reach second recycle in test");
}
#[test]
@@ -1298,4 +1306,66 @@ mod tests {
);
assert!(game.move_cards(from, to, 1).is_err());
}
// ── Solvability check (solve_first_move / solve_fresh_deal) ──────────────
/// `SolveError` has no `PartialEq`, so compare the winnable verdict and the
/// extracted first move (both `Eq`) rather than the whole `Result`.
fn verdict_key(outcome: &SolveOutcome) -> (bool, Option<KlondikeInstruction>) {
(outcome.is_err(), outcome.clone().ok().flatten())
}
#[test]
fn solve_fresh_deal_is_deterministic() {
let a = GameState::solve_fresh_deal(
7,
DrawMode::DrawOne,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET,
);
let b = GameState::solve_fresh_deal(
7,
DrawMode::DrawOne,
DEFAULT_SOLVE_MOVES_BUDGET,
DEFAULT_SOLVE_STATES_BUDGET,
);
assert_eq!(verdict_key(&a), verdict_key(&b));
}
#[test]
fn winnable_verdict_carries_a_first_move() {
// Contract: a first move is present iff the verdict is winnable.
let outcome = GameState::solve_fresh_deal(7, DrawMode::DrawOne, 5_000, 5_000);
let winnable = matches!(outcome, Ok(Some(_)));
let has_move = outcome.ok().flatten().is_some();
assert_eq!(winnable, has_move);
}
#[test]
fn solve_first_move_uses_live_game_state() {
let mut game = GameState::new(42, DrawMode::DrawOne);
game.draw().expect("draw must succeed");
let outcome = game.solve_first_move(5_000, 5_000);
let winnable = matches!(outcome, Ok(Some(_)));
let has_move = outcome.ok().flatten().is_some();
assert_eq!(winnable, has_move);
}
#[test]
fn zero_state_budget_is_inconclusive() {
let outcome = GameState::solve_fresh_deal(7, DrawMode::DrawOne, 5_000, 0);
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
#[test]
fn budget_is_passed_through_not_clamped() {
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
// budget reaches the solver unchanged.
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 1_000, 1_000);
let medium =
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000);
assert!(easy.is_err());
assert!(matches!(medium, Ok(Some(_))));
}
}
-112
View File
@@ -16,8 +16,6 @@ use klondike::{
};
use serde::{Deserialize, Serialize};
use crate::game_state::GameMode;
/// Whether cards are drawn one at a time or three at a time from the stock.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DrawMode {
@@ -51,116 +49,6 @@ impl KlondikeAdapter {
scoring: ScoringConfig::DEFAULT,
}
}
// ── Scoring helpers ───────────────────────────────────────────────────
/// Score delta for a card move.
///
/// Reads from [`ScoringConfig`] (WXP Standard values):
/// - Any pile → Foundation: +10
/// - Waste → Tableau: +5
/// - Foundation → Tableau: 15
/// - All other moves: 0
pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 {
let sc = ScoringConfig::DEFAULT;
match (from, to) {
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
(KlondikePile::Foundation(_), KlondikePile::Tableau(_)) => sc.move_from_foundation,
_ => 0,
}
}
/// Score delta for exposing a face-down tableau card: +5.
pub fn score_for_flip() -> i32 {
ScoringConfig::DEFAULT.flip_up_bonus
}
/// Score delta for undo: 15.
///
/// This is a Ferrous product policy — `card_game::SessionConfig::undo_penalty`
/// defaults to 0; the solver overrides it to 0 explicitly. The 15 WXP penalty
/// is applied here by `GameState` on every undo.
pub fn score_for_undo() -> i32 {
-15
}
/// Score delta for recycling waste → stock.
///
/// [`ScoringConfig::recycle`] is a flat delta (default 0 = always free).
/// WXP allows a fixed number of free recycles before charging a penalty,
/// which the upstream library cannot express with a single delta:
///
/// | Mode | Free recycles | Penalty per extra recycle |
/// |---|---|---|
/// | Draw-1 | 1 | 100 |
/// | Draw-3 | 3 | 20 |
///
/// **Design note:** recycling is *never* blocked — only penalised.
/// This is intentional: Draw-1 can be played indefinitely with the score
/// dropping toward zero after the first free recycle. A hard cap would
/// create unwinnable positions when the solver cannot find a path without
/// additional recycling. Zen mode suppresses the penalty entirely.
///
/// `recycle_count` must be the new total **after** this recycle.
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
if is_draw_three {
if recycle_count > 3 { -20 } else { 0 }
} else if recycle_count > 1 {
-100
} else {
0
}
}
/// Score delta for a card move, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 {
if mode == GameMode::Zen {
0
} else {
Self::score_for_move(from, to)
}
}
/// Score delta for exposing a face-down card, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`].
pub fn score_for_flip_with_mode(mode: GameMode) -> i32 {
if mode == GameMode::Zen {
0
} else {
Self::score_for_flip()
}
}
/// Compute the new score after an undo, accounting for game mode.
///
/// In [`GameMode::Zen`] the score is always 0. Otherwise applies the
/// 15 undo penalty and clamps to 0 via [`Self::score_for_undo`].
pub fn apply_undo_score(snapshot_score: i32, mode: GameMode) -> i32 {
if mode == GameMode::Zen {
0
} else {
(snapshot_score + Self::score_for_undo()).max(0)
}
}
/// Score delta for recycling, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`].
pub fn score_for_recycle_with_mode(
recycle_count: u32,
is_draw_three: bool,
mode: GameMode,
) -> i32 {
if mode == GameMode::Zen {
0
} else {
Self::score_for_recycle(recycle_count, is_draw_three)
}
}
}
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
+5 -1
View File
@@ -12,9 +12,13 @@ pub mod klondike_adapter;
// re-exported — they are only used internally (in `klondike_adapter.rs` and
// when decoding instructions to piles in `instruction_to_piles`) and do not
// appear in any public method signature.
pub use card_game::{Card, Session};
pub use card_game::{Card, Session, SolveError};
pub use klondike::{Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
pub use klondike_adapter::DrawMode;
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
// former `solitaire_data::solver` wrapper module.
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
#[cfg(test)]
mod proptest_tests;
+6 -9
View File
@@ -99,12 +99,6 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
}
}
pub mod solver;
pub use solver::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve,
try_solve_from_state,
};
pub mod stats;
pub use stats::{StatsExt, StatsSnapshot};
@@ -124,8 +118,8 @@ pub use achievements::{
pub mod progress;
pub use progress::{
PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path,
save_progress_to, xp_for_win,
PlayerProgress, XpBreakdown, daily_seed_for, level_for_xp, load_progress_from,
progress_file_path, save_progress_to, xp_breakdown, xp_for_win,
};
pub mod weekly;
@@ -172,8 +166,11 @@ pub use replay::{
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
};
// `latest_replay_path` is still consumed by the engine's one-shot legacy
// migration; `load_latest_replay_from`/`save_latest_replay_to` had no callers
// outside `replay.rs` and were dropped from the public surface.
#[allow(deprecated)]
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
pub use replay::latest_replay_path;
#[cfg(not(target_arch = "wasm32"))]
pub mod matomo_client;
+35 -5
View File
@@ -25,12 +25,34 @@ pub fn daily_seed_for(date: NaiveDate) -> u64 {
y * 10_000 + m * 100 + d
}
/// XP awarded for winning a game.
/// Component breakdown of the XP awarded for a win.
///
/// This is the single source of truth for win-XP scoring: [`xp_for_win`] sums
/// it for the total, and UI that displays the individual lines (the win-summary
/// modal) reads the parts from here so the breakdown can never drift from the
/// total.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct XpBreakdown {
/// Flat base XP granted for any win.
pub base: u64,
/// Scaled fast-win bonus (10..=50 for sub-2-minute wins, else 0).
pub speed_bonus: u64,
/// Bonus for winning without using undo (25, else 0).
pub no_undo_bonus: u64,
}
impl XpBreakdown {
/// Total XP awarded: `base + speed_bonus + no_undo_bonus`.
pub fn total(self) -> u64 {
self.base + self.speed_bonus + self.no_undo_bonus
}
}
/// Component breakdown of the XP awarded for a win.
///
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
/// the player did not use undo.
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
let base: u64 = 50;
pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown {
let speed_bonus: u64 = if time_seconds >= 120 {
0
} else {
@@ -39,8 +61,16 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
scaled.max(10)
};
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
base + speed_bonus + no_undo_bonus
XpBreakdown {
base: 50,
speed_bonus,
no_undo_bonus: if used_undo { 0 } else { 25 },
}
}
/// XP awarded for winning a game. See [`xp_breakdown`] for the components.
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
xp_breakdown(time_seconds, used_undo).total()
}
/// Platform-specific default path for `progress.json`.
+1 -1
View File
@@ -200,7 +200,7 @@ pub struct Settings {
#[serde(default = "default_time_bonus_multiplier")]
pub time_bonus_multiplier: f32,
/// When `true`, the engine rejects new-game deals the
/// [`solitaire_data::solver`] cannot prove winnable, retrying
/// the solver cannot prove winnable, retrying
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
/// giving up and using the last tried seed. Off by default —
/// the solver adds a few hundred milliseconds of latency on the
-140
View File
@@ -1,140 +0,0 @@
//! Klondike solvability check using upstream `card_game::Session::solve()`.
//!
//! Backs the **Settings → Gameplay → "Winnable deals only"** toggle, the
//! Play-by-seed verdict badge, and the hint system (which wants the first
//! move on a winning path). All search is delegated to `card_game`; this
//! module only adapts the inputs (a seed or a live [`GameState`]) and extracts
//! the first move from the returned solution.
use card_game::{Session, SessionConfig, SolveError};
use klondike::KlondikeInstruction;
use solitaire_core::DrawMode;
use solitaire_core::game_state::GameState;
use solitaire_core::klondike_adapter::KlondikeAdapter;
/// Default move budget for a solve. Matches the winnable-deal retry loop.
pub const DEFAULT_SOLVE_MOVES_BUDGET: u64 = 100_000;
/// Default unique-state budget for a solve.
pub const DEFAULT_SOLVE_STATES_BUDGET: u64 = 200_000;
/// Outcome of a solvability check:
///
/// * `Ok(Some(instruction))` — winnable; `instruction` is the first move on a
/// winning path (used by the hint system).
/// * `Ok(None)` — provably unwinnable (search exhausted with no solution, or
/// the game is already won so no next move exists).
/// * `Err(SolveError)` — inconclusive; the move/state budget was exceeded
/// before a verdict was reached.
pub type SolveOutcome = Result<Option<KlondikeInstruction>, SolveError>;
/// Solves a fresh Classic-mode game dealt from `seed` + `draw_mode`.
///
/// Fresh-deal solving models standard Klondike rules, so the non-standard
/// take-from-foundation house rule stays disabled here.
pub fn try_solve(
seed: u64,
draw_mode: DrawMode,
moves_budget: u64,
states_budget: u64,
) -> SolveOutcome {
let mut game = GameState::new(seed, draw_mode);
game.take_from_foundation = false;
try_solve_from_state(&game, moves_budget, states_budget)
}
/// Solves from an existing in-progress [`GameState`], returning the first move
/// on a winning path when one exists.
pub fn try_solve_from_state(
state: &GameState,
moves_budget: u64,
states_budget: u64,
) -> SolveOutcome {
// An already-won game has no "next move"; report it as unwinnable so the
// winnable contract (`Some(_)` ⇒ a real move exists) holds.
if state.is_won() {
return Ok(None);
}
let config = SessionConfig {
inner: KlondikeAdapter::config_for(state.draw_mode(), state.take_from_foundation),
undo_penalty: 0,
solve_moves_budget: moves_budget,
solve_states_budget: states_budget,
};
let session = Session::new(state.session().state().state().clone(), config);
session.solve().map(|solution| {
solution.and_then(|solution| {
solution
.raw_solution()
.iter()
.map(|snapshot| *snapshot.instruction())
.find(|instruction| !instruction.is_useless())
})
})
}
#[cfg(test)]
mod tests {
use super::*;
/// `SolveError` has no `PartialEq`, so compare the winnable verdict and the
/// extracted first move (both `Eq`) rather than the whole `Result`.
fn verdict_key(outcome: &SolveOutcome) -> (bool, Option<KlondikeInstruction>) {
(outcome.is_err(), outcome.clone().ok().flatten())
}
#[test]
fn try_solve_is_deterministic() {
let a = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET);
let b = try_solve(7, DrawMode::DrawOne, DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET);
assert_eq!(verdict_key(&a), verdict_key(&b));
}
#[test]
fn winnable_verdict_carries_a_first_move() {
// Contract: a first move is present iff the verdict is winnable.
let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 5_000);
let winnable = matches!(outcome, Ok(Some(_)));
let has_move = outcome.ok().flatten().is_some();
assert_eq!(winnable, has_move);
}
#[test]
fn try_solve_from_state_uses_live_game_state() {
let mut game = GameState::new(42, DrawMode::DrawOne);
game.draw().expect("draw must succeed");
let outcome = try_solve_from_state(&game, 5_000, 5_000);
let winnable = matches!(outcome, Ok(Some(_)));
let has_move = outcome.ok().flatten().is_some();
assert_eq!(winnable, has_move);
}
#[test]
fn zero_state_budget_is_inconclusive() {
let outcome = try_solve(7, DrawMode::DrawOne, 5_000, 0);
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
#[test]
fn budget_is_passed_through_not_clamped() {
// This seed is Inconclusive at 1k states but Winnable at 5k — proving
// the budget reaches the solver unchanged.
let easy = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 1_000, 1_000);
let medium = try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, 5_000, 5_000);
assert!(easy.is_err());
assert!(matches!(medium, Ok(Some(_))));
}
#[test]
fn budget_above_five_thousand_is_not_clamped() {
let below_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 5_000, 5_000);
let above_cap = try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, 50_000, 50_000);
assert!(below_cap.is_err(), "seed must be Inconclusive at 5 000 states");
assert!(
matches!(above_cap, Ok(Some(_))),
"seed must be Winnable at 50 000 states — re-introducing the 5k cap would break this"
);
}
}
+33 -12
View File
@@ -497,15 +497,15 @@ mod tests {
/// replays all `saved_moves` to reconstruct every pile.
///
/// A fresh-game test (zero moves) never exercises that replay path, so this
/// test plays several real moves — including an undo — before saving, then
/// asserts the full pile layout round-trips exactly.
/// test plays several real moves — including an undo — before saving.
///
/// `GameState::PartialEq` covers stock, waste, all four foundations, all
/// seven tableau columns, `score`, `move_count`, `undo_count`, and
/// `recycle_count`. Any breakage in the upstream serde or replay path
/// will cause at least one pile to disagree.
/// Since schema v5 no longer persists `score`/`undo_count`/`recycle_count`
/// (they are derived from the replayed session stats), round-trip fidelity is
/// verified by **re-save idempotency**: reloading the save and serialising it
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
/// to 0 on load because only the forward instruction history is persisted.
#[test]
fn game_state_v4_mid_game_round_trip() {
fn game_state_v5_mid_game_round_trip() {
use solitaire_core::KlondikeInstruction;
use solitaire_core::game_state::GameState;
@@ -546,19 +546,40 @@ mod tests {
save_game_state_to(&path, &gs).expect("save");
// Verify the file contains the v4 schema marker (tolerates pretty-print whitespace).
// Verify the file carries the v5 schema marker.
let json = fs::read_to_string(&path).expect("read json");
assert!(
json.contains("schema_version") && json.contains('4') && !json.contains(": 3"),
"saved file must use schema version 4",
json.contains("\"schema_version\"") && json.contains('5'),
"saved file must use schema version 5",
);
let loaded = load_game_state_from(&path)
.expect("a valid in-progress game must load without error");
// The forward instruction history round-trips, so the reconstructed board
// re-serialises to byte-identical JSON.
let path_reload = gs_path("v5_mid_game_reload");
let _ = fs::remove_file(&path_reload);
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
assert_eq!(
loaded, gs,
"all pile layouts and counters must be identical after schema-v4 round-trip",
fs::read_to_string(&path).expect("read original save"),
fs::read_to_string(&path_reload).expect("read re-saved"),
"re-saving the loaded game must reproduce the original save exactly",
);
// Derived board reads match the live game (move count + recycle count are
// both rebuilt from the replayed forward history).
assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
assert_eq!(
loaded.recycle_count(),
gs.recycle_count(),
"recycle_count round-trips",
);
// undo_count is intentionally not persisted: it resets to 0 on load.
assert_eq!(
loaded.undo_count(),
0,
"undo_count resets across save/load under schema v5",
);
}
+3 -3
View File
@@ -176,9 +176,9 @@ fn evaluate_on_win(
daily_challenge_streak: progress.0.daily_challenge_streak,
last_win_score: ev.score,
last_win_time_seconds: ev.time_seconds,
last_win_used_undo: game.0.undo_count > 0,
last_win_used_undo: game.0.undo_count() > 0,
wall_clock_hour: Some(Local::now().hour()),
last_win_recycle_count: game.0.recycle_count,
last_win_recycle_count: game.0.recycle_count(),
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
};
@@ -779,7 +779,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.undo_count = 1;
.force_test_undos(1);
app.world_mut().write_message(GameWonEvent {
score: 1000,
+4 -3
View File
@@ -1487,6 +1487,7 @@ fn update_drag_shadow(
drag: Res<DragState>,
layout: Option<Res<LayoutResource>>,
card_entities: Query<(&CardEntity, &Transform)>,
card_index: Res<CardEntityIndex>,
mut shadow: Local<Option<Entity>>,
) {
if drag.is_idle() {
@@ -1503,9 +1504,9 @@ fn update_drag_shadow(
// Find the world position of the first (top) dragged card.
let top_pos = drag.cards.first().and_then(|first_card| {
card_entities
.iter()
.find(|(marker, _)| marker.card == *first_card)
card_index
.get(first_card)
.and_then(|entity| card_entities.get(entity).ok())
.map(|(_, t)| t.translation)
});
+2 -11
View File
@@ -44,7 +44,8 @@ use std::hash::{Hash, Hasher};
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use solitaire_core::card::Card;
use solitaire_core::{Foundation, KlondikePile};
use solitaire_core::KlondikePile;
use solitaire_core::klondike_adapter::foundation_from_slot;
use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim;
@@ -645,16 +646,6 @@ fn pile_cards(
}
}
fn foundation_from_slot(slot: u8) -> Option<Foundation> {
match slot {
0 => Some(Foundation::Foundation1),
1 => Some(Foundation::Foundation2),
2 => Some(Foundation::Foundation3),
3 => Some(Foundation::Foundation4),
_ => None,
}
}
// ---------------------------------------------------------------------------
// Unit tests (pure functions only — no Bevy world required)
// ---------------------------------------------------------------------------
+5 -7
View File
@@ -15,9 +15,7 @@ use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle;
use solitaire_core::KlondikePile;
use solitaire_core::{DrawMode, game_state::{GameMode, GameState}};
use solitaire_data::solver::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, try_solve,
};
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
use solitaire_data::{
@@ -318,7 +316,7 @@ fn seed_from_system_time() -> u64 {
}
/// Walks forward from `initial_seed` (incrementing by 1 with wrapping
/// arithmetic) until the [`solitaire_data::solver`] returns a verdict
/// arithmetic) until the [`GameState::solve_fresh_deal`] returns a verdict
/// the engine accepts as winnable, or until [`SOLVER_DEAL_RETRY_CAP`]
/// attempts have elapsed.
///
@@ -393,7 +391,7 @@ fn poll_pending_new_game_seed(
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawMode) -> u64 {
let mut seed = initial_seed;
for _ in 0..SOLVER_DEAL_RETRY_CAP {
match try_solve(
match GameState::solve_fresh_deal(
seed,
draw_mode,
DEFAULT_SOLVE_MOVES_BUDGET,
@@ -920,7 +918,7 @@ fn handle_move(
changed.write(StateChangedEvent);
if !was_won && game.0.is_won() {
won.write(GameWonEvent {
score: game.0.score,
score: game.0.score(),
time_seconds: game.0.elapsed_seconds,
});
// Delete the saved state — a won game should not be resumed.
@@ -1117,7 +1115,7 @@ fn check_no_moves(
// Only spawn the overlay if one does not already exist, and no other
// modal scrim is currently open (global ModalScrim guard).
if game_over_screens.is_empty() && scrims.is_empty() {
spawn_game_over_screen(&mut commands, game.0.score, font_res.as_deref());
spawn_game_over_screen(&mut commands, game.0.score(), font_res.as_deref());
}
}
}
+17 -18
View File
@@ -1818,7 +1818,7 @@ fn detect_score_change(
score_q: Query<Entity, With<HudScore>>,
mut commands: Commands,
) {
let current = game.0.score;
let current = game.0.score();
let delta = current - prev.0;
prev.0 = current;
if delta <= 0 {
@@ -2275,7 +2275,7 @@ fn update_hud(
**t = if is_zen {
String::new()
} else {
format!("Score: {}", g.score)
format!("Score: {}", g.score())
};
}
if let Ok(mut t) = moves_q.single_mut() {
@@ -2311,7 +2311,7 @@ fn update_hud(
// --- Undo count ---
if let Ok((mut t, mut color)) = undos_q.single_mut() {
let count = g.undo_count;
let count = g.undo_count();
if count == 0 {
**t = String::new();
*color = TextColor(TEXT_PRIMARY);
@@ -2325,8 +2325,8 @@ fn update_hud(
// --- Recycle counter (both modes, hidden until first recycle) ---
if let Ok(mut t) = recycles_q.single_mut() {
**t = if g.recycle_count > 0 {
format!("Recycles: {}", g.recycle_count)
**t = if g.recycle_count() > 0 {
format!("Recycles: {}", g.recycle_count())
} else {
String::new()
};
@@ -2763,9 +2763,9 @@ mod tests {
#[test]
fn score_reflects_game_state() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.score = 750;
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
app.update();
assert_eq!(read_hud_text::<HudScore>(&mut app), "Score: 750");
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
}
#[test]
@@ -2795,7 +2795,6 @@ mod tests {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
app.world_mut().resource_mut::<GameStateResource>().0.score = 999;
app.update();
// Zen mode spec: "No score display" → text must be empty.
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
@@ -2916,7 +2915,7 @@ mod tests {
fn challenge_hud_empty_when_no_daily_resource() {
// No DailyChallengeResource inserted → HudChallenge must be empty.
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
@@ -2931,7 +2930,7 @@ mod tests {
target_score: None,
max_time_secs: Some(300),
});
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
}
@@ -2946,7 +2945,7 @@ mod tests {
target_score: Some(4000),
max_time_secs: None,
});
app.world_mut().resource_mut::<GameStateResource>().0.score = 1;
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
}
@@ -2984,7 +2983,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.undo_count = 3;
.force_test_undos(3);
app.update();
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
}
@@ -3057,7 +3056,7 @@ mod tests {
fn recycles_hud_shows_count_draw_three() {
let mut app = headless_app();
let mut gs = GameState::new(42, DrawMode::DrawThree);
gs.recycle_count = 3;
gs.force_test_recycles(3);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
@@ -3068,7 +3067,7 @@ mod tests {
let mut app = headless_app();
// Draw-One with recycle_count > 0 must now show the counter too.
let mut gs = GameState::new(42, DrawMode::DrawOne);
gs.recycle_count = 2;
gs.force_test_recycles(2);
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
app.update();
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
@@ -3108,7 +3107,7 @@ mod tests {
set_manual_time_step(&mut app, 0.0);
// Initial state has score=0; bumping by 50 (the threshold)
// is the smallest jump that triggers the floater.
app.world_mut().resource_mut::<GameStateResource>().0.score = 50;
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.update();
// One floater should now exist.
@@ -3129,7 +3128,7 @@ mod tests {
#[test]
fn score_floater_despawns_after_full_lifetime() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.update();
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
@@ -3155,7 +3154,7 @@ mod tests {
let mut app = headless_app();
// +5 mirrors a single tableau-to-foundation move; well below
// the 50-point threshold so the floater path stays dormant.
app.world_mut().resource_mut::<GameStateResource>().0.score = 5;
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
app.update();
assert_eq!(
count_with::<ScoreFloater>(&mut app),
@@ -3231,7 +3230,7 @@ mod tests {
..Settings::default()
}));
// +100 would normally create both a ScorePulse and a ScoreFloater.
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.update();
assert_eq!(
count_with::<ScorePulse>(&mut app),
+25 -21
View File
@@ -33,7 +33,9 @@ use solitaire_core::game_state::GameState;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning;
use crate::card_animation::{CardAnimation, MotionCurve};
use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC};
use crate::card_plugin::{
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC,
};
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
@@ -93,8 +95,8 @@ pub struct HintSolverConfig {
impl Default for HintSolverConfig {
fn default() -> Self {
Self {
moves_budget: solitaire_data::solver::DEFAULT_SOLVE_MOVES_BUDGET,
states_budget: solitaire_data::solver::DEFAULT_SOLVE_STATES_BUDGET,
moves_budget: solitaire_core::DEFAULT_SOLVE_MOVES_BUDGET,
states_budget: solitaire_core::DEFAULT_SOLVE_STATES_BUDGET,
}
}
}
@@ -121,6 +123,10 @@ impl Plugin for InputPlugin {
.init_resource::<HintSolverConfig>()
.init_resource::<crate::pending_hint::PendingHintTask>()
.init_resource::<GameInputConsumedResource>()
// The drag systems resolve cards via `CardEntityIndex`; `CardPlugin`
// owns and rebuilds it, but init here too so `InputPlugin` is
// self-sufficient in tests (idempotent if already registered).
.init_resource::<CardEntityIndex>()
.add_message::<StartZenRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<ForfeitRequestEvent>()
@@ -674,6 +680,7 @@ fn follow_drag(
layout: Option<Res<LayoutResource>>,
tuning: Res<AnimationTuning>,
mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
card_index: Res<CardEntityIndex>,
) {
// Skip if idle or if a touch drag is running.
if drag.is_idle() || drag.active_touch_id.is_some() {
@@ -704,9 +711,8 @@ fn follow_drag(
// Elevate cards: push to DRAG_Z and dim slightly so the board
// beneath stays readable.
for (i, card) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, mut sprite)) = card_transforms
.iter_mut()
.find(|(ce, _, _)| ce.card == *card)
if let Some(entity) = card_index.get(card)
&& let Ok((_, mut transform, mut sprite)) = card_transforms.get_mut(entity)
{
transform.translation.z = dragged_card_z(i);
sprite.color.set_alpha(0.85);
@@ -719,9 +725,8 @@ fn follow_drag(
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
for (i, card) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, _)) = card_transforms
.iter_mut()
.find(|(ce, _, _)| ce.card == *card)
if let Some(entity) = card_index.get(card)
&& let Ok((_, mut transform, _)) = card_transforms.get_mut(entity)
{
transform.translation.x = bottom_pos.x;
transform.translation.y = bottom_pos.y + fan * i as f32;
@@ -743,6 +748,7 @@ fn end_drag(
mut changed: MessageWriter<StateChangedEvent>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Transform)>,
card_index: Res<CardEntityIndex>,
) {
if paused.is_some_and(|p| p.0) {
drag.clear();
@@ -830,9 +836,8 @@ fn end_drag(
continue;
};
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
if let Some((entity, _, transform)) = card_entities
.iter()
.find(|(_, ce, _)| ce.card == *card)
if let Some(entity) = card_index.get(card)
&& let Ok((_, _, transform)) = card_entities.get(entity)
{
let drag_pos = transform.translation.truncate();
let drag_z = transform.translation.z;
@@ -930,6 +935,7 @@ fn touch_follow_drag(
layout: Option<Res<LayoutResource>>,
tuning: Res<AnimationTuning>,
mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
card_index: Res<CardEntityIndex>,
) {
let Some(active_id) = drag.active_touch_id else {
return; // Mouse drag or idle.
@@ -957,9 +963,8 @@ fn touch_follow_drag(
drag.committed = true;
for (i, card) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, mut sprite)) = card_transforms
.iter_mut()
.find(|(ce, _, _)| ce.card == *card)
if let Some(entity) = card_index.get(card)
&& let Ok((_, mut transform, mut sprite)) = card_transforms.get_mut(entity)
{
transform.translation.z = dragged_card_z(i);
sprite.color.set_alpha(0.85);
@@ -971,9 +976,8 @@ fn touch_follow_drag(
let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;
for (i, card) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, _)) = card_transforms
.iter_mut()
.find(|(ce, _, _)| ce.card == *card)
if let Some(entity) = card_index.get(card)
&& let Ok((_, mut transform, _)) = card_transforms.get_mut(entity)
{
transform.translation.x = bottom_pos.x;
transform.translation.y = bottom_pos.y + fan * i as f32;
@@ -998,6 +1002,7 @@ fn touch_end_drag(
mut changed: MessageWriter<StateChangedEvent>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Transform)>,
card_index: Res<CardEntityIndex>,
) {
let Some(active_id) = drag.active_touch_id else {
return; // Mouse drag or idle.
@@ -1070,9 +1075,8 @@ fn touch_end_drag(
continue;
};
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
if let Some((entity, _, transform)) = card_entities
.iter()
.find(|(_, ce, _)| ce.card == *card)
if let Some(entity) = card_index.get(card)
&& let Ok((_, _, transform)) = card_entities.get(entity)
{
let drag_pos = transform.translation.truncate();
let drag_z = transform.translation.z;
+1 -2
View File
@@ -26,7 +26,6 @@ use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::KlondikeInstruction;
use solitaire_core::game_state::GameState;
use solitaire_data::solver::try_solve_from_state;
use crate::card_plugin::CardEntity;
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
@@ -66,7 +65,7 @@ impl PendingHintTask {
// Winnable (`Ok(Some)`) carries the first move on a winning path;
// unwinnable (`Ok(None)`) and inconclusive (`Err`) both fall back
// to the live-state heuristic so H always produces feedback.
match try_solve_from_state(&state, moves_budget, states_budget) {
match state.solve_first_move(moves_budget, states_budget) {
Ok(Some(first_move)) => HintTaskOutput::SolverMove(first_move),
Ok(None) | Err(_) => HintTaskOutput::NeedsHeuristic,
}
+4 -5
View File
@@ -11,7 +11,7 @@
//! 3. `handle_text_input` appends decimal digits / handles Backspace while
//! the modal is open, updating [`SeedInputBuffer`] each frame.
//! 4. `tick_debounce_and_spawn_solver_task` waits for 12 frames (~200 ms at
//! 60 Hz) of no input before spawning a [`try_solve`] task on
//! 60 Hz) of no input before spawning a [`GameState::solve_fresh_deal`] task on
//! [`AsyncComputeTaskPool`]. Any fresh keypress drops the in-flight task
//! by resetting the resource.
//! 5. `poll_solver_task` polls the in-flight task each frame and updates the
@@ -24,9 +24,8 @@ use bevy::input::ButtonInput;
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::DrawMode;
use solitaire_data::solver::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome, try_solve,
};
use solitaire_core::game_state::GameState;
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
use crate::font_plugin::FontResource;
@@ -343,7 +342,7 @@ fn tick_debounce_and_spawn_solver_task(
.as_ref()
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
let task = AsyncComputeTaskPool::get().spawn(async move {
try_solve(
GameState::solve_fresh_deal(
seed,
draw_mode,
DEFAULT_SOLVE_MOVES_BUDGET,
+2 -2
View File
@@ -88,7 +88,7 @@ fn award_xp_on_win(
mut progress: ResMut<ProgressResource>,
) {
for ev in wins.read() {
let used_undo = game.0.undo_count > 0;
let used_undo = game.0.undo_count() > 0;
let amount = xp_for_win(ev.time_seconds, used_undo);
let prev_level = progress.0.add_xp(amount);
xp_awarded.write(XpAwardedEvent { amount });
@@ -151,7 +151,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.undo_count = 1;
.force_test_undos(1);
app.world_mut().write_message(GameWonEvent {
score: 500,
+1 -1
View File
@@ -241,7 +241,7 @@ enum SettingsButton {
ToggleTouchInputMode,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through
/// [`solitaire_data::solver::try_solve`] until one is provably
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
/// winnable (or the retry cap is hit). Off by default.
ToggleWinnableDealsOnly,
/// Toggle the inverse of [`Settings::disable_smart_default_size`].
+2 -2
View File
@@ -82,7 +82,7 @@ fn evaluate_weekly_goals(
for ev in events.drain(..) {
let ctx = WeeklyGoalContext {
time_seconds: ev.time_seconds,
used_undo: game.0.undo_count > 0,
used_undo: game.0.undo_count() > 0,
draw_mode: game.0.draw_mode(),
};
for def in WEEKLY_GOALS {
@@ -177,7 +177,7 @@ mod tests {
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.undo_count = 1;
.force_test_undos(1);
app.world_mut().write_message(GameWonEvent {
score: 500,
+12 -17
View File
@@ -90,28 +90,23 @@ pub struct WinSummaryPending {
/// Builds a human-readable XP breakdown string for the win modal.
///
/// Mirrors the logic in `solitaire_data::xp_for_win` so the breakdown always
/// matches the total shown on the `XpAwardedEvent`.
/// Reads the components from `solitaire_data::xp_breakdown` the single source
/// of truth shared with `xp_for_win` — so the breakdown can never drift from
/// the total shown on the `XpAwardedEvent`.
///
/// Examples:
/// - slow win, no undo → `"+50 base +25 no-undo"`
/// - fast win, undo → `"+50 base +30 speed"`
/// - fast win, no undo → `"+50 base +25 no-undo +30 speed"`
fn build_xp_detail(time_seconds: u64, used_undo: bool) -> String {
let speed_bonus: u64 = if time_seconds >= 120 {
0
} else {
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
scaled.max(10)
};
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
let xp = solitaire_data::xp_breakdown(time_seconds, used_undo);
let mut parts = vec!["+50 base".to_string()];
if no_undo_bonus > 0 {
parts.push("+25 no-undo".to_string());
let mut parts = vec![format!("+{} base", xp.base)];
if xp.no_undo_bonus > 0 {
parts.push(format!("+{} no-undo", xp.no_undo_bonus));
}
if speed_bonus > 0 {
parts.push(format!("+{speed_bonus} speed"));
if xp.speed_bonus > 0 {
parts.push(format!("+{} speed", xp.speed_bonus));
}
parts.join(" ")
}
@@ -477,14 +472,14 @@ fn cache_win_data(
None
};
let used_undo = game.0.undo_count > 0;
let used_undo = game.0.undo_count() > 0;
pending.score = ev.score;
pending.time_seconds = ev.time_seconds;
pending.xp = 0; // reset; XP event follows
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
pending.new_record = is_new_record;
pending.challenge_level = challenge_level;
pending.undo_count = game.0.undo_count;
pending.undo_count = game.0.undo_count();
pending.mode = game.0.mode;
if is_new_record {
@@ -1592,7 +1587,7 @@ mod tests {
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
game.0 = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Zen);
game.0.undo_count = 2;
game.0.force_test_undos(2);
}
app.world_mut().write_message(GameWonEvent {
+4 -4
View File
@@ -184,7 +184,7 @@ impl ReplayPlayer {
StateSnapshot {
step_idx: self.step_idx,
total_steps: self.moves.len(),
score: self.game.score,
score: self.game.score(),
move_count: self.game.move_count(),
is_won: self.game.is_won(),
stock: self
@@ -487,12 +487,12 @@ impl SolitaireGame {
!stock_empty || !waste_empty || !self.game.possible_instructions().is_empty()
};
GameSnapshot {
score: self.game.score,
score: self.game.score(),
move_count: self.game.move_count(),
is_won: self.game.is_won(),
is_auto_completable: self.game.is_auto_completable(),
has_moves,
undo_count: self.game.undo_count,
undo_count: self.game.undo_count(),
undo_stack_len: self.game.undo_stack_len(),
stock: self
.game
@@ -1059,7 +1059,7 @@ mod tests {
draw_mode,
mode: GameMode::Classic,
time_seconds: 120,
final_score: game.game.score,
final_score: game.game.score(),
recorded_at,
moves: exported_moves,
};