Skip to main content

proof_engine/save/
profile.rs

1//! User profiles, play statistics, local leaderboards, unlockables, and
2//! the prestige system.
3
4use std::collections::HashMap;
5
6// ─────────────────────────────────────────────────────────────────────────────
7//  UserProfile
8// ─────────────────────────────────────────────────────────────────────────────
9
10/// A single user profile stored locally.
11#[derive(Debug, Clone, PartialEq)]
12pub struct UserProfile {
13    pub id: u64,
14    pub display_name: String,
15    /// Seed used to procedurally generate an avatar image.
16    pub avatar_seed: u64,
17    pub created_at: u64,
18    pub last_login: u64,
19    pub play_time_seconds: u64,
20    pub preferences: HashMap<String, String>,
21}
22
23impl UserProfile {
24    pub fn new(id: u64, display_name: impl Into<String>, created_at: u64) -> Self {
25        Self {
26            id,
27            display_name: display_name.into(),
28            avatar_seed: id.wrapping_mul(0x9E37_79B9_7F4A_7C15),
29            created_at,
30            last_login: created_at,
31            play_time_seconds: 0,
32            preferences: HashMap::new(),
33        }
34    }
35
36    pub fn set_preference(&mut self, key: impl Into<String>, value: impl Into<String>) {
37        self.preferences.insert(key.into(), value.into());
38    }
39
40    pub fn get_preference(&self, key: &str) -> Option<&str> {
41        self.preferences.get(key).map(|s| s.as_str())
42    }
43
44    /// Serialise to a simple binary blob (little-endian).
45    pub fn to_bytes(&self) -> Vec<u8> {
46        let mut out = Vec::new();
47        out.extend_from_slice(&self.id.to_le_bytes());
48        let name_bytes = self.display_name.as_bytes();
49        out.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
50        out.extend_from_slice(name_bytes);
51        out.extend_from_slice(&self.avatar_seed.to_le_bytes());
52        out.extend_from_slice(&self.created_at.to_le_bytes());
53        out.extend_from_slice(&self.last_login.to_le_bytes());
54        out.extend_from_slice(&self.play_time_seconds.to_le_bytes());
55        out.extend_from_slice(&(self.preferences.len() as u32).to_le_bytes());
56        for (k, v) in &self.preferences {
57            let kb = k.as_bytes();
58            let vb = v.as_bytes();
59            out.extend_from_slice(&(kb.len() as u32).to_le_bytes());
60            out.extend_from_slice(kb);
61            out.extend_from_slice(&(vb.len() as u32).to_le_bytes());
62            out.extend_from_slice(vb);
63        }
64        out
65    }
66
67    /// Deserialise from bytes produced by `to_bytes`.
68    pub fn from_bytes(data: &[u8]) -> Result<Self, String> {
69        let mut pos = 0usize;
70
71        macro_rules! read_u32 {
72            () => {{
73                if pos + 4 > data.len() { return Err("truncated u32".into()); }
74                let v = u32::from_le_bytes([data[pos], data[pos+1], data[pos+2], data[pos+3]]);
75                pos += 4;
76                v
77            }};
78        }
79        macro_rules! read_u64 {
80            () => {{
81                if pos + 8 > data.len() { return Err("truncated u64".into()); }
82                let v = u64::from_le_bytes([
83                    data[pos], data[pos+1], data[pos+2], data[pos+3],
84                    data[pos+4], data[pos+5], data[pos+6], data[pos+7],
85                ]);
86                pos += 8;
87                v
88            }};
89        }
90        macro_rules! read_string {
91            () => {{
92                let len = read_u32!() as usize;
93                if pos + len > data.len() { return Err("truncated string".into()); }
94                let s = std::str::from_utf8(&data[pos..pos+len])
95                    .map_err(|e| e.to_string())?.to_owned();
96                pos += len;
97                s
98            }};
99        }
100
101        let id              = read_u64!();
102        let display_name    = read_string!();
103        let avatar_seed     = read_u64!();
104        let created_at      = read_u64!();
105        let last_login      = read_u64!();
106        let play_time       = read_u64!();
107        let pref_count      = read_u32!() as usize;
108
109        let mut preferences = HashMap::new();
110        for _ in 0..pref_count {
111            let k = read_string!();
112            let v = read_string!();
113            preferences.insert(k, v);
114        }
115
116        Ok(Self {
117            id,
118            display_name,
119            avatar_seed,
120            created_at,
121            last_login,
122            play_time_seconds: play_time,
123            preferences,
124        })
125    }
126}
127
128// ─────────────────────────────────────────────────────────────────────────────
129//  ProfileManager
130// ─────────────────────────────────────────────────────────────────────────────
131
132/// Manages up to 8 local user profiles.
133pub struct ProfileManager {
134    profiles: Vec<UserProfile>,
135    current_index: Option<usize>,
136    next_id: u64,
137}
138
139impl ProfileManager {
140    pub const MAX_PROFILES: usize = 8;
141
142    pub fn new() -> Self {
143        Self { profiles: Vec::new(), current_index: None, next_id: 1 }
144    }
145
146    /// Create a new profile with the given display name.
147    pub fn create(&mut self, display_name: impl Into<String>, timestamp: u64) -> Result<u64, String> {
148        if self.profiles.len() >= Self::MAX_PROFILES {
149            return Err(format!("maximum {} profiles reached", Self::MAX_PROFILES));
150        }
151        let id = self.next_id;
152        self.next_id += 1;
153        let profile = UserProfile::new(id, display_name, timestamp);
154        self.profiles.push(profile);
155        if self.current_index.is_none() {
156            self.current_index = Some(0);
157        }
158        Ok(id)
159    }
160
161    /// Delete a profile by ID.
162    pub fn delete(&mut self, id: u64) -> Result<(), String> {
163        let idx = self.profiles.iter().position(|p| p.id == id)
164            .ok_or_else(|| format!("profile {id} not found"))?;
165        self.profiles.remove(idx);
166        // Fix current index
167        match self.current_index {
168            Some(cur) if cur == idx => {
169                self.current_index = if self.profiles.is_empty() { None } else { Some(0) };
170            }
171            Some(cur) if cur > idx => {
172                self.current_index = Some(cur - 1);
173            }
174            _ => {}
175        }
176        Ok(())
177    }
178
179    /// Switch to a profile by ID.
180    pub fn switch(&mut self, id: u64) -> Result<(), String> {
181        let idx = self.profiles.iter().position(|p| p.id == id)
182            .ok_or_else(|| format!("profile {id} not found"))?;
183        self.current_index = Some(idx);
184        Ok(())
185    }
186
187    /// Get the currently active profile.
188    pub fn current(&self) -> Option<&UserProfile> {
189        self.current_index.and_then(|i| self.profiles.get(i))
190    }
191
192    /// Get a mutable reference to the currently active profile.
193    pub fn current_mut(&mut self) -> Option<&mut UserProfile> {
194        self.current_index.and_then(|i| self.profiles.get_mut(i))
195    }
196
197    pub fn list(&self) -> Vec<&UserProfile> {
198        self.profiles.iter().collect()
199    }
200
201    pub fn get_by_id(&self, id: u64) -> Option<&UserProfile> {
202        self.profiles.iter().find(|p| p.id == id)
203    }
204
205    pub fn get_by_id_mut(&mut self, id: u64) -> Option<&mut UserProfile> {
206        self.profiles.iter_mut().find(|p| p.id == id)
207    }
208
209    pub fn count(&self) -> usize {
210        self.profiles.len()
211    }
212
213    /// Serialise all profiles to bytes.
214    pub fn to_bytes(&self) -> Vec<u8> {
215        let mut out = Vec::new();
216        out.extend_from_slice(&(self.profiles.len() as u32).to_le_bytes());
217        out.extend_from_slice(&self.next_id.to_le_bytes());
218        let cur = self.current_index.map(|i| i as u64).unwrap_or(u64::MAX);
219        out.extend_from_slice(&cur.to_le_bytes());
220        for p in &self.profiles {
221            let pb = p.to_bytes();
222            out.extend_from_slice(&(pb.len() as u32).to_le_bytes());
223            out.extend_from_slice(&pb);
224        }
225        out
226    }
227
228    /// Deserialise from bytes produced by `to_bytes`.
229    pub fn from_bytes(data: &[u8]) -> Result<Self, String> {
230        if data.len() < 20 {
231            return Err("ProfileManager bytes too short".into());
232        }
233        let count = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
234        let next_id = u64::from_le_bytes([
235            data[4], data[5], data[6], data[7],
236            data[8], data[9], data[10], data[11],
237        ]);
238        let cur_raw = u64::from_le_bytes([
239            data[12], data[13], data[14], data[15],
240            data[16], data[17], data[18], data[19],
241        ]);
242        let current_index = if cur_raw == u64::MAX { None } else { Some(cur_raw as usize) };
243
244        let mut pos = 20usize;
245        let mut profiles = Vec::with_capacity(count);
246        for _ in 0..count {
247            if pos + 4 > data.len() { return Err("truncated profile length".into()); }
248            let len = u32::from_le_bytes([data[pos], data[pos+1], data[pos+2], data[pos+3]]) as usize;
249            pos += 4;
250            if pos + len > data.len() { return Err("truncated profile data".into()); }
251            let p = UserProfile::from_bytes(&data[pos..pos+len])?;
252            profiles.push(p);
253            pos += len;
254        }
255
256        Ok(Self { profiles, current_index, next_id })
257    }
258}
259
260impl Default for ProfileManager {
261    fn default() -> Self {
262        Self::new()
263    }
264}
265
266// ─────────────────────────────────────────────────────────────────────────────
267//  StatisticsRecord
268// ─────────────────────────────────────────────────────────────────────────────
269
270/// Lifetime play statistics for a profile.
271#[derive(Debug, Clone, Default)]
272pub struct StatisticsRecord {
273    pub enemies_killed: HashMap<String, u32>,
274    pub damage_dealt: u64,
275    pub damage_received: u64,
276    pub distance_traveled: f64,
277    pub items_collected: u64,
278    pub deaths: u32,
279    pub highest_combo: u32,
280    pub max_damage_hit: u64,
281    pub areas_visited: Vec<String>,
282}
283
284impl StatisticsRecord {
285    pub fn new() -> Self {
286        Self::default()
287    }
288
289    pub fn record_kill(&mut self, enemy_type: impl Into<String>) {
290        *self.enemies_killed.entry(enemy_type.into()).or_insert(0) += 1;
291    }
292
293    pub fn record_damage_dealt(&mut self, amount: u64) {
294        self.damage_dealt += amount;
295        if amount > self.max_damage_hit {
296            self.max_damage_hit = amount;
297        }
298    }
299
300    pub fn record_damage_received(&mut self, amount: u64) {
301        self.damage_received += amount;
302    }
303
304    pub fn record_travel(&mut self, distance: f64) {
305        self.distance_traveled += distance;
306    }
307
308    pub fn collect_item(&mut self) {
309        self.items_collected += 1;
310    }
311
312    pub fn record_death(&mut self) {
313        self.deaths += 1;
314    }
315
316    pub fn record_combo(&mut self, combo: u32) {
317        if combo > self.highest_combo {
318            self.highest_combo = combo;
319        }
320    }
321
322    pub fn visit_area(&mut self, area: impl Into<String>) {
323        let area = area.into();
324        if !self.areas_visited.contains(&area) {
325            self.areas_visited.push(area);
326        }
327    }
328
329    pub fn total_kills(&self) -> u32 {
330        self.enemies_killed.values().sum()
331    }
332}
333
334// ─────────────────────────────────────────────────────────────────────────────
335//  Leaderboard
336// ─────────────────────────────────────────────────────────────────────────────
337
338/// A single leaderboard entry.
339#[derive(Debug, Clone, PartialEq)]
340pub struct LeaderboardEntry {
341    pub score: u64,
342    pub player_name: String,
343    pub metadata: HashMap<String, String>,
344}
345
346/// Local top-N leaderboard for a level/mode.
347pub struct Leaderboard {
348    pub name: String,
349    entries: Vec<LeaderboardEntry>,
350    max_entries: usize,
351}
352
353impl Leaderboard {
354    pub fn new(name: impl Into<String>, max_entries: usize) -> Self {
355        Self { name: name.into(), entries: Vec::new(), max_entries }
356    }
357
358    /// Submit a score.  The leaderboard stays sorted (highest first) and
359    /// truncated to `max_entries`.
360    pub fn submit(&mut self, score: u64, player_name: impl Into<String>, metadata: HashMap<String, String>) {
361        self.entries.push(LeaderboardEntry { score, player_name: player_name.into(), metadata });
362        self.entries.sort_by(|a, b| b.score.cmp(&a.score));
363        self.entries.truncate(self.max_entries);
364    }
365
366    pub fn get_top(&self, n: usize) -> &[LeaderboardEntry] {
367        let end = n.min(self.entries.len());
368        &self.entries[..end]
369    }
370
371    /// 1-based rank of the given score; returns `entries.len() + 1` if not on board.
372    pub fn rank_of(&self, score: u64) -> usize {
373        self.entries.iter().position(|e| e.score == score)
374            .map(|i| i + 1)
375            .unwrap_or(self.entries.len() + 1)
376    }
377
378    /// Fraction (0.0–1.0) of entries the given score beats.
379    pub fn percentile_of(&self, score: u64) -> f32 {
380        if self.entries.is_empty() { return 1.0; }
381        let beaten = self.entries.iter().filter(|e| e.score < score).count();
382        beaten as f32 / self.entries.len() as f32
383    }
384
385    pub fn len(&self) -> usize {
386        self.entries.len()
387    }
388
389    pub fn is_empty(&self) -> bool {
390        self.entries.is_empty()
391    }
392}
393
394// ─────────────────────────────────────────────────────────────────────────────
395//  UnlockCondition / Unlockable / UnlockRegistry
396// ─────────────────────────────────────────────────────────────────────────────
397
398/// Condition that must be met to unlock something.
399#[derive(Debug, Clone, PartialEq)]
400pub enum UnlockCondition {
401    StatThreshold { stat: String, threshold: u64 },
402    AchievementCompleted(String),
403    ItemCollected(String),
404    LevelReached(u32),
405    Manual,
406}
407
408/// A single unlockable item, achievement, or cosmetic.
409#[derive(Debug, Clone)]
410pub struct Unlockable {
411    pub id: String,
412    pub name: String,
413    pub unlock_condition: UnlockCondition,
414    pub unlocked: bool,
415    /// If true, the item is not shown until unlocked.
416    pub hidden_until_unlocked: bool,
417}
418
419impl Unlockable {
420    pub fn new(
421        id: impl Into<String>,
422        name: impl Into<String>,
423        condition: UnlockCondition,
424        hidden: bool,
425    ) -> Self {
426        Self {
427            id: id.into(),
428            name: name.into(),
429            unlock_condition: condition,
430            unlocked: false,
431            hidden_until_unlocked: hidden,
432        }
433    }
434
435    pub fn unlock(&mut self) {
436        self.unlocked = true;
437    }
438
439    pub fn is_visible(&self) -> bool {
440        self.unlocked || !self.hidden_until_unlocked
441    }
442}
443
444/// Registry of all unlockables.
445pub struct UnlockRegistry {
446    items: HashMap<String, Unlockable>,
447}
448
449impl UnlockRegistry {
450    pub fn new() -> Self {
451        Self { items: HashMap::new() }
452    }
453
454    pub fn register(&mut self, item: Unlockable) {
455        self.items.insert(item.id.clone(), item);
456    }
457
458    pub fn unlock(&mut self, id: &str) -> bool {
459        if let Some(item) = self.items.get_mut(id) {
460            item.unlock();
461            true
462        } else {
463            false
464        }
465    }
466
467    pub fn is_unlocked(&self, id: &str) -> bool {
468        self.items.get(id).map_or(false, |i| i.unlocked)
469    }
470
471    /// Check conditions against current stats and auto-unlock anything that qualifies.
472    pub fn check_and_unlock(&mut self, stats: &StatisticsRecord, player_level: u32) -> Vec<String> {
473        let mut newly_unlocked = Vec::new();
474        for item in self.items.values_mut() {
475            if item.unlocked { continue; }
476            let should_unlock = match &item.unlock_condition {
477                UnlockCondition::StatThreshold { stat, threshold } => {
478                    match stat.as_str() {
479                        "kills"            => stats.total_kills() as u64 >= *threshold,
480                        "deaths"           => stats.deaths as u64 >= *threshold,
481                        "damage_dealt"     => stats.damage_dealt >= *threshold,
482                        "items_collected"  => stats.items_collected >= *threshold,
483                        _                  => false,
484                    }
485                }
486                UnlockCondition::LevelReached(lvl) => player_level >= *lvl,
487                UnlockCondition::Manual => false,
488                UnlockCondition::AchievementCompleted(_) => false,
489                UnlockCondition::ItemCollected(_) => false,
490            };
491            if should_unlock {
492                item.unlocked = true;
493                newly_unlocked.push(item.id.clone());
494            }
495        }
496        newly_unlocked
497    }
498
499    pub fn visible_items(&self) -> Vec<&Unlockable> {
500        self.items.values().filter(|i| i.is_visible()).collect()
501    }
502
503    pub fn all_items(&self) -> Vec<&Unlockable> {
504        self.items.values().collect()
505    }
506}
507
508impl Default for UnlockRegistry {
509    fn default() -> Self {
510        Self::new()
511    }
512}
513
514// ─────────────────────────────────────────────────────────────────────────────
515//  PrestigeSystem
516// ─────────────────────────────────────────────────────────────────────────────
517
518const MIN_LEVEL_FOR_PRESTIGE: u32 = 50;
519const PRESTIGE_CURRENCY_PER_LEVEL: u64 = 10;
520
521/// Tracks prestige level, cumulative prestige currency, and bonus multipliers.
522pub struct PrestigeSystem {
523    pub prestige_level: u32,
524    pub prestige_currency: u64,
525    /// Cumulative currency earned across all prestiges.
526    pub lifetime_currency: u64,
527    pub current_player_level: u32,
528}
529
530impl PrestigeSystem {
531    pub fn new() -> Self {
532        Self {
533            prestige_level: 0,
534            prestige_currency: 0,
535            lifetime_currency: 0,
536            current_player_level: 1,
537        }
538    }
539
540    /// Whether the player is eligible to prestige.
541    pub fn can_prestige(&self) -> bool {
542        self.current_player_level >= MIN_LEVEL_FOR_PRESTIGE
543    }
544
545    /// Perform a prestige reset.
546    ///
547    /// Returns the amount of prestige currency earned.
548    pub fn prestige(&mut self) -> Result<u64, String> {
549        if !self.can_prestige() {
550            return Err(format!(
551                "must reach level {} to prestige (current: {})",
552                MIN_LEVEL_FOR_PRESTIGE, self.current_player_level
553            ));
554        }
555        let earned = self.current_player_level as u64 * PRESTIGE_CURRENCY_PER_LEVEL;
556        self.prestige_level += 1;
557        self.prestige_currency += earned;
558        self.lifetime_currency += earned;
559        self.current_player_level = 1; // reset
560        Ok(earned)
561    }
562
563    /// Bonus multiplier applied to experience/rewards based on prestige level.
564    pub fn get_bonus_multiplier(&self) -> f32 {
565        1.0 + (self.prestige_level as f32) * 0.1
566    }
567
568    /// Spend prestige currency; returns error if insufficient.
569    pub fn spend_currency(&mut self, amount: u64) -> Result<(), String> {
570        if self.prestige_currency < amount {
571            return Err(format!(
572                "insufficient prestige currency: have {}, need {}",
573                self.prestige_currency, amount
574            ));
575        }
576        self.prestige_currency -= amount;
577        Ok(())
578    }
579
580    pub fn set_player_level(&mut self, level: u32) {
581        self.current_player_level = level;
582    }
583}
584
585impl Default for PrestigeSystem {
586    fn default() -> Self {
587        Self::new()
588    }
589}
590
591// ─────────────────────────────────────────────────────────────────────────────
592//  Tests
593// ─────────────────────────────────────────────────────────────────────────────
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    // ── UserProfile ──────────────────────────────────────────────────────────
600
601    #[test]
602    fn test_profile_serialization_roundtrip() {
603        let mut p = UserProfile::new(1, "Hero", 1000);
604        p.set_preference("theme", "dark");
605        let bytes = p.to_bytes();
606        let restored = UserProfile::from_bytes(&bytes).unwrap();
607        assert_eq!(restored.id, p.id);
608        assert_eq!(restored.display_name, p.display_name);
609        assert_eq!(restored.get_preference("theme"), Some("dark"));
610    }
611
612    #[test]
613    fn test_profile_manager_create_and_list() {
614        let mut mgr = ProfileManager::new();
615        mgr.create("Alice", 100).unwrap();
616        mgr.create("Bob", 200).unwrap();
617        assert_eq!(mgr.count(), 2);
618        let names: Vec<&str> = mgr.list().iter().map(|p| p.display_name.as_str()).collect();
619        assert!(names.contains(&"Alice"));
620        assert!(names.contains(&"Bob"));
621    }
622
623    #[test]
624    fn test_profile_manager_max_8() {
625        let mut mgr = ProfileManager::new();
626        for i in 0..8 {
627            mgr.create(format!("P{i}"), i as u64).unwrap();
628        }
629        let result = mgr.create("Extra", 999);
630        assert!(result.is_err());
631    }
632
633    #[test]
634    fn test_profile_manager_switch_and_delete() {
635        let mut mgr = ProfileManager::new();
636        let id1 = mgr.create("Alice", 1).unwrap();
637        let id2 = mgr.create("Bob", 2).unwrap();
638        mgr.switch(id2).unwrap();
639        assert_eq!(mgr.current().unwrap().display_name, "Bob");
640        mgr.delete(id2).unwrap();
641        assert_eq!(mgr.count(), 1);
642        let _ = mgr.switch(id1);
643        assert_eq!(mgr.current().unwrap().display_name, "Alice");
644    }
645
646    #[test]
647    fn test_profile_manager_serialization() {
648        let mut mgr = ProfileManager::new();
649        mgr.create("Alice", 1).unwrap();
650        mgr.create("Bob", 2).unwrap();
651        let bytes = mgr.to_bytes();
652        let restored = ProfileManager::from_bytes(&bytes).unwrap();
653        assert_eq!(restored.count(), 2);
654    }
655
656    // ── StatisticsRecord ─────────────────────────────────────────────────────
657
658    #[test]
659    fn test_statistics_record() {
660        let mut stats = StatisticsRecord::new();
661        stats.record_kill("goblin");
662        stats.record_kill("goblin");
663        stats.record_kill("orc");
664        assert_eq!(stats.total_kills(), 3);
665        stats.record_damage_dealt(500);
666        stats.record_damage_dealt(1000);
667        assert_eq!(stats.max_damage_hit, 1000);
668        stats.record_travel(100.0);
669        stats.record_travel(50.0);
670        assert!((stats.distance_traveled - 150.0).abs() < 1e-6);
671        stats.visit_area("Forest");
672        stats.visit_area("Forest"); // dedup
673        assert_eq!(stats.areas_visited.len(), 1);
674    }
675
676    // ── Leaderboard ──────────────────────────────────────────────────────────
677
678    #[test]
679    fn test_leaderboard_submit_and_rank() {
680        let mut lb = Leaderboard::new("level_1", 5);
681        lb.submit(1000, "Alice", HashMap::new());
682        lb.submit(2000, "Bob", HashMap::new());
683        lb.submit(500, "Carol", HashMap::new());
684        assert_eq!(lb.get_top(3)[0].score, 2000);
685        assert_eq!(lb.rank_of(2000), 1);
686        assert_eq!(lb.rank_of(1000), 2);
687    }
688
689    #[test]
690    fn test_leaderboard_max_entries() {
691        let mut lb = Leaderboard::new("test", 3);
692        for s in [100u64, 200, 300, 400, 500] {
693            lb.submit(s, "p", HashMap::new());
694        }
695        assert_eq!(lb.len(), 3);
696        assert_eq!(lb.get_top(1)[0].score, 500);
697    }
698
699    #[test]
700    fn test_leaderboard_percentile() {
701        let mut lb = Leaderboard::new("test", 10);
702        for s in [100u64, 200, 300, 400] {
703            lb.submit(s, "p", HashMap::new());
704        }
705        // Score of 300 beats 100 and 200 → 2/4 = 0.5
706        let pct = lb.percentile_of(300);
707        assert!((pct - 0.5).abs() < 1e-5, "got {pct}");
708    }
709
710    // ── UnlockRegistry ───────────────────────────────────────────────────────
711
712    #[test]
713    fn test_unlock_registry_manual() {
714        let mut reg = UnlockRegistry::new();
715        reg.register(Unlockable::new("item_a", "Item A", UnlockCondition::Manual, false));
716        assert!(!reg.is_unlocked("item_a"));
717        reg.unlock("item_a");
718        assert!(reg.is_unlocked("item_a"));
719    }
720
721    #[test]
722    fn test_unlock_auto_by_kills() {
723        let mut reg = UnlockRegistry::new();
724        reg.register(Unlockable::new(
725            "killer",
726            "Killer Badge",
727            UnlockCondition::StatThreshold { stat: "kills".into(), threshold: 5 },
728            false,
729        ));
730        let mut stats = StatisticsRecord::new();
731        for _ in 0..5 { stats.record_kill("goblin"); }
732        let newly = reg.check_and_unlock(&stats, 1);
733        assert!(newly.contains(&"killer".to_string()));
734        assert!(reg.is_unlocked("killer"));
735    }
736
737    // ── PrestigeSystem ───────────────────────────────────────────────────────
738
739    #[test]
740    fn test_prestige_system_cannot_prestige_early() {
741        let mut ps = PrestigeSystem::new();
742        ps.set_player_level(10);
743        assert!(!ps.can_prestige());
744        assert!(ps.prestige().is_err());
745    }
746
747    #[test]
748    fn test_prestige_system_prestige() {
749        let mut ps = PrestigeSystem::new();
750        ps.set_player_level(50);
751        assert!(ps.can_prestige());
752        let earned = ps.prestige().unwrap();
753        assert_eq!(earned, 50 * 10);
754        assert_eq!(ps.prestige_level, 1);
755        assert_eq!(ps.current_player_level, 1);
756    }
757
758    #[test]
759    fn test_prestige_bonus_multiplier() {
760        let mut ps = PrestigeSystem::new();
761        ps.set_player_level(50);
762        assert!((ps.get_bonus_multiplier() - 1.0).abs() < 1e-6);
763        ps.prestige().unwrap();
764        assert!((ps.get_bonus_multiplier() - 1.1).abs() < 1e-5);
765    }
766
767    #[test]
768    fn test_prestige_spend_currency() {
769        let mut ps = PrestigeSystem::new();
770        ps.set_player_level(50);
771        ps.prestige().unwrap();
772        let available = ps.prestige_currency;
773        assert!(ps.spend_currency(available / 2).is_ok());
774        assert!(ps.spend_currency(available).is_err());
775    }
776}