//! 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>, /// 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) -> 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) { if self.unlocked { return; } self.unlocked = true; self.unlock_date = Some(at); } } #[cfg(test)] mod tests { use super::*; #[test] fn locked_creates_an_unlocked_record() { let r = AchievementRecord::locked("first_win"); assert_eq!(r.id, "first_win"); assert!(!r.unlocked); assert!(r.unlock_date.is_none()); assert!(!r.reward_granted); } #[test] fn unlock_sets_unlocked_and_stores_timestamp() { let mut r = AchievementRecord::locked("first_win"); let ts = Utc::now(); r.unlock(ts); assert!(r.unlocked); assert_eq!(r.unlock_date, Some(ts)); } #[test] fn unlock_is_idempotent_and_preserves_earliest_date() { let mut r = AchievementRecord::locked("first_win"); let early = DateTime::UNIX_EPOCH; let later = Utc::now(); r.unlock(early); r.unlock(later); // should be a no-op assert_eq!(r.unlock_date, Some(early), "earliest unlock date must be preserved"); } }