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:
@@ -8,3 +8,4 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Shared `AchievementRecord` definition — used by both the game client and
|
||||
//! the sync server.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One player's unlock state for a single achievement.
|
||||
///
|
||||
/// The achievement *definition* (name, description, condition fn) lives in
|
||||
/// `solitaire_core`. This record only tracks runtime unlock state and is
|
||||
/// what gets persisted and synced.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AchievementRecord {
|
||||
/// Matches the `id` field of the corresponding `AchievementDef` in
|
||||
/// `solitaire_core`.
|
||||
pub id: String,
|
||||
/// Whether the achievement has been unlocked.
|
||||
pub unlocked: bool,
|
||||
/// The UTC timestamp at which the achievement was first unlocked.
|
||||
/// `None` when not yet unlocked.
|
||||
pub unlock_date: Option<DateTime<Utc>>,
|
||||
/// Whether the unlock reward (XP, cosmetic, etc.) has been granted.
|
||||
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 the earliest `unlock_date` so
|
||||
/// that merging two unlock records always keeps the older timestamp.
|
||||
pub fn unlock(&mut self, at: DateTime<Utc>) {
|
||||
if self.unlocked {
|
||||
return;
|
||||
}
|
||||
self.unlocked = true;
|
||||
self.unlock_date = Some(at);
|
||||
}
|
||||
}
|
||||
+111
-3
@@ -1,17 +1,125 @@
|
||||
//! Shared API types and merge logic for Solitaire Quest.
|
||||
//!
|
||||
//! This crate is the contract between the game client (`solitaire_data`) and
|
||||
//! the sync server (`solitaire_server`). Changing any public type here is a
|
||||
//! breaking change on both sides — version carefully.
|
||||
//!
|
||||
//! **No Bevy. No network. No file I/O.** Only `serde`, `uuid`, and `chrono`.
|
||||
|
||||
pub mod achievements;
|
||||
pub mod merge;
|
||||
pub mod progress;
|
||||
pub mod stats;
|
||||
|
||||
pub use achievements::AchievementRecord;
|
||||
pub use merge::merge;
|
||||
pub use progress::{level_for_xp, PlayerProgress};
|
||||
pub use stats::StatsSnapshot;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Payload sent from client to server (and returned after server merge).
|
||||
/// Full fields are added in Phase 8 (Sync System).
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync wire types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full sync payload sent from the client to the server and returned after
|
||||
/// server-side merge. Contains all data needed to reconcile two instances.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SyncPayload {
|
||||
/// Identifies the owning player. Must match the authenticated user.
|
||||
pub user_id: Uuid,
|
||||
/// Cumulative game statistics.
|
||||
pub stats: StatsSnapshot,
|
||||
/// Per-achievement unlock records.
|
||||
pub achievements: Vec<AchievementRecord>,
|
||||
/// XP, level, cosmetic unlocks, and daily/weekly progress.
|
||||
pub progress: PlayerProgress,
|
||||
/// Wall-clock time of the last local modification.
|
||||
pub last_modified: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Response returned by the sync server after merging.
|
||||
/// Response returned by the sync server after a pull or push operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SyncResponse {
|
||||
/// The merged payload that the client should save locally.
|
||||
pub merged: SyncPayload,
|
||||
/// The server's current wall-clock time (useful for clock-skew detection).
|
||||
pub server_time: DateTime<Utc>,
|
||||
/// Fields where local and remote values differed and could not be merged
|
||||
/// deterministically. Returned for display purposes — data is never
|
||||
/// silently discarded.
|
||||
pub conflicts: Vec<ConflictReport>,
|
||||
}
|
||||
|
||||
/// Describes a single field where local and remote values diverged in a way
|
||||
/// that the merge function could not resolve automatically.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConflictReport {
|
||||
/// Dot-separated field path, e.g. `"win_streak_current"`.
|
||||
pub field: String,
|
||||
/// Human-readable representation of the local value.
|
||||
pub local_value: String,
|
||||
/// Human-readable representation of the remote value.
|
||||
pub remote_value: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daily challenge / leaderboard types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Describes today's daily challenge, returned by `GET /api/daily-challenge`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChallengeGoal {
|
||||
/// Date this challenge applies to, formatted as `"YYYY-MM-DD"`.
|
||||
pub date: String,
|
||||
/// Deterministic RNG seed for this date's deal — identical for all players.
|
||||
pub seed: u64,
|
||||
/// Human-readable description of the goal, e.g. "Win in under 5 minutes".
|
||||
pub description: String,
|
||||
/// Optional target score required to complete the challenge.
|
||||
pub target_score: Option<i32>,
|
||||
/// Optional maximum allowed time in seconds to complete the challenge.
|
||||
pub max_time_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// A single row from the server leaderboard, returned by `GET /api/leaderboard`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LeaderboardEntry {
|
||||
/// Display name chosen by the player at opt-in time.
|
||||
pub display_name: String,
|
||||
/// The player's best single-game score.
|
||||
pub best_score: Option<i32>,
|
||||
/// The player's fastest win time in seconds.
|
||||
pub best_time_secs: Option<u64>,
|
||||
/// When this entry was last recorded.
|
||||
pub recorded_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Errors returned by the sync server in `application/json` error bodies.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
/// The request could not be authenticated (missing or invalid JWT).
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
/// The supplied credentials were incorrect.
|
||||
#[error("invalid credentials")]
|
||||
InvalidCredentials,
|
||||
/// A username that was requested for registration is already taken.
|
||||
#[error("username already taken")]
|
||||
UsernameTaken,
|
||||
/// The request payload was too large (> 1 MB).
|
||||
#[error("payload too large")]
|
||||
PayloadTooLarge,
|
||||
/// The request body could not be parsed.
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
/// An unexpected server-side error occurred.
|
||||
#[error("internal server error")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
//! Pure merge logic for sync payloads.
|
||||
//!
|
||||
//! All functions are free of I/O and side effects — safe to call from any
|
||||
//! context including unit tests and the Bevy main thread.
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload};
|
||||
use crate::progress::level_for_xp;
|
||||
|
||||
/// Merge two [`SyncPayload`]s into a single authoritative result.
|
||||
///
|
||||
/// The merge strategy is additive and conflict-free for most fields:
|
||||
/// - Counters: take the maximum (games_played, games_won, etc.)
|
||||
/// - Best records: take the minimum for times, maximum for scores/xp
|
||||
/// - Achievements: union by id, preserving the earliest `unlock_date`
|
||||
/// - Cosmetic unlocks: union of both vectors
|
||||
/// - Level: recomputed from merged `total_xp`
|
||||
///
|
||||
/// Fields that cannot be merged deterministically (e.g. diverged streak
|
||||
/// counts) are recorded in [`ConflictReport`] entries returned alongside
|
||||
/// the merged payload. Data is never silently discarded.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use solitaire_sync::{SyncPayload, StatsSnapshot, PlayerProgress, merge};
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// let a = SyncPayload {
|
||||
/// user_id: Uuid::nil(),
|
||||
/// stats: StatsSnapshot { games_played: 5, ..Default::default() },
|
||||
/// achievements: vec![],
|
||||
/// progress: PlayerProgress::default(),
|
||||
/// last_modified: chrono::Utc::now(),
|
||||
/// };
|
||||
/// let b = SyncPayload {
|
||||
/// user_id: Uuid::nil(),
|
||||
/// stats: StatsSnapshot { games_played: 3, ..Default::default() },
|
||||
/// achievements: vec![],
|
||||
/// progress: PlayerProgress::default(),
|
||||
/// last_modified: chrono::Utc::now(),
|
||||
/// };
|
||||
/// let (merged, conflicts) = merge(&a, &b);
|
||||
/// assert_eq!(merged.stats.games_played, 5);
|
||||
/// assert!(conflicts.is_empty());
|
||||
/// ```
|
||||
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> (SyncPayload, Vec<ConflictReport>) {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
let stats = merge_stats(&local.stats, &remote.stats, &mut conflicts);
|
||||
let achievements = merge_achievements(&local.achievements, &remote.achievements);
|
||||
let progress = merge_progress(&local.progress, &remote.progress, &mut conflicts);
|
||||
|
||||
let merged = SyncPayload {
|
||||
user_id: local.user_id,
|
||||
stats,
|
||||
achievements,
|
||||
progress,
|
||||
last_modified: Utc::now(),
|
||||
};
|
||||
|
||||
(merged, conflicts)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn merge_stats(
|
||||
local: &StatsSnapshot,
|
||||
remote: &StatsSnapshot,
|
||||
conflicts: &mut Vec<ConflictReport>,
|
||||
) -> StatsSnapshot {
|
||||
// win_streak_current cannot be merged deterministically — record conflict
|
||||
// but take the higher value as a best-effort resolution.
|
||||
if local.win_streak_current != remote.win_streak_current {
|
||||
conflicts.push(ConflictReport {
|
||||
field: "win_streak_current".to_string(),
|
||||
local_value: local.win_streak_current.to_string(),
|
||||
remote_value: remote.win_streak_current.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let merged_games_won = local.games_won.max(remote.games_won);
|
||||
let merged_games_played = local.games_played.max(remote.games_played);
|
||||
|
||||
// Recompute average time from the merged totals. If no wins yet, keep 0.
|
||||
let avg_time_seconds = if merged_games_won == 0 {
|
||||
0
|
||||
} else {
|
||||
// Use whichever side has more wins to approximate total time, then blend.
|
||||
// We don't have total_time stored, so we reconstruct it from avg * count.
|
||||
let local_total = local.avg_time_seconds as u128 * local.games_won as u128;
|
||||
let remote_total = remote.avg_time_seconds as u128 * remote.games_won as u128;
|
||||
// Take max total time (conservative — avoids underestimating total play time).
|
||||
let best_total = local_total.max(remote_total);
|
||||
(best_total / merged_games_won as u128) as u64
|
||||
};
|
||||
|
||||
StatsSnapshot {
|
||||
games_played: merged_games_played,
|
||||
games_won: merged_games_won,
|
||||
games_lost: local.games_lost.max(remote.games_lost),
|
||||
win_streak_current: local.win_streak_current.max(remote.win_streak_current),
|
||||
win_streak_best: local.win_streak_best.max(remote.win_streak_best),
|
||||
avg_time_seconds,
|
||||
fastest_win_seconds: local.fastest_win_seconds.min(remote.fastest_win_seconds),
|
||||
lifetime_score: local.lifetime_score.max(remote.lifetime_score),
|
||||
best_single_score: local.best_single_score.max(remote.best_single_score),
|
||||
draw_one_wins: local.draw_one_wins.max(remote.draw_one_wins),
|
||||
draw_three_wins: local.draw_three_wins.max(remote.draw_three_wins),
|
||||
last_modified: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Achievements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Union of local and remote achievement records.
|
||||
///
|
||||
/// - Achievements never disappear from the merged set.
|
||||
/// - If both sides have an achievement unlocked, the *earliest* `unlock_date`
|
||||
/// is preserved.
|
||||
/// - If only one side has an achievement unlocked, it is carried forward.
|
||||
fn merge_achievements(
|
||||
local: &[AchievementRecord],
|
||||
remote: &[AchievementRecord],
|
||||
) -> Vec<AchievementRecord> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut map: HashMap<&str, AchievementRecord> = HashMap::new();
|
||||
|
||||
// Insert all local records first.
|
||||
for rec in local {
|
||||
map.insert(rec.id.as_str(), rec.clone());
|
||||
}
|
||||
|
||||
// Merge in remote records.
|
||||
for remote_rec in remote {
|
||||
match map.get_mut(remote_rec.id.as_str()) {
|
||||
Some(existing) => {
|
||||
// Merge: once unlocked, never lock again.
|
||||
if remote_rec.unlocked && !existing.unlocked {
|
||||
// Remote is unlocked but local isn't — adopt remote unlock.
|
||||
existing.unlocked = true;
|
||||
existing.unlock_date = remote_rec.unlock_date;
|
||||
existing.reward_granted = remote_rec.reward_granted;
|
||||
} else if remote_rec.unlocked && existing.unlocked {
|
||||
// Both unlocked — keep the earlier date.
|
||||
match (existing.unlock_date, remote_rec.unlock_date) {
|
||||
(Some(local_dt), Some(remote_dt)) if remote_dt < local_dt => {
|
||||
existing.unlock_date = Some(remote_dt);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// reward_granted: true if either side granted it.
|
||||
existing.reward_granted = existing.reward_granted || remote_rec.reward_granted;
|
||||
}
|
||||
// If only local is unlocked — nothing changes.
|
||||
}
|
||||
None => {
|
||||
// Remote has an achievement that local doesn't know about.
|
||||
map.insert(remote_rec.id.as_str(), remote_rec.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result: Vec<AchievementRecord> = map.into_values().collect();
|
||||
result.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn merge_progress(
|
||||
local: &PlayerProgress,
|
||||
remote: &PlayerProgress,
|
||||
conflicts: &mut Vec<ConflictReport>,
|
||||
) -> PlayerProgress {
|
||||
// daily_challenge_streak cannot be merged deterministically.
|
||||
if local.daily_challenge_streak != remote.daily_challenge_streak {
|
||||
conflicts.push(ConflictReport {
|
||||
field: "daily_challenge_streak".to_string(),
|
||||
local_value: local.daily_challenge_streak.to_string(),
|
||||
remote_value: remote.daily_challenge_streak.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let total_xp = local.total_xp.max(remote.total_xp);
|
||||
|
||||
// Union cosmetic unlocks.
|
||||
let unlocked_card_backs = union_usize_vecs(&local.unlocked_card_backs, &remote.unlocked_card_backs);
|
||||
let unlocked_backgrounds =
|
||||
union_usize_vecs(&local.unlocked_backgrounds, &remote.unlocked_backgrounds);
|
||||
|
||||
// Keep the most recently completed daily challenge date (latest).
|
||||
let daily_challenge_last_completed =
|
||||
match (local.daily_challenge_last_completed, remote.daily_challenge_last_completed) {
|
||||
(Some(l), Some(r)) => Some(l.max(r)),
|
||||
(Some(l), None) => Some(l),
|
||||
(None, Some(r)) => Some(r),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
// Take the higher streak as a best-effort resolution.
|
||||
let daily_challenge_streak =
|
||||
local.daily_challenge_streak.max(remote.daily_challenge_streak);
|
||||
|
||||
// weekly_goal_progress: use whichever side has the more recent ISO week key.
|
||||
let (weekly_goal_week_iso, weekly_goal_progress) =
|
||||
match (&local.weekly_goal_week_iso, &remote.weekly_goal_week_iso) {
|
||||
(Some(l), Some(r)) if r > l => {
|
||||
(remote.weekly_goal_week_iso.clone(), remote.weekly_goal_progress.clone())
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
(local.weekly_goal_week_iso.clone(), local.weekly_goal_progress.clone())
|
||||
}
|
||||
(Some(_), None) => {
|
||||
(local.weekly_goal_week_iso.clone(), local.weekly_goal_progress.clone())
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
(remote.weekly_goal_week_iso.clone(), remote.weekly_goal_progress.clone())
|
||||
}
|
||||
(None, None) => (None, Default::default()),
|
||||
};
|
||||
|
||||
// Challenge index: take the higher (further ahead in challenge progression).
|
||||
let challenge_index = local.challenge_index.max(remote.challenge_index);
|
||||
|
||||
PlayerProgress {
|
||||
total_xp,
|
||||
level: level_for_xp(total_xp),
|
||||
daily_challenge_last_completed,
|
||||
daily_challenge_streak,
|
||||
weekly_goal_progress,
|
||||
weekly_goal_week_iso,
|
||||
unlocked_card_backs,
|
||||
unlocked_backgrounds,
|
||||
challenge_index,
|
||||
last_modified: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the sorted union of two `Vec<usize>` slices with duplicates removed.
|
||||
fn union_usize_vecs(a: &[usize], b: &[usize]) -> Vec<usize> {
|
||||
use std::collections::BTreeSet;
|
||||
let set: BTreeSet<usize> = a.iter().chain(b.iter()).copied().collect();
|
||||
set.into_iter().collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{Duration, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload};
|
||||
|
||||
fn make_payload(stats: StatsSnapshot, achievements: Vec<AchievementRecord>, progress: PlayerProgress) -> SyncPayload {
|
||||
SyncPayload {
|
||||
user_id: Uuid::nil(),
|
||||
stats,
|
||||
achievements,
|
||||
progress,
|
||||
last_modified: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_payload() -> SyncPayload {
|
||||
make_payload(StatsSnapshot::default(), vec![], PlayerProgress::default())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Idempotency
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn merge_is_idempotent_for_equal_payloads() {
|
||||
let mut a = default_payload();
|
||||
a.stats.games_played = 10;
|
||||
a.stats.games_won = 5;
|
||||
a.stats.fastest_win_seconds = 120;
|
||||
a.stats.lifetime_score = 5000;
|
||||
a.progress.total_xp = 2000;
|
||||
a.progress.unlocked_card_backs = vec![0, 1];
|
||||
|
||||
let (merged, conflicts) = merge(&a, &a);
|
||||
|
||||
assert_eq!(merged.stats.games_played, 10);
|
||||
assert_eq!(merged.stats.games_won, 5);
|
||||
assert_eq!(merged.stats.fastest_win_seconds, 120);
|
||||
assert_eq!(merged.stats.lifetime_score, 5000);
|
||||
assert_eq!(merged.progress.total_xp, 2000);
|
||||
assert_eq!(merged.progress.unlocked_card_backs, vec![0, 1]);
|
||||
// Identical payloads produce no conflicts.
|
||||
assert!(conflicts.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stats merge
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn stats_games_played_takes_max() {
|
||||
let mut local = default_payload();
|
||||
local.stats.games_played = 20;
|
||||
let mut remote = default_payload();
|
||||
remote.stats.games_played = 15;
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.games_played, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_games_won_takes_max() {
|
||||
let mut local = default_payload();
|
||||
local.stats.games_won = 7;
|
||||
let mut remote = default_payload();
|
||||
remote.stats.games_won = 12;
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.games_won, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_fastest_win_takes_min() {
|
||||
let mut local = default_payload();
|
||||
local.stats.fastest_win_seconds = 300;
|
||||
let mut remote = default_payload();
|
||||
remote.stats.fastest_win_seconds = 120;
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.fastest_win_seconds, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_best_score_takes_max() {
|
||||
let mut local = default_payload();
|
||||
local.stats.best_single_score = 4000;
|
||||
let mut remote = default_payload();
|
||||
remote.stats.best_single_score = 6000;
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.best_single_score, 6000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn differing_win_streak_current_generates_conflict() {
|
||||
let mut local = default_payload();
|
||||
local.stats.win_streak_current = 3;
|
||||
let mut remote = default_payload();
|
||||
remote.stats.win_streak_current = 5;
|
||||
|
||||
let (merged, conflicts) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.win_streak_current, 5);
|
||||
assert!(
|
||||
conflicts.iter().any(|c| c.field == "win_streak_current"),
|
||||
"expected conflict report for win_streak_current"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_win_streak_current_produces_no_conflict() {
|
||||
let mut local = default_payload();
|
||||
local.stats.win_streak_current = 4;
|
||||
let mut remote = default_payload();
|
||||
remote.stats.win_streak_current = 4;
|
||||
|
||||
let (_, conflicts) = merge(&local, &remote);
|
||||
assert!(
|
||||
!conflicts.iter().any(|c| c.field == "win_streak_current"),
|
||||
"no conflict expected for matching streaks"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Achievement merge
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn achievements_are_never_removed() {
|
||||
let unlocked = {
|
||||
let mut r = AchievementRecord::locked("first_win");
|
||||
r.unlock(Utc::now());
|
||||
r
|
||||
};
|
||||
let local = make_payload(StatsSnapshot::default(), vec![unlocked.clone()], PlayerProgress::default());
|
||||
let remote = make_payload(StatsSnapshot::default(), vec![], PlayerProgress::default());
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert!(
|
||||
merged.achievements.iter().any(|a| a.id == "first_win" && a.unlocked),
|
||||
"unlocked achievement must survive merge even if absent from remote"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn achievements_remote_unlock_propagates_to_local() {
|
||||
let locked = AchievementRecord::locked("century");
|
||||
let mut unlocked = AchievementRecord::locked("century");
|
||||
unlocked.unlock(Utc::now());
|
||||
|
||||
let local = make_payload(StatsSnapshot::default(), vec![locked], PlayerProgress::default());
|
||||
let remote = make_payload(StatsSnapshot::default(), vec![unlocked.clone()], PlayerProgress::default());
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
let ach = merged.achievements.iter().find(|a| a.id == "century").expect("must exist");
|
||||
assert!(ach.unlocked);
|
||||
assert_eq!(ach.unlock_date, unlocked.unlock_date);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn achievements_earliest_unlock_date_wins_on_conflict() {
|
||||
let earlier = Utc::now() - Duration::hours(2);
|
||||
let later = Utc::now();
|
||||
|
||||
let mut local_rec = AchievementRecord::locked("speed_demon");
|
||||
local_rec.unlock(later);
|
||||
let mut remote_rec = AchievementRecord::locked("speed_demon");
|
||||
remote_rec.unlock(earlier);
|
||||
|
||||
let local = make_payload(StatsSnapshot::default(), vec![local_rec], PlayerProgress::default());
|
||||
let remote = make_payload(StatsSnapshot::default(), vec![remote_rec], PlayerProgress::default());
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
let ach = merged.achievements.iter().find(|a| a.id == "speed_demon").expect("must exist");
|
||||
assert_eq!(ach.unlock_date, Some(earlier), "earlier date must win");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn achievements_union_includes_both_sides() {
|
||||
let mut a1 = AchievementRecord::locked("first_win");
|
||||
a1.unlock(Utc::now());
|
||||
let mut a2 = AchievementRecord::locked("century");
|
||||
a2.unlock(Utc::now());
|
||||
|
||||
let local = make_payload(StatsSnapshot::default(), vec![a1], PlayerProgress::default());
|
||||
let remote = make_payload(StatsSnapshot::default(), vec![a2], PlayerProgress::default());
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.achievements.len(), 2);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Progress merge
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn progress_total_xp_takes_max() {
|
||||
let mut local = default_payload();
|
||||
local.progress.total_xp = 1500;
|
||||
local.progress.level = crate::progress::level_for_xp(1500);
|
||||
let mut remote = default_payload();
|
||||
remote.progress.total_xp = 2500;
|
||||
remote.progress.level = crate::progress::level_for_xp(2500);
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.progress.total_xp, 2500);
|
||||
assert_eq!(merged.progress.level, crate::progress::level_for_xp(2500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_unlocked_card_backs_are_union() {
|
||||
let mut local = default_payload();
|
||||
local.progress.unlocked_card_backs = vec![0, 1];
|
||||
let mut remote = default_payload();
|
||||
remote.progress.unlocked_card_backs = vec![0, 2];
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert!(merged.progress.unlocked_card_backs.contains(&0));
|
||||
assert!(merged.progress.unlocked_card_backs.contains(&1));
|
||||
assert!(merged.progress.unlocked_card_backs.contains(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_unlocked_backgrounds_are_union() {
|
||||
let mut local = default_payload();
|
||||
local.progress.unlocked_backgrounds = vec![0, 3];
|
||||
let mut remote = default_payload();
|
||||
remote.progress.unlocked_backgrounds = vec![0, 4];
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert!(merged.progress.unlocked_backgrounds.contains(&3));
|
||||
assert!(merged.progress.unlocked_backgrounds.contains(&4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn differing_daily_challenge_streak_generates_conflict() {
|
||||
let mut local = default_payload();
|
||||
local.progress.daily_challenge_streak = 5;
|
||||
let mut remote = default_payload();
|
||||
remote.progress.daily_challenge_streak = 3;
|
||||
|
||||
let (_, conflicts) = merge(&local, &remote);
|
||||
assert!(
|
||||
conflicts.iter().any(|c| c.field == "daily_challenge_streak"),
|
||||
"expected conflict for daily_challenge_streak"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_is_recomputed_from_merged_xp() {
|
||||
let mut local = default_payload();
|
||||
local.progress.total_xp = 4500; // level 9
|
||||
let mut remote = default_payload();
|
||||
remote.progress.total_xp = 5500; // level 10
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.progress.total_xp, 5500);
|
||||
assert_eq!(merged.progress.level, crate::progress::level_for_xp(5500));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Shared `PlayerProgress` definition — used by both the game client and the
|
||||
//! sync server.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Duration, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// XP-to-level calculation per ARCHITECTURE.md §13.
|
||||
///
|
||||
/// - Levels 1–10: `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
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted player progression state.
|
||||
///
|
||||
/// Mutation helpers such as `add_xp`, `record_daily_completion`, etc. are
|
||||
/// defined as inherent methods directly on this type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlayerProgress {
|
||||
/// Total XP accumulated across all games.
|
||||
pub total_xp: u64,
|
||||
/// Current player level, recomputed from `total_xp`.
|
||||
pub level: u32,
|
||||
/// Date of the last completed daily challenge, if any.
|
||||
pub daily_challenge_last_completed: Option<NaiveDate>,
|
||||
/// Current daily-challenge streak length.
|
||||
pub daily_challenge_streak: u32,
|
||||
/// Per-goal progress counters for the current ISO week.
|
||||
pub weekly_goal_progress: HashMap<String, u32>,
|
||||
/// ISO week key (e.g. `"2026-W17"`) the `weekly_goal_progress` counters
|
||||
/// belong to. Cleared when a new week begins.
|
||||
#[serde(default)]
|
||||
pub weekly_goal_week_iso: Option<String>,
|
||||
/// Indices of card-back designs the player has unlocked (index 0 is always unlocked).
|
||||
pub unlocked_card_backs: Vec<usize>,
|
||||
/// Indices of background designs the player has unlocked (index 0 is always unlocked).
|
||||
pub unlocked_backgrounds: Vec<usize>,
|
||||
/// Index of the next Challenge-mode seed to serve to this player.
|
||||
#[serde(default)]
|
||||
pub challenge_index: u32,
|
||||
/// Wall-clock time of the last modification (used for conflict detection).
|
||||
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],
|
||||
unlocked_backgrounds: vec![0],
|
||||
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 {
|
||||
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).
|
||||
///
|
||||
/// Returns `true` if this call recorded a fresh completion.
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Shared `StatsSnapshot` definition — used by both the game client and the
|
||||
//! sync server to represent cumulative player statistics.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cumulative game statistics that travel across the sync boundary.
|
||||
///
|
||||
/// Game-logic mutation helpers that depend on `solitaire_core` types (e.g.
|
||||
/// `update_on_win`) are provided via the `StatsExt` extension trait in
|
||||
/// `solitaire_data`. File I/O helpers also live in `solitaire_data::storage`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StatsSnapshot {
|
||||
/// Total number of games started (won + lost + abandoned).
|
||||
pub games_played: u32,
|
||||
/// Number of games won.
|
||||
pub games_won: u32,
|
||||
/// Number of games lost or abandoned.
|
||||
pub games_lost: u32,
|
||||
/// Current win streak length.
|
||||
pub win_streak_current: u32,
|
||||
/// All-time best win streak.
|
||||
pub win_streak_best: u32,
|
||||
/// Rolling average of win times in seconds.
|
||||
pub avg_time_seconds: u64,
|
||||
/// Fastest single win time in seconds. `u64::MAX` when no wins recorded yet.
|
||||
pub fastest_win_seconds: u64,
|
||||
/// Sum of all winning scores.
|
||||
pub lifetime_score: u64,
|
||||
/// Highest score achieved in a single game.
|
||||
pub best_single_score: u32,
|
||||
/// Wins achieved in Draw-One mode.
|
||||
pub draw_one_wins: u32,
|
||||
/// Wins achieved in Draw-Three mode.
|
||||
pub draw_three_wins: u32,
|
||||
/// Wall-clock time of the last modification (used for conflict detection).
|
||||
pub last_modified: DateTime<Utc>,
|
||||
}
|
||||
|
||||
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 {
|
||||
/// 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 0–100, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user