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
+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 {