feat(workspace): full server + sync implementation, all tests green

- solitaire_server: Axum auth, sync push/pull, leaderboard, daily
  challenge, account deletion, JWT middleware, rate limiting via
  tower_governor, SQLite migrations, health endpoint
- solitaire_server: expose build_test_router (no rate limiting) so
  integration tests work without a peer IP in oneshot requests
- solitaire_sync: SyncPayload, merge logic, shared API types
- solitaire_data: SyncProvider trait, LocalOnlyProvider,
  SolitaireServerClient, auth_tokens keyring integration, blanket
  Box<dyn SyncProvider> impl
- solitaire_data/settings: derive Default on SyncBackend (clippy fix)
- .sqlx/: offline query cache so server compiles without a live DB
- sqlx: removed non-existent "offline" feature flag
- keyring v2: fixed Entry::new() returning Result<Entry>
- sqlx 0.8: all SQLite TEXT columns wrapped in Option<T>
- Integration tests: max_connections(1) on in-memory pool so all
  connections share the same schema

All 191 tests pass; cargo clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-26 23:32:56 +00:00
parent 13b428b81c
commit 34ba4dc6ed
55 changed files with 4372 additions and 270 deletions
+6 -33
View File
@@ -1,46 +1,18 @@
//! Persistence for per-player achievement unlock records.
//!
//! The [`AchievementRecord`] struct is defined in `solitaire_sync` so the
//! server can use the same type. This module re-exports it and provides
//! file I/O helpers.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub use solitaire_sync::AchievementRecord;
const APP_DIR_NAME: &str = "solitaire_quest";
const FILE_NAME: &str = "achievements.json";
/// One player's unlock state for a single achievement.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AchievementRecord {
pub id: String,
pub unlocked: bool,
pub unlock_date: Option<DateTime<Utc>>,
pub reward_granted: bool,
}
impl AchievementRecord {
/// Construct an initial record for an achievement that is not yet unlocked.
pub fn locked(id: impl Into<String>) -> Self {
Self {
id: id.into(),
unlocked: false,
unlock_date: None,
reward_granted: false,
}
}
/// Mark this record unlocked at the given timestamp. No-op if already unlocked
/// (preserves earliest `unlock_date`).
pub fn unlock(&mut self, at: DateTime<Utc>) {
if self.unlocked {
return;
}
self.unlocked = true;
self.unlock_date = Some(at);
}
}
/// Platform-specific default path for `achievements.json`.
pub fn achievements_file_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
@@ -70,6 +42,7 @@ pub fn save_achievements_to(path: &Path, records: &[AchievementRecord]) -> io::R
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use std::env;
fn tmp_path(name: &str) -> PathBuf {
+106
View File
@@ -0,0 +1,106 @@
//! Secure storage for JWT access and refresh tokens using the OS keychain.
//!
//! Tokens are stored under service name `"solitaire_quest_server"` with entry
//! keys `"{username}_access"` and `"{username}_refresh"`.
//!
//! On Linux this requires a running secret service (GNOME Keyring / KWallet).
//! If the keychain is unavailable, operations return
//! [`TokenError::KeychainUnavailable`] — callers should fall back to prompting
//! the user to log in again.
//!
//! # Note: no unit tests — requires live OS keychain.
use keyring::Entry;
use thiserror::Error;
/// Errors that can occur when reading or writing tokens in the OS keychain.
#[derive(Debug, Error)]
pub enum TokenError {
/// The OS keychain (secret service / keychain daemon) is not available.
#[error("keychain unavailable: {0}")]
KeychainUnavailable(String),
/// No token was found in the keychain for the given username.
#[error("token not found for user {0}")]
NotFound(String),
/// An unexpected keychain error occurred.
#[error("keychain error: {0}")]
Keyring(String),
}
/// Service name used to namespace all keychain entries for this application.
const SERVICE: &str = "solitaire_quest_server";
/// Map a `keyring::Error` to the appropriate `TokenError`.
fn map_keyring_err(err: keyring::Error, username: &str) -> TokenError {
let msg = err.to_string();
match err {
keyring::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
keyring::Error::NoEntry => TokenError::NotFound(username.to_string()),
_ => TokenError::Keyring(msg),
}
}
/// Store the access and refresh tokens for `username` in the OS keychain.
///
/// Any previously stored tokens for that username are overwritten.
pub fn store_tokens(
username: &str,
access_token: &str,
refresh_token: &str,
) -> Result<(), TokenError> {
Entry::new(SERVICE, &format!("{username}_access"))
.map_err(|e| map_keyring_err(e, username))?
.set_password(access_token)
.map_err(|e| map_keyring_err(e, username))?;
Entry::new(SERVICE, &format!("{username}_refresh"))
.map_err(|e| map_keyring_err(e, username))?
.set_password(refresh_token)
.map_err(|e| map_keyring_err(e, username))?;
Ok(())
}
/// Load the stored access token for `username` from the OS keychain.
///
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
Entry::new(SERVICE, &format!("{username}_access"))
.map_err(|e| map_keyring_err(e, username))?
.get_password()
.map_err(|e| map_keyring_err(e, username))
}
/// Load the stored refresh token for `username` from the OS keychain.
///
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
Entry::new(SERVICE, &format!("{username}_refresh"))
.map_err(|e| map_keyring_err(e, username))?
.get_password()
.map_err(|e| map_keyring_err(e, username))
}
/// Delete the stored access and refresh tokens for `username`.
///
/// Intended to be called on logout or account deletion. Missing entries are
/// silently ignored (the tokens are already gone, which is the desired state).
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
match Entry::new(SERVICE, &format!("{username}_access"))
.map_err(|e| map_keyring_err(e, username))?
.delete_password()
{
Ok(()) | Err(keyring::Error::NoEntry) => {}
Err(e) => return Err(map_keyring_err(e, username)),
}
match Entry::new(SERVICE, &format!("{username}_refresh"))
.map_err(|e| map_keyring_err(e, username))?
.delete_password()
{
Ok(()) | Err(keyring::Error::NoEntry) => {}
Err(e) => return Err(map_keyring_err(e, username)),
}
Ok(())
}
+38 -3
View File
@@ -35,11 +35,35 @@ pub trait SyncProvider: Send + Sync {
}
}
/// 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
}
}
pub mod stats;
pub use stats::StatsSnapshot;
pub use stats::{StatsExt, StatsSnapshot};
pub mod storage;
pub use storage::{load_stats, load_stats_from, save_stats, save_stats_to, stats_file_path};
pub use storage::{
cleanup_orphaned_tmp_files, load_stats, load_stats_from, save_stats, save_stats_to,
stats_file_path,
};
pub mod achievements;
pub use achievements::{
@@ -62,4 +86,15 @@ 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, Settings};
pub use settings::{
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
Theme,
};
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};
+8 -121
View File
@@ -1,30 +1,22 @@
//! Player progression — XP, level, unlocks, daily/weekly progress.
//!
//! Persisted to `progress.json` next to `stats.json` and `achievements.json`.
//!
//! [`PlayerProgress`] is defined in `solitaire_sync` (so the server can use
//! the same type) and re-exported here along with file I/O helpers.
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use chrono::{Datelike, NaiveDate};
pub use solitaire_sync::progress::level_for_xp;
pub use solitaire_sync::PlayerProgress;
const APP_DIR_NAME: &str = "solitaire_quest";
const FILE_NAME: &str = "progress.json";
/// XP-to-level lookup. Matches ARCHITECTURE.md §13.
///
/// Levels 110: `level = floor(total_xp / 500)`
/// Levels 11+: `level = 10 + floor((total_xp - 5_000) / 1_000)`
pub fn level_for_xp(xp: u64) -> u32 {
if xp < 5_000 {
(xp / 500) as u32
} else {
10 + ((xp - 5_000) / 1_000) as u32
}
}
/// Deterministic seed derived from a date, identical for all players globally.
/// Used as the RNG seed for the daily-challenge deal.
pub fn daily_seed_for(date: NaiveDate) -> u64 {
@@ -52,112 +44,6 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
base + speed_bonus + no_undo_bonus
}
/// Persisted player progression state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProgress {
pub total_xp: u64,
pub level: u32,
pub daily_challenge_last_completed: Option<NaiveDate>,
pub daily_challenge_streak: u32,
pub weekly_goal_progress: HashMap<String, u32>,
/// ISO week key (e.g. `"2026-W17"`) the current `weekly_goal_progress`
/// counters belong to. When the engine sees a different week it clears
/// progress and updates this field.
#[serde(default)]
pub weekly_goal_week_iso: Option<String>,
pub unlocked_card_backs: Vec<usize>,
pub unlocked_backgrounds: Vec<usize>,
/// Index of the next Challenge-mode seed the player will be served.
/// Increments on each Challenge-mode win. Out-of-range values wrap modulo
/// `CHALLENGE_SEEDS.len()` at lookup time.
#[serde(default)]
pub challenge_index: u32,
pub last_modified: DateTime<Utc>,
}
impl Default for PlayerProgress {
fn default() -> Self {
Self {
total_xp: 0,
level: 0,
daily_challenge_last_completed: None,
daily_challenge_streak: 0,
weekly_goal_progress: HashMap::new(),
weekly_goal_week_iso: None,
unlocked_card_backs: vec![0], // back #0 always available
unlocked_backgrounds: vec![0], // background #0 always available
challenge_index: 0,
last_modified: DateTime::UNIX_EPOCH,
}
}
}
impl PlayerProgress {
/// Add XP and recompute level. Returns the previous level so callers can
/// detect level-up events.
pub fn add_xp(&mut self, amount: u64) -> u32 {
let prev_level = self.level;
self.total_xp = self.total_xp.saturating_add(amount);
self.level = level_for_xp(self.total_xp);
self.last_modified = Utc::now();
prev_level
}
/// `true` if a level-up just occurred (current level > `prev_level`).
pub fn leveled_up_from(&self, prev_level: u32) -> bool {
self.level > prev_level
}
/// Reset weekly-goal progress when the ISO week has rolled over.
/// No-op if the stored week key already matches `current`.
pub fn roll_weekly_goals_if_new_week(&mut self, current: &str) -> bool {
if self.weekly_goal_week_iso.as_deref() == Some(current) {
return false;
}
self.weekly_goal_progress.clear();
self.weekly_goal_week_iso = Some(current.to_string());
self.last_modified = Utc::now();
true
}
/// Increment progress for `goal_id` by 1, capped at `target`.
/// Returns `true` if this call brought the counter from below `target`
/// to at-or-above `target` (i.e. just completed the goal).
pub fn record_weekly_progress(&mut self, goal_id: &str, target: u32) -> bool {
let entry = self.weekly_goal_progress.entry(goal_id.to_string()).or_insert(0);
if *entry >= target {
// Already complete — do not over-count.
return false;
}
*entry = entry.saturating_add(1);
self.last_modified = Utc::now();
*entry >= target
}
/// Record a daily-challenge completion for `date`.
///
/// - First completion ever, or a gap of more than one day: streak resets to 1.
/// - Completion the day after the previous: streak increments.
/// - Same day as the previous: no-op (idempotent — a player can't double-count).
///
/// Returns `true` if this call recorded a fresh completion (i.e. it wasn't
/// the same-day no-op case).
pub fn record_daily_completion(&mut self, date: NaiveDate) -> bool {
match self.daily_challenge_last_completed {
Some(last) if last == date => return false,
Some(last) if last + Duration::days(1) == date => {
self.daily_challenge_streak = self.daily_challenge_streak.saturating_add(1);
}
_ => {
self.daily_challenge_streak = 1;
}
}
self.daily_challenge_last_completed = Some(date);
self.last_modified = Utc::now();
true
}
}
/// Platform-specific default path for `progress.json`.
pub fn progress_file_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
@@ -186,6 +72,7 @@ pub fn save_progress_to(path: &Path, progress: &PlayerProgress) -> io::Result<()
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use std::env;
fn tmp_path(name: &str) -> PathBuf {
+172 -7
View File
@@ -1,42 +1,127 @@
//! User settings (persistent).
//!
//! Currently tracks SFX volume and the first-run flag. Other fields from
//! ARCHITECTURE.md §9 (`draw_mode`, `music_volume`, `theme`, `sync_backend`)
//! will land alongside the systems that need them.
//! Tracks draw mode, volumes, animation speed, visual theme, sync backend, and
//! the first-run flag. All fields use `#[serde(default)]` so settings files
//! written by older versions of the game still deserialize correctly.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::DrawMode;
const APP_DIR_NAME: &str = "solitaire_quest";
const SETTINGS_FILE_NAME: &str = "settings.json";
/// Animation playback speed for card transitions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AnimSpeed {
/// Standard animation timing (default).
#[default]
Normal,
/// Roughly 2× faster than Normal.
Fast,
/// Skip animations entirely — cards teleport to their destinations.
Instant,
}
/// Visual theme applied to the table background and UI chrome.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Theme {
/// Classic green felt (default).
#[default]
Green,
/// Blue felt variant.
Blue,
/// Dark / night-mode variant.
Dark,
}
/// Which sync backend the player has configured.
///
/// JWT tokens for `SolitaireServer` are stored in the OS keychain via
/// `solitaire_data::auth_tokens` — **never** in this struct.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub enum SyncBackend {
/// No sync — all progress stays on the local device (default).
#[default]
#[serde(rename = "local")]
Local,
/// Sync with a self-hosted Solitaire Quest server.
#[serde(rename = "solitaire_server")]
SolitaireServer {
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
url: String,
/// The player's username on that server.
username: String,
// JWT tokens are stored in the OS keychain — not here.
},
/// Google Play Games Services (Android only). Selecting this on non-Android
/// platforms silently falls back to `Local` at runtime.
#[serde(rename = "google_play_games")]
GooglePlayGames,
}
/// Persistent user settings.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Settings {
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's main track gain.
/// Draw mode selected for new games.
#[serde(default = "default_draw_mode")]
pub draw_mode: DrawMode,
/// Linear SFX volume in `[0.0, 1.0]`. Applied to kira's SFX channel gain.
#[serde(default = "default_sfx_volume")]
pub sfx_volume: f32,
/// Linear music volume in `[0.0, 1.0]`. Applied to kira's music channel gain.
#[serde(default = "default_music_volume")]
pub music_volume: f32,
/// Speed at which card animations play.
#[serde(default)]
pub animation_speed: AnimSpeed,
/// Visual theme for the table and UI.
#[serde(default)]
pub theme: Theme,
/// Which sync backend is active.
#[serde(default)]
pub sync_backend: SyncBackend,
/// Set to `true` once the player has dismissed the first-run banner.
#[serde(default)]
pub first_run_complete: bool,
}
fn default_draw_mode() -> DrawMode {
DrawMode::DrawOne
}
fn default_sfx_volume() -> f32 {
0.8
}
fn default_music_volume() -> f32 {
0.5
}
impl Default for Settings {
fn default() -> Self {
Self {
sfx_volume: 0.8,
draw_mode: DrawMode::DrawOne,
sfx_volume: default_sfx_volume(),
music_volume: default_music_volume(),
animation_speed: AnimSpeed::Normal,
theme: Theme::Green,
sync_backend: SyncBackend::Local,
first_run_complete: false,
}
}
}
impl Settings {
/// Clamps `sfx_volume` into `[0.0, 1.0]` after deserialization or
/// hand-editing of `settings.json`.
/// Clamps both `sfx_volume` and `music_volume` into `[0.0, 1.0]` after
/// deserialization or hand-editing of `settings.json`.
pub fn sanitized(self) -> Self {
Self {
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
music_volume: self.music_volume.clamp(0.0, 1.0),
..self
}
}
@@ -46,6 +131,12 @@ impl Settings {
self.sfx_volume = (self.sfx_volume + delta).clamp(0.0, 1.0);
self.sfx_volume
}
/// Adjust music volume by `delta`, clamped to `[0.0, 1.0]`. Returns the new value.
pub fn adjust_music_volume(&mut self, delta: f32) -> f32 {
self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0);
self.music_volume
}
}
/// Returns the platform-specific path to `settings.json`, or `None` if
@@ -90,7 +181,12 @@ mod tests {
fn defaults_are_reasonable() {
let s = Settings::default();
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
assert!((s.music_volume - 0.5).abs() < 1e-6);
assert!(!s.first_run_complete);
assert_eq!(s.draw_mode, DrawMode::DrawOne);
assert_eq!(s.animation_speed, AnimSpeed::Normal);
assert_eq!(s.theme, Theme::Green);
assert_eq!(s.sync_backend, SyncBackend::Local);
}
#[test]
@@ -103,17 +199,43 @@ mod tests {
assert!((s.adjust_sfx_volume(-1.0) - 0.0).abs() < 1e-6);
}
#[test]
fn adjust_music_volume_clamps() {
let mut s = Settings::default();
s.music_volume = 0.5;
assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6);
assert!((s.adjust_music_volume(0.5) - 1.0).abs() < 1e-6);
assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6);
assert!((s.adjust_music_volume(-1.0) - 0.0).abs() < 1e-6);
}
#[test]
fn sanitized_clamps_out_of_range_volume() {
let s = Settings {
sfx_volume: 5.0,
music_volume: -1.5,
first_run_complete: true,
..Settings::default()
}
.sanitized();
assert_eq!(s.sfx_volume, 1.0);
assert_eq!(s.music_volume, 0.0);
assert!(s.first_run_complete);
}
#[test]
fn sanitized_clamps_music_volume() {
let mut s = Settings::default();
s.music_volume = 2.0;
let s = s.sanitized();
assert_eq!(s.music_volume, 1.0);
let mut s2 = Settings::default();
s2.music_volume = -0.5;
let s2 = s2.sanitized();
assert_eq!(s2.music_volume, 0.0);
}
#[test]
fn round_trip_save_and_load() {
let path = tmp_path("round_trip");
@@ -121,6 +243,28 @@ mod tests {
let s = Settings {
sfx_volume: 0.42,
first_run_complete: true,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded, s);
}
#[test]
fn round_trip_save_and_load_full_settings() {
let path = tmp_path("round_trip_full");
let _ = fs::remove_file(&path);
let s = Settings {
draw_mode: DrawMode::DrawThree,
sfx_volume: 0.3,
music_volume: 0.7,
animation_speed: AnimSpeed::Fast,
theme: Theme::Dark,
sync_backend: SyncBackend::SolitaireServer {
url: "https://example.com".to_string(),
username: "testuser".to_string(),
},
first_run_complete: true,
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
@@ -142,4 +286,25 @@ mod tests {
let s = load_settings_from(&path);
assert_eq!(s, Settings::default());
}
#[test]
fn load_from_old_format_uses_defaults_for_new_fields() {
// Simulate a settings.json written by an older version that only had
// sfx_volume and first_run_complete.
let path = tmp_path("old_format");
fs::write(
&path,
br#"{ "sfx_volume": 0.6, "first_run_complete": true }"#,
)
.expect("write");
let s = load_settings_from(&path);
assert!((s.sfx_volume - 0.6).abs() < 1e-6);
assert!(s.first_run_complete);
// New fields should fall back to their defaults.
assert!((s.music_volume - 0.5).abs() < 1e-6);
assert_eq!(s.animation_speed, AnimSpeed::Normal);
assert_eq!(s.theme, Theme::Green);
assert_eq!(s.sync_backend, SyncBackend::Local);
assert_eq!(s.draw_mode, DrawMode::DrawOne);
}
}
+15 -59
View File
@@ -1,51 +1,24 @@
//! Player statistics — persisted to `stats.json` between sessions.
//!
//! [`StatsSnapshot`] is defined in `solitaire_sync` and re-exported here.
//! This module adds the [`StatsExt`] extension trait, which supplies the
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use chrono::Utc;
use solitaire_core::game_state::DrawMode;
/// Cumulative game statistics. Stored as `stats.json` in the platform data dir.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatsSnapshot {
pub games_played: u32,
pub games_won: u32,
pub games_lost: u32,
pub win_streak_current: u32,
pub win_streak_best: u32,
/// Rolling average of win times in seconds.
pub avg_time_seconds: u64,
/// Fastest win time. `u64::MAX` means no wins yet.
pub fastest_win_seconds: u64,
/// Sum of all winning scores.
pub lifetime_score: u64,
pub best_single_score: u32,
pub draw_one_wins: u32,
pub draw_three_wins: u32,
pub last_modified: DateTime<Utc>,
}
pub use solitaire_sync::StatsSnapshot;
impl Default for StatsSnapshot {
fn default() -> Self {
Self {
games_played: 0,
games_won: 0,
games_lost: 0,
win_streak_current: 0,
win_streak_best: 0,
avg_time_seconds: 0,
fastest_win_seconds: u64::MAX,
lifetime_score: 0,
best_single_score: 0,
draw_one_wins: 0,
draw_three_wins: 0,
last_modified: DateTime::UNIX_EPOCH,
}
}
}
impl StatsSnapshot {
/// Extension trait providing game-logic mutation helpers for [`StatsSnapshot`].
///
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
pub trait StatsExt {
/// Record a completed win. Updates all relevant counters and rolling averages.
pub fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
}
impl StatsExt for StatsSnapshot {
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
let prev_wins = self.games_won;
self.games_played += 1;
self.games_won += 1;
@@ -78,23 +51,6 @@ impl StatsSnapshot {
self.last_modified = Utc::now();
}
/// Record an abandoned game (player started a new game without winning).
pub fn record_abandoned(&mut self) {
self.games_played += 1;
self.games_lost += 1;
self.win_streak_current = 0;
self.last_modified = Utc::now();
}
/// Win percentage as 0100, or `None` if no games played.
pub fn win_rate(&self) -> Option<f32> {
if self.games_played == 0 {
None
} else {
Some(self.games_won as f32 / self.games_played as f32 * 100.0)
}
}
}
#[cfg(test)]
+79 -1
View File
@@ -58,10 +58,48 @@ pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
save_stats_to(&path, stats)
}
/// Remove any leftover `*.json.tmp` files in the app data directory.
///
/// These can be left behind if the process crashes between the write and rename
/// in an atomic save. Safe to call on startup; missing or unreadable entries
/// are silently skipped.
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
let dir = match dirs::data_dir() {
Some(d) => d.join(APP_DIR_NAME),
None => return Ok(()),
};
if !dir.exists() {
return Ok(());
}
cleanup_tmp_files_in(&dir);
Ok(())
}
/// Inner helper: delete `*.json.tmp` entries inside `dir`.
///
/// Per-file errors (already deleted, permission denied) are silently ignored.
fn cleanup_tmp_files_in(dir: &Path) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".json.tmp"))
.unwrap_or(false)
{
let _ = fs::remove_file(&path);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stats::StatsSnapshot;
use crate::stats::{StatsExt, StatsSnapshot};
use solitaire_core::game_state::DrawMode;
use std::env;
@@ -109,4 +147,44 @@ mod tests {
let stats = load_stats_from(&path);
assert_eq!(stats, StatsSnapshot::default());
}
/// Test the core cleanup logic by creating `.json.tmp` files in a temporary
/// directory, running the cleanup loop manually, and verifying removal.
#[test]
fn cleanup_removes_tmp_files() {
let dir = env::temp_dir().join("solitaire_cleanup_test");
fs::create_dir_all(&dir).expect("create test dir");
// Create a pair of .json.tmp files and one regular file that must survive.
let tmp1 = dir.join("stats.json.tmp");
let tmp2 = dir.join("progress.json.tmp");
let keep = dir.join("settings.json");
fs::write(&tmp1, b"orphan1").expect("write tmp1");
fs::write(&tmp2, b"orphan2").expect("write tmp2");
fs::write(&keep, b"{}").expect("write keep");
// Run the cleanup logic directly against our test directory.
cleanup_tmp_files_in(&dir);
assert!(!tmp1.exists(), "stats.json.tmp should have been removed");
assert!(!tmp2.exists(), "progress.json.tmp should have been removed");
assert!(keep.exists(), "settings.json must not be removed");
// Tidy up.
let _ = fs::remove_file(&keep);
let _ = fs::remove_dir(&dir);
}
/// Calling `cleanup_orphaned_tmp_files` on a box with no app data dir is a
/// no-op and must not return an error.
#[test]
fn cleanup_on_nonexistent_dir_is_ok() {
// We can't control whether the real app dir exists in the test
// environment, but the public function must at least not panic or
// return an Err when the directory is absent.
// The real implementation returns Ok(()) for missing dirs.
let result = cleanup_orphaned_tmp_files();
// The function is allowed to succeed whether or not the dir exists.
assert!(result.is_ok());
}
}
+318
View File
@@ -0,0 +1,318 @@
//! Concrete [`SyncProvider`] implementations and a factory for constructing
//! the correct provider from a [`SyncBackend`] setting.
//!
//! # Backends
//!
//! | Struct | Backend |
//! |---|---|
//! | [`LocalOnlyProvider`] | No-op; used when sync is disabled |
//! | [`SolitaireServerClient`] | Self-hosted Solitaire Quest server (JWT auth) |
//!
//! Use [`provider_for_backend`] to obtain a `Box<dyn SyncProvider + Send + Sync>`
//! without matching on [`SyncBackend`] anywhere else in the codebase.
use async_trait::async_trait;
use solitaire_sync::{SyncPayload, SyncResponse};
use crate::{
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
settings::SyncBackend,
SyncError, SyncProvider,
};
// ---------------------------------------------------------------------------
// LocalOnlyProvider
// ---------------------------------------------------------------------------
/// A no-op sync provider used when the player has not configured any backend.
///
/// Both [`pull`](SyncProvider::pull) and [`push`](SyncProvider::push) always
/// return [`SyncError::UnsupportedPlatform`], so callers know no remote data
/// is available without treating it as a fatal error.
pub struct LocalOnlyProvider;
#[async_trait]
impl SyncProvider for LocalOnlyProvider {
async fn pull(&self) -> Result<SyncPayload, SyncError> {
Err(SyncError::UnsupportedPlatform)
}
async fn push(&self, _payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
Err(SyncError::UnsupportedPlatform)
}
fn backend_name(&self) -> &'static str {
"local"
}
fn is_authenticated(&self) -> bool {
false
}
}
// ---------------------------------------------------------------------------
// SolitaireServerClient
// ---------------------------------------------------------------------------
/// HTTP sync client for the self-hosted Solitaire Quest server.
///
/// Authenticates via JWT stored in the OS keychain. On a 401 response the
/// client automatically attempts a token refresh and retries the request once
/// before returning an error.
pub struct SolitaireServerClient {
/// Base URL of the server, e.g. `"https://solitaire.example.com"`.
/// Trailing slashes are stripped on construction.
base_url: String,
/// The player's username on this server — used as the keychain key.
username: String,
/// Shared `reqwest` client (keeps connection pools alive across calls).
client: reqwest::Client,
}
impl SolitaireServerClient {
/// Construct a new client for the given server URL and username.
///
/// The `base_url` trailing slash is stripped so URL construction is
/// consistent regardless of how the user entered the setting.
pub fn new(base_url: impl Into<String>, username: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_owned(),
username: username.into(),
client: reqwest::Client::new(),
}
}
/// Attempt to refresh the access token using the stored refresh token.
///
/// On success the new access token is persisted to the OS keychain,
/// replacing the previous one. The refresh token itself is unchanged.
async fn refresh_token(&self) -> Result<(), SyncError> {
let refresh = load_refresh_token(&self.username)
.map_err(|e| SyncError::Auth(e.to_string()))?;
let resp = self
.client
.post(format!("{}/api/auth/refresh", self.base_url))
.json(&serde_json::json!({ "refresh_token": refresh }))
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(SyncError::Auth("refresh failed".into()));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| SyncError::Serialization(e.to_string()))?;
let new_access = body["access_token"]
.as_str()
.ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?;
// store_tokens replaces both access and refresh; we keep the old
// refresh token unchanged so its 30-day TTL is preserved.
store_tokens(&self.username, new_access, &refresh)
.map_err(|e| SyncError::Auth(e.to_string()))
}
/// Load the current access token from the OS keychain.
fn access_token(&self) -> Result<String, SyncError> {
load_access_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))
}
}
#[async_trait]
impl SyncProvider for SolitaireServerClient {
/// Fetch the latest sync payload from the server.
///
/// On HTTP 401 the client refreshes the access token and retries once.
async fn pull(&self) -> Result<SyncPayload, SyncError> {
let token = self.access_token()?;
let url = format!("{}/api/sync/pull", self.base_url);
let resp = self
.client
.get(&url)
.bearer_auth(&token)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once.
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
.get(&url)
.bearer_auth(new_token)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
return extract_pull_body(resp).await;
}
extract_pull_body(resp).await
}
/// Push the local payload to the server and return the merged response.
///
/// On HTTP 401 the client refreshes the access token and retries once.
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError> {
let token = self.access_token()?;
let url = format!("{}/api/sync/push", self.base_url);
let resp = self
.client
.post(&url)
.bearer_auth(&token)
.json(payload)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once.
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
.post(&url)
.bearer_auth(new_token)
.json(payload)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
return extract_push_body(resp).await;
}
extract_push_body(resp).await
}
fn backend_name(&self) -> &'static str {
"solitaire_server"
}
/// Returns `true` if a valid access token is present in the OS keychain.
fn is_authenticated(&self) -> bool {
load_access_token(&self.username).is_ok()
}
}
// ---------------------------------------------------------------------------
// Response extraction helpers
// ---------------------------------------------------------------------------
/// Deserialize a pull response body as [`SyncResponse`] and return its
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncError> {
let status = resp.status();
if status.is_success() {
let sync_resp: SyncResponse = resp
.json()
.await
.map_err(|e| SyncError::Serialization(e.to_string()))?;
Ok(sync_resp.merged)
} else {
Err(SyncError::Auth(format!("server returned {status}")))
}
}
/// Deserialize a push response body as [`SyncResponse`], or map non-200
/// statuses to the appropriate [`SyncError`].
async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, SyncError> {
let status = resp.status();
if status.is_success() {
resp.json()
.await
.map_err(|e| SyncError::Serialization(e.to_string()))
} else {
Err(SyncError::Auth(format!("server returned {status}")))
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/// Construct the appropriate [`SyncProvider`] for the given [`SyncBackend`]
/// setting.
///
/// This is the **one** place in the codebase that matches on [`SyncBackend`]
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
/// and remains backend-agnostic.
///
/// `GooglePlayGames` is Android-only; on desktop it silently falls back to
/// [`LocalOnlyProvider`].
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
match backend {
SyncBackend::Local => Box::new(LocalOnlyProvider),
SyncBackend::SolitaireServer { url, username } => {
Box::new(SolitaireServerClient::new(url.clone(), username.clone()))
}
SyncBackend::GooglePlayGames => {
// GPGS is Android-only; fall back to no-op on desktop.
Box::new(LocalOnlyProvider)
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_provider_backend_name() {
assert_eq!(LocalOnlyProvider.backend_name(), "local");
}
#[test]
fn local_provider_not_authenticated() {
assert!(!LocalOnlyProvider.is_authenticated());
}
#[tokio::test]
async fn local_provider_pull_returns_unsupported() {
let err = LocalOnlyProvider.pull().await.unwrap_err();
assert!(matches!(err, SyncError::UnsupportedPlatform));
}
#[test]
fn server_client_strips_trailing_slash() {
let c = SolitaireServerClient::new("https://example.com/", "alice");
assert_eq!(c.base_url, "https://example.com");
}
#[test]
fn server_client_backend_name() {
let c = SolitaireServerClient::new("https://example.com", "alice");
assert_eq!(c.backend_name(), "solitaire_server");
}
#[test]
fn factory_local_returns_local_provider() {
let provider = provider_for_backend(&SyncBackend::Local);
assert_eq!(provider.backend_name(), "local");
}
#[test]
fn factory_gpgs_falls_back_to_local() {
let provider = provider_for_backend(&SyncBackend::GooglePlayGames);
assert_eq!(provider.backend_name(), "local");
}
#[test]
fn factory_server_returns_server_client() {
let provider = provider_for_backend(&SyncBackend::SolitaireServer {
url: "https://example.com".to_string(),
username: "bob".to_string(),
});
assert_eq!(provider.backend_name(), "solitaire_server");
}
}