Files
Ferrous-Solitaire/solitaire_data/src/lib.rs
T
funman300 8a5fa8751c feat(core,engine): Klondike solver and "Winnable deals only" toggle
Closes Quat investigation #1. Today some Klondike deals are
unwinnable from the start and the player has no signal that the
deal they were given is solvable. A new Settings → Gameplay toggle
"Winnable deals only" (default off) makes the engine retry seeds
at deal-time until the solver returns Winnable, up to a cap.

Solver

solitaire_core::solver is a hand-rolled iterative-DFS solver with
memoisation on a 64-bit canonical state hash. Move enumeration is
priority-ordered: foundation moves first (zero choice when an Ace
or rank-up exists), inter-tableau moves second, waste-to-tableau
third, stock-draw last. The draw is skipped when the cycle counter
shows we've recirculated the entire stock without progress —
Klondike's deterministic stock cycle means further draws can't
unlock anything new.

Two budget knobs (move_budget = 100k, state_budget = 200k by
default) cap pathological cases at Inconclusive; the caller treats
Inconclusive as "winnable" so the player isn't penalised for the
solver giving up. Median solve time is 2 ms; pathological
inconclusives top out near 120 ms.

Switched from recursive to iterative DFS after a real-deal solve
overflowed Rust's default 8 MB thread stack. Behaviour identical;
the change is invisible to callers.

Pure logic — solitaire_core has no Bevy or I/O. Same input always
yields the same SolverResult.

Settings

Settings.winnable_deals_only is a #[serde(default)] bool; legacy
files load to false. SOLVER_DEAL_RETRY_CAP = 50 caps the retry
loop. The Settings → Gameplay toggle reads as "Winnable deals only"
with a "(may take a moment when on)" caption.

Engine integration

handle_new_game's seed-selection path now branches on the toggle.
When on AND mode is Classic AND no specific seed was requested
(daily challenges, replays, and explicit-seed requests bypass the
solver), choose_winnable_seed walks seed N, N+1, N+2, … calling
try_solve until it finds Winnable or Inconclusive. If the cap is
hit without a verdict, the latest tried seed is used so the player
always gets a deal rather than spinning forever.

19 new tests (11 solver, 3 settings, 5 engine including the
choose_winnable_seed unit). Two ignored bench/scan helpers
(solver_bench, find_unwinnable) for ad-hoc profiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:02:22 +00:00

165 lines
6.6 KiB
Rust

use async_trait::async_trait;
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
use thiserror::Error;
/// All errors that can arise during sync operations.
#[derive(Debug, Error)]
pub enum SyncError {
#[error("unsupported platform for this sync backend")]
UnsupportedPlatform,
#[error("network error: {0}")]
Network(String),
#[error("authentication error: {0}")]
Auth(String),
#[error("serialization error: {0}")]
Serialization(String),
}
/// Every sync backend implements this trait. The SyncPlugin only calls these
/// methods — it never matches on a backend enum variant.
#[async_trait]
pub trait SyncProvider: Send + Sync {
/// Fetch the remote sync payload. Returns the latest server state for merging.
async fn pull(&self) -> Result<SyncPayload, SyncError>;
/// Push the local payload to the backend. Returns the merged server response.
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
/// Human-readable name of this backend, used in settings UI and logs.
fn backend_name(&self) -> &'static str;
/// Returns true if the user is currently authenticated with this backend.
fn is_authenticated(&self) -> bool;
/// Mirror an achievement unlock to this backend (no-op for most backends).
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError> {
Ok(())
}
/// Fetch the global leaderboard from this backend. Returns an empty list
/// for backends that do not support leaderboards (e.g. `LocalOnlyProvider`).
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
Ok(vec![])
}
/// Fetch today's daily challenge from the server. Returns `None` for
/// backends that don't support it, or on any non-fatal network failure.
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError> {
Ok(None)
}
/// Opt the authenticated player into the leaderboard with the given
/// display name. No-op for backends that don't support leaderboards.
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError> {
Ok(())
}
/// Remove the authenticated player from the leaderboard.
/// No-op for backends that don't support leaderboards.
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
Ok(())
}
/// Permanently delete the authenticated player's account and all server
/// data. No-op for backends that don't support account management.
async fn delete_account(&self) -> Result<(), SyncError> {
Ok(())
}
/// Upload a winning replay to the backend so it's available for web
/// playback at `<server>/replays/<id>`. Default returns
/// `UnsupportedPlatform` so backends without a server (e.g.
/// `LocalOnlyProvider`) are silently no-op'd by the engine's
/// push-on-win system, matching the same pattern `pull` / `push`
/// follow.
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<(), SyncError> {
Err(SyncError::UnsupportedPlatform)
}
}
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
/// `provider_for_backend`) can be passed directly to `SyncPlugin::new`.
#[async_trait]
impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
async fn pull(&self) -> Result<SyncPayload, SyncError> {
(**self).pull().await
}
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
(**self).push(payload).await
}
fn backend_name(&self) -> &'static str {
(**self).backend_name()
}
fn is_authenticated(&self) -> bool {
(**self).is_authenticated()
}
async fn mirror_achievement(&self, id: &str) -> Result<(), SyncError> {
(**self).mirror_achievement(id).await
}
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
(**self).fetch_leaderboard().await
}
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError> {
(**self).fetch_daily_challenge().await
}
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
(**self).opt_in_leaderboard(display_name).await
}
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
(**self).opt_out_leaderboard().await
}
async fn delete_account(&self) -> Result<(), SyncError> {
(**self).delete_account().await
}
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<(), SyncError> {
(**self).push_replay(replay).await
}
}
pub mod stats;
pub use stats::{StatsExt, StatsSnapshot};
pub mod storage;
pub use storage::{
cleanup_orphaned_tmp_files, delete_game_state_at, delete_time_attack_session_at,
game_state_file_path, load_game_state_from, load_stats, load_stats_from,
load_time_attack_session_from, load_time_attack_session_from_at, save_game_state_to,
save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
time_attack_session_path, time_attack_session_with_now, TimeAttackSession,
};
pub mod achievements;
pub use achievements::{
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
};
pub mod progress;
pub use progress::{
daily_seed_for, level_for_xp, load_progress_from, progress_file_path, save_progress_to,
xp_for_win, PlayerProgress,
};
pub mod weekly;
pub use weekly::{
current_iso_week_key, weekly_goal_by_id, WeeklyGoalContext, WeeklyGoalDef, WeeklyGoalKind,
WEEKLY_GOALS, WEEKLY_GOAL_XP,
};
pub mod challenge;
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,
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
};
pub mod auth_tokens;
pub use auth_tokens::{
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
};
pub mod sync_client;
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
pub mod replay;
#[allow(deprecated)]
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
pub use replay::{
append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay,
replay_history_path, save_replay_history_to, Replay, ReplayHistory, ReplayMove,
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
};