000143231b
Classic, Zen, and Challenge already auto-saved correctly via the existing game_state.json path — GameState carries mode and the save/restore systems are mode-agnostic. Time Attack was the gap: the per-deal GameState round-tripped fine, but the session-level TimeAttackResource (10-minute countdown + accumulated wins) defaulted on every launch, so closing mid-session reset the timer and erased the win count. Adds a sibling time_attack_session.json next to game_state.json, atomic .tmp + rename via the existing save pattern. The new TimeAttackSession struct carries remaining_secs, wins, and saved_at_unix_secs (wall-clock anchor for stale-session detection). load_time_attack_session_from_at takes an injectable now() so tests can drive deterministic clock scenarios. Load logic: if now_unix - saved_at_unix_secs > remaining_secs the window expired in real time while the app was closed — return None so the player isn't dropped into a session whose timer ran out behind their back. Otherwise restore remaining_secs minus the real-world elapsed delta. Handles clock-running-backwards (NTP correction, VM clock drift) by clamping the elapsed delta at zero. time_attack_plugin wires four new systems: load on Startup, clear stale file when a fresh session starts (rare — only matters when the previous session was abandoned + a new one started without exit/relaunch), 30-second auto-save while a session is active, delete file on natural expiry, and save on AppExit. The save file is removed every time the session ends so a stale "session exists" state can't pollute the next launch. No GameState schema bump needed — the per-mode session lives in its own file. stats / progress / achievements / settings unaffected. 8 new storage tests cover round-trip, expired-discard, time-decay, atomic-write, missing-file, corrupt-file, delete idempotency, and clock-backwards. 6 new plugin tests cover exit-persists, exit-clears, auto-save-cadence, auto-save-noop-when-inactive, new-session-clears-stale, and natural-expiry-clears. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
5.5 KiB
Rust
142 lines
5.5 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(())
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
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, 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};
|