Skip to main content

proof_engine/game/
achievements.rs

1//! Achievement, progression, daily challenge, and mastery systems.
2//!
3//! Complete implementation of achievement tracking, skill progression trees,
4//! daily/weekly challenges, and mastery level bonuses.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use super::SessionStats;
10
11// ─── Achievement Category ────────────────────────────────────────────────────────
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum AchievementCategory {
15    Combat,
16    Exploration,
17    Progression,
18    Collection,
19    Challenge,
20    Social,
21    Hidden,
22}
23
24impl AchievementCategory {
25    pub fn name(&self) -> &str {
26        match self {
27            AchievementCategory::Combat => "Combat",
28            AchievementCategory::Exploration => "Exploration",
29            AchievementCategory::Progression => "Progression",
30            AchievementCategory::Collection => "Collection",
31            AchievementCategory::Challenge => "Challenge",
32            AchievementCategory::Social => "Social",
33            AchievementCategory::Hidden => "Hidden",
34        }
35    }
36
37    pub fn icon(&self) -> char {
38        match self {
39            AchievementCategory::Combat => '⚔',
40            AchievementCategory::Exploration => '🗺',
41            AchievementCategory::Progression => '⬆',
42            AchievementCategory::Collection => '📦',
43            AchievementCategory::Challenge => '⚡',
44            AchievementCategory::Social => '👥',
45            AchievementCategory::Hidden => '?',
46        }
47    }
48
49    pub fn all() -> &'static [AchievementCategory] {
50        &[
51            AchievementCategory::Combat,
52            AchievementCategory::Exploration,
53            AchievementCategory::Progression,
54            AchievementCategory::Collection,
55            AchievementCategory::Challenge,
56            AchievementCategory::Social,
57            AchievementCategory::Hidden,
58        ]
59    }
60}
61
62// ─── Achievement Condition ───────────────────────────────────────────────────────
63
64#[derive(Debug, Clone)]
65pub enum AchievementCondition {
66    KillCount { enemy_type: String, count: u32 },
67    TotalKills(u32),
68    ReachLevel(u32),
69    CompleteQuests(u32),
70    CompleteAllQuests,
71    CollectItems(u32),
72    CollectRareItem,
73    MaxInventory,
74    DealDamage(f64),
75    TakeDamage(f64),
76    DealCritDamage(u32),
77    SpendGold(u64),
78    EarnGold(u64),
79    HaveGold(u64),
80    PlayTime(f64),
81    SurviveMinutes(f32),
82    Die(u32),
83    VisitLocations(u32),
84    DiscoverSecrets(u32),
85    OpenChests(u32),
86    UsedSkills(u32),
87    CastSpells(u32),
88    CraftItems(u32),
89    ScoreThreshold(u64),
90    ComboCount(u32),
91    PerfectClear,
92    WinStreak(u32),
93    WinWithoutDamage,
94    WinAtMinHealth(f32),
95    BossKills(u32),
96    KillBossUnderTime { boss_id: String, seconds: f32 },
97    ReachComboMultiplier(f32),
98    CollectAllSecretsInLevel,
99    CompleteWithClass { class_name: String },
100    Custom(String),
101}
102
103impl AchievementCondition {
104    pub fn description(&self) -> String {
105        match self {
106            AchievementCondition::KillCount { enemy_type, count } =>
107                format!("Kill {} {} enemies", count, enemy_type),
108            AchievementCondition::TotalKills(n) =>
109                format!("Kill {} enemies total", n),
110            AchievementCondition::ReachLevel(n) =>
111                format!("Reach level {}", n),
112            AchievementCondition::CompleteQuests(n) =>
113                format!("Complete {} quests", n),
114            AchievementCondition::CompleteAllQuests =>
115                "Complete all quests".to_string(),
116            AchievementCondition::CollectItems(n) =>
117                format!("Collect {} items", n),
118            AchievementCondition::CollectRareItem =>
119                "Find a rare or better item".to_string(),
120            AchievementCondition::MaxInventory =>
121                "Fill your inventory completely".to_string(),
122            AchievementCondition::DealDamage(n) =>
123                format!("Deal {:.0} total damage", n),
124            AchievementCondition::TakeDamage(n) =>
125                format!("Take {:.0} total damage", n),
126            AchievementCondition::DealCritDamage(n) =>
127                format!("Land {} critical hits", n),
128            AchievementCondition::SpendGold(n) =>
129                format!("Spend {} gold", n),
130            AchievementCondition::EarnGold(n) =>
131                format!("Earn {} gold", n),
132            AchievementCondition::HaveGold(n) =>
133                format!("Have {} gold at once", n),
134            AchievementCondition::PlayTime(secs) =>
135                format!("Play for {:.0} minutes", secs / 60.0),
136            AchievementCondition::SurviveMinutes(mins) =>
137                format!("Survive for {} minutes", mins),
138            AchievementCondition::Die(n) =>
139                format!("Die {} times", n),
140            AchievementCondition::VisitLocations(n) =>
141                format!("Visit {} locations", n),
142            AchievementCondition::DiscoverSecrets(n) =>
143                format!("Discover {} secrets", n),
144            AchievementCondition::OpenChests(n) =>
145                format!("Open {} chests", n),
146            AchievementCondition::UsedSkills(n) =>
147                format!("Use skills {} times", n),
148            AchievementCondition::CastSpells(n) =>
149                format!("Cast {} spells", n),
150            AchievementCondition::CraftItems(n) =>
151                format!("Craft {} items", n),
152            AchievementCondition::ScoreThreshold(n) =>
153                format!("Reach a score of {}", n),
154            AchievementCondition::ComboCount(n) =>
155                format!("Achieve a {} hit combo", n),
156            AchievementCondition::PerfectClear =>
157                "Clear a level without taking damage".to_string(),
158            AchievementCondition::WinStreak(n) =>
159                format!("Win {} games in a row", n),
160            AchievementCondition::WinWithoutDamage =>
161                "Win a game without taking any damage".to_string(),
162            AchievementCondition::WinAtMinHealth(pct) =>
163                format!("Win with less than {:.0}% health remaining", pct * 100.0),
164            AchievementCondition::BossKills(n) =>
165                format!("Defeat {} bosses", n),
166            AchievementCondition::KillBossUnderTime { boss_id, seconds } =>
167                format!("Defeat {} in under {:.0}s", boss_id, seconds),
168            AchievementCondition::ReachComboMultiplier(m) =>
169                format!("Reach a {:.1}x combo multiplier", m),
170            AchievementCondition::CollectAllSecretsInLevel =>
171                "Find all secrets in a single level".to_string(),
172            AchievementCondition::CompleteWithClass { class_name } =>
173                format!("Complete the game as a {}", class_name),
174            AchievementCondition::Custom(s) => s.clone(),
175        }
176    }
177
178    pub fn check(&self, stats: &SessionStats) -> bool {
179        match self {
180            AchievementCondition::TotalKills(n) => stats.enemies_killed >= *n,
181            AchievementCondition::DealDamage(n) => stats.damage_dealt >= *n,
182            AchievementCondition::TakeDamage(n) => stats.damage_taken >= *n,
183            AchievementCondition::DealCritDamage(n) => stats.critical_hits >= *n,
184            AchievementCondition::EarnGold(n) => stats.gold_earned >= *n,
185            AchievementCondition::SpendGold(n) => stats.gold_spent >= *n,
186            AchievementCondition::PlayTime(secs) => stats.playtime_secs >= *secs,
187            AchievementCondition::Die(n) => stats.deaths >= *n,
188            AchievementCondition::DiscoverSecrets(n) => stats.secrets_found >= *n,
189            AchievementCondition::OpenChests(n) => stats.chests_opened >= *n,
190            AchievementCondition::UsedSkills(n) => stats.skills_used >= *n,
191            AchievementCondition::CastSpells(n) => stats.spells_cast >= *n,
192            AchievementCondition::CraftItems(n) => stats.items_crafted >= *n,
193            AchievementCondition::CompleteQuests(n) => stats.quests_completed >= *n,
194            AchievementCondition::ScoreThreshold(n) => stats.highest_score >= *n,
195            AchievementCondition::ComboCount(n) => stats.highest_combo >= *n,
196            AchievementCondition::ReachLevel(n) => stats.max_level_reached >= *n,
197            AchievementCondition::CollectItems(n) => stats.items_collected >= *n,
198            AchievementCondition::BossKills(n) => stats.boss_kills >= *n,
199            AchievementCondition::WinWithoutDamage => stats.damage_taken == 0.0,
200            _ => false, // Conditions requiring external context return false here
201        }
202    }
203}
204
205// ─── Achievement ─────────────────────────────────────────────────────────────────
206
207#[derive(Debug, Clone)]
208pub struct Achievement {
209    pub id: String,
210    pub name: String,
211    pub description: String,
212    pub points: u32,
213    pub icon_char: char,
214    pub secret: bool,
215    pub category: AchievementCategory,
216    pub condition: AchievementCondition,
217    pub unlocked: bool,
218    pub unlock_date: Option<u64>,
219    pub progress: i64,
220    pub progress_max: i64,
221}
222
223impl Achievement {
224    pub fn new(
225        id: impl Into<String>,
226        name: impl Into<String>,
227        description: impl Into<String>,
228        points: u32,
229        icon_char: char,
230        category: AchievementCategory,
231        condition: AchievementCondition,
232    ) -> Self {
233        Self {
234            id: id.into(),
235            name: name.into(),
236            description: description.into(),
237            points,
238            icon_char,
239            secret: false,
240            category,
241            condition,
242            unlocked: false,
243            unlock_date: None,
244            progress: 0,
245            progress_max: 1,
246        }
247    }
248
249    pub fn secret(mut self) -> Self {
250        self.secret = true;
251        self
252    }
253
254    pub fn with_progress_max(mut self, max: i64) -> Self {
255        self.progress_max = max;
256        self
257    }
258
259    pub fn display_name(&self) -> &str {
260        if self.secret && !self.unlocked {
261            "???"
262        } else {
263            &self.name
264        }
265    }
266
267    pub fn display_description(&self) -> &str {
268        if self.secret && !self.unlocked {
269            "This achievement is secret."
270        } else {
271            &self.description
272        }
273    }
274
275    pub fn progress_fraction(&self) -> f32 {
276        if self.progress_max <= 0 {
277            return if self.unlocked { 1.0 } else { 0.0 };
278        }
279        (self.progress as f32 / self.progress_max as f32).clamp(0.0, 1.0)
280    }
281
282    pub fn unlock_now(&mut self) {
283        if !self.unlocked {
284            self.unlocked = true;
285            self.progress = self.progress_max;
286            self.unlock_date = Some(
287                SystemTime::now()
288                    .duration_since(UNIX_EPOCH)
289                    .unwrap_or_default()
290                    .as_secs()
291            );
292        }
293    }
294}
295
296// ─── Built-in Achievement List ───────────────────────────────────────────────────
297
298pub fn build_default_achievements() -> Vec<Achievement> {
299    vec![
300        // Combat
301        Achievement::new("first_blood", "First Blood", "Kill your first enemy.", 10, '⚔', AchievementCategory::Combat,
302            AchievementCondition::TotalKills(1)).with_progress_max(1),
303        Achievement::new("warrior", "Warrior", "Kill 100 enemies.", 25, '⚔', AchievementCategory::Combat,
304            AchievementCondition::TotalKills(100)).with_progress_max(100),
305        Achievement::new("slayer", "Slayer", "Kill 500 enemies.", 50, '⚔', AchievementCategory::Combat,
306            AchievementCondition::TotalKills(500)).with_progress_max(500),
307        Achievement::new("legend", "Legend", "Kill 2000 enemies.", 100, '⚔', AchievementCategory::Combat,
308            AchievementCondition::TotalKills(2000)).with_progress_max(2000),
309        Achievement::new("crit_expert", "Critical Expert", "Land 100 critical hits.", 30, '✦', AchievementCategory::Combat,
310            AchievementCondition::DealCritDamage(100)).with_progress_max(100),
311        Achievement::new("damage_dealer", "Damage Dealer", "Deal 10,000 total damage.", 40, '💥', AchievementCategory::Combat,
312            AchievementCondition::DealDamage(10000.0)).with_progress_max(10000),
313        Achievement::new("boss_slayer", "Boss Slayer", "Defeat 10 bosses.", 60, '👑', AchievementCategory::Combat,
314            AchievementCondition::BossKills(10)).with_progress_max(10),
315        Achievement::new("untouchable", "Untouchable", "Win a game without taking damage.", 100, '🛡', AchievementCategory::Challenge,
316            AchievementCondition::WinWithoutDamage).secret(),
317        Achievement::new("combo_beginner", "Combo Beginner", "Achieve a 10-hit combo.", 15, '🔥', AchievementCategory::Combat,
318            AchievementCondition::ComboCount(10)).with_progress_max(10),
319        Achievement::new("combo_master", "Combo Master", "Achieve a 50-hit combo.", 50, '🔥', AchievementCategory::Combat,
320            AchievementCondition::ComboCount(50)).with_progress_max(50),
321
322        // Exploration
323        Achievement::new("explorer", "Explorer", "Visit 10 locations.", 20, '🗺', AchievementCategory::Exploration,
324            AchievementCondition::VisitLocations(10)).with_progress_max(10),
325        Achievement::new("cartographer", "Cartographer", "Visit 50 locations.", 50, '🗺', AchievementCategory::Exploration,
326            AchievementCondition::VisitLocations(50)).with_progress_max(50),
327        Achievement::new("secret_finder", "Secret Finder", "Discover 5 secrets.", 30, '🔍', AchievementCategory::Exploration,
328            AchievementCondition::DiscoverSecrets(5)).with_progress_max(5),
329        Achievement::new("treasure_hunter", "Treasure Hunter", "Open 20 chests.", 25, '📦', AchievementCategory::Exploration,
330            AchievementCondition::OpenChests(20)).with_progress_max(20),
331
332        // Progression
333        Achievement::new("level_10", "Rising Star", "Reach level 10.", 20, '⬆', AchievementCategory::Progression,
334            AchievementCondition::ReachLevel(10)).with_progress_max(10),
335        Achievement::new("level_25", "Veteran", "Reach level 25.", 40, '⬆', AchievementCategory::Progression,
336            AchievementCondition::ReachLevel(25)).with_progress_max(25),
337        Achievement::new("level_50", "Master", "Reach level 50.", 80, '⬆', AchievementCategory::Progression,
338            AchievementCondition::ReachLevel(50)).with_progress_max(50),
339        Achievement::new("quester", "Quester", "Complete 10 quests.", 25, '📜', AchievementCategory::Progression,
340            AchievementCondition::CompleteQuests(10)).with_progress_max(10),
341        Achievement::new("craftsman", "Craftsman", "Craft 20 items.", 30, '🔨', AchievementCategory::Progression,
342            AchievementCondition::CraftItems(20)).with_progress_max(20),
343        Achievement::new("skilled", "Skilled", "Use skills 100 times.", 20, '✨', AchievementCategory::Progression,
344            AchievementCondition::UsedSkills(100)).with_progress_max(100),
345
346        // Collection
347        Achievement::new("hoarder", "Hoarder", "Collect 50 items.", 20, '🎒', AchievementCategory::Collection,
348            AchievementCondition::CollectItems(50)).with_progress_max(50),
349        Achievement::new("wealthy", "Wealthy", "Earn 10,000 gold.", 30, '💰', AchievementCategory::Collection,
350            AchievementCondition::EarnGold(10000)).with_progress_max(10000),
351        Achievement::new("big_spender", "Big Spender", "Spend 5,000 gold.", 25, '💸', AchievementCategory::Collection,
352            AchievementCondition::SpendGold(5000)).with_progress_max(5000),
353
354        // Challenge
355        Achievement::new("score_10k", "High Scorer", "Reach a score of 10,000.", 30, '🏆', AchievementCategory::Challenge,
356            AchievementCondition::ScoreThreshold(10000)).with_progress_max(10000),
357        Achievement::new("score_100k", "Champion", "Reach a score of 100,000.", 75, '🏆', AchievementCategory::Challenge,
358            AchievementCondition::ScoreThreshold(100000)).with_progress_max(100000),
359        Achievement::new("perfectionist", "Perfectionist", "Clear a level without taking damage.", 80, '⭐', AchievementCategory::Challenge,
360            AchievementCondition::PerfectClear).secret(),
361        Achievement::new("win_streak_5", "On a Roll", "Win 5 games in a row.", 50, '🔥', AchievementCategory::Challenge,
362            AchievementCondition::WinStreak(5)).with_progress_max(5),
363        Achievement::new("survivor", "Survivor", "Take 1,000 damage without dying.", 35, '❤', AchievementCategory::Challenge,
364            AchievementCondition::TakeDamage(1000.0)).with_progress_max(1000),
365
366        // Time
367        Achievement::new("dedicated", "Dedicated", "Play for 1 hour total.", 30, '⏰', AchievementCategory::Progression,
368            AchievementCondition::PlayTime(3600.0)).with_progress_max(3600),
369        Achievement::new("addicted", "Addicted", "Play for 10 hours total.", 60, '⏰', AchievementCategory::Progression,
370            AchievementCondition::PlayTime(36000.0)).with_progress_max(36000),
371
372        // Hidden/Special
373        Achievement::new("die_100", "Persistent", "Die 100 times. Keep trying!", 50, '💀', AchievementCategory::Hidden,
374            AchievementCondition::Die(100)).secret().with_progress_max(100),
375    ]
376}
377
378// ─── Achievement Notification ────────────────────────────────────────────────────
379
380#[derive(Debug, Clone)]
381pub struct AchievementNotification {
382    pub achievement: Achievement,
383    pub state: NotificationState,
384    pub timer: f32,
385    pub slide_x: f32,
386    pub target_x: f32,
387    pub alpha: f32,
388}
389
390#[derive(Debug, Clone, Copy, PartialEq)]
391pub enum NotificationState {
392    SlidingIn,
393    Holding,
394    SlidingOut,
395    Done,
396}
397
398impl AchievementNotification {
399    pub fn new(achievement: Achievement) -> Self {
400        Self {
401            achievement,
402            state: NotificationState::SlidingIn,
403            timer: 0.0,
404            slide_x: -40.0,
405            target_x: 5.0,
406            alpha: 0.0,
407        }
408    }
409
410    pub fn update(&mut self, dt: f32) {
411        match self.state {
412            NotificationState::SlidingIn => {
413                self.timer += dt;
414                let t = (self.timer / 0.4).min(1.0);
415                let ease = 1.0 - (1.0 - t).powi(3);
416                self.slide_x = self.slide_x + (self.target_x - self.slide_x) * ease;
417                self.alpha = ease;
418                if self.timer >= 0.4 {
419                    self.state = NotificationState::Holding;
420                    self.timer = 0.0;
421                    self.slide_x = self.target_x;
422                    self.alpha = 1.0;
423                }
424            }
425            NotificationState::Holding => {
426                self.timer += dt;
427                if self.timer >= 3.0 {
428                    self.state = NotificationState::SlidingOut;
429                    self.timer = 0.0;
430                }
431            }
432            NotificationState::SlidingOut => {
433                self.timer += dt;
434                let t = (self.timer / 0.4).min(1.0);
435                let ease = t.powi(3);
436                self.slide_x = self.target_x + ease * (-self.target_x - 45.0);
437                self.alpha = 1.0 - ease;
438                if self.timer >= 0.4 {
439                    self.state = NotificationState::Done;
440                }
441            }
442            NotificationState::Done => {}
443        }
444    }
445
446    pub fn is_done(&self) -> bool {
447        self.state == NotificationState::Done
448    }
449}
450
451// ─── Achievement Manager ─────────────────────────────────────────────────────────
452
453pub struct AchievementManager {
454    pub achievements: Vec<Achievement>,
455    pub notify_queue: VecDeque<Achievement>,
456    pub active_notifications: Vec<AchievementNotification>,
457    win_streak: u32,
458    custom_progress: HashMap<String, i64>,
459    enemy_kill_counts: HashMap<String, u32>,
460    have_gold: u64,
461}
462
463impl AchievementManager {
464    pub fn new() -> Self {
465        Self {
466            achievements: build_default_achievements(),
467            notify_queue: VecDeque::new(),
468            active_notifications: Vec::new(),
469            win_streak: 0,
470            custom_progress: HashMap::new(),
471            enemy_kill_counts: HashMap::new(),
472            have_gold: 0,
473        }
474    }
475
476    pub fn with_achievements(achievements: Vec<Achievement>) -> Self {
477        let mut m = Self::new();
478        m.achievements = achievements;
479        m
480    }
481
482    pub fn check_all(&mut self, stats: &SessionStats) {
483        let ids: Vec<String> = self.achievements.iter()
484            .filter(|a| !a.unlocked)
485            .map(|a| a.id.clone())
486            .collect();
487        for id in ids {
488            if let Some(ach) = self.achievements.iter().find(|a| a.id == id) {
489                if ach.condition.check(stats) {
490                    let ach = self.achievements.iter_mut().find(|a| a.id == id).unwrap();
491                    ach.unlock_now();
492                    let unlocked = ach.clone();
493                    self.notify_queue.push_back(unlocked);
494                }
495            }
496        }
497    }
498
499    pub fn unlock(&mut self, id: &str) {
500        if let Some(ach) = self.achievements.iter_mut().find(|a| a.id == id) {
501            if !ach.unlocked {
502                ach.unlock_now();
503                let unlocked = ach.clone();
504                self.notify_queue.push_back(unlocked);
505            }
506        }
507    }
508
509    pub fn progress(&mut self, id: &str, delta: i64) {
510        if let Some(ach) = self.achievements.iter_mut().find(|a| a.id == id) {
511            if !ach.unlocked {
512                ach.progress = (ach.progress + delta).min(ach.progress_max);
513                if ach.progress >= ach.progress_max {
514                    ach.unlock_now();
515                    let unlocked = ach.clone();
516                    self.notify_queue.push_back(unlocked);
517                }
518            }
519        }
520    }
521
522    pub fn is_unlocked(&self, id: &str) -> bool {
523        self.achievements.iter().find(|a| a.id == id).map(|a| a.unlocked).unwrap_or(false)
524    }
525
526    pub fn completion_percent(&self) -> f32 {
527        let total = self.achievements.len();
528        if total == 0 { return 100.0; }
529        let unlocked = self.achievements.iter().filter(|a| a.unlocked).count();
530        unlocked as f32 / total as f32 * 100.0
531    }
532
533    pub fn points(&self) -> u32 {
534        self.achievements.iter().filter(|a| a.unlocked).map(|a| a.points).sum()
535    }
536
537    pub fn total_possible_points(&self) -> u32 {
538        self.achievements.iter().map(|a| a.points).sum()
539    }
540
541    pub fn update(&mut self, dt: f32) {
542        // Drain notify queue into active notifications (max 3 at once)
543        while self.active_notifications.len() < 3 {
544            if let Some(ach) = self.notify_queue.pop_front() {
545                self.active_notifications.push(AchievementNotification::new(ach));
546            } else {
547                break;
548            }
549        }
550        // Update active notifications
551        for n in &mut self.active_notifications {
552            n.update(dt);
553        }
554        self.active_notifications.retain(|n| !n.is_done());
555    }
556
557    pub fn by_category(&self, category: AchievementCategory) -> Vec<&Achievement> {
558        self.achievements.iter().filter(|a| a.category == category).collect()
559    }
560
561    pub fn unlocked_achievements(&self) -> Vec<&Achievement> {
562        self.achievements.iter().filter(|a| a.unlocked).collect()
563    }
564
565    pub fn locked_achievements(&self) -> Vec<&Achievement> {
566        self.achievements.iter().filter(|a| !a.unlocked && !a.secret).collect()
567    }
568
569    pub fn record_win(&mut self) {
570        self.win_streak += 1;
571        self.progress("win_streak_5", 1);
572    }
573
574    pub fn record_loss(&mut self) {
575        self.win_streak = 0;
576    }
577
578    pub fn record_enemy_kill(&mut self, enemy_type: &str) {
579        *self.enemy_kill_counts.entry(enemy_type.to_string()).or_insert(0) += 1;
580        let count = self.enemy_kill_counts[enemy_type];
581        // Check kill count achievements
582        let ids: Vec<String> = self.achievements.iter()
583            .filter(|a| !a.unlocked)
584            .filter_map(|a| {
585                if let AchievementCondition::KillCount { enemy_type: et, count: needed } = &a.condition {
586                    if et == enemy_type && count >= *needed {
587                        Some(a.id.clone())
588                    } else {
589                        None
590                    }
591                } else {
592                    None
593                }
594            })
595            .collect();
596        for id in ids {
597            self.unlock(&id);
598        }
599    }
600
601    pub fn set_gold(&mut self, amount: u64) {
602        self.have_gold = amount;
603        let ids: Vec<String> = self.achievements.iter()
604            .filter(|a| !a.unlocked)
605            .filter_map(|a| {
606                if let AchievementCondition::HaveGold(needed) = &a.condition {
607                    if amount >= *needed { Some(a.id.clone()) } else { None }
608                } else { None }
609            })
610            .collect();
611        for id in ids {
612            self.unlock(&id);
613        }
614    }
615
616    pub fn achievement_by_id(&self, id: &str) -> Option<&Achievement> {
617        self.achievements.iter().find(|a| a.id == id)
618    }
619}
620
621impl Default for AchievementManager {
622    fn default() -> Self {
623        Self::new()
624    }
625}
626
627// ─── Progression Node ─────────────────────────────────────────────────────────────
628
629#[derive(Debug, Clone)]
630pub struct ProgressionNode {
631    pub id: String,
632    pub name: String,
633    pub description: String,
634    pub cost: u32,
635    pub unlocks: Vec<String>,
636    pub requires: Vec<String>,
637    pub icon: char,
638    pub tier: u32,
639}
640
641impl ProgressionNode {
642    pub fn new(
643        id: impl Into<String>,
644        name: impl Into<String>,
645        description: impl Into<String>,
646        cost: u32,
647        tier: u32,
648    ) -> Self {
649        Self {
650            id: id.into(),
651            name: name.into(),
652            description: description.into(),
653            cost,
654            unlocks: Vec::new(),
655            requires: Vec::new(),
656            icon: '◆',
657            tier,
658        }
659    }
660
661    pub fn with_requires(mut self, reqs: Vec<impl Into<String>>) -> Self {
662        self.requires = reqs.into_iter().map(|r| r.into()).collect();
663        self
664    }
665
666    pub fn with_unlocks(mut self, unlocks: Vec<impl Into<String>>) -> Self {
667        self.unlocks = unlocks.into_iter().map(|u| u.into()).collect();
668        self
669    }
670
671    pub fn with_icon(mut self, icon: char) -> Self {
672        self.icon = icon;
673        self
674    }
675}
676
677// ─── Progression Tree ─────────────────────────────────────────────────────────────
678
679#[derive(Debug, Clone)]
680pub struct ProgressionTree {
681    pub nodes: Vec<ProgressionNode>,
682    pub name: String,
683}
684
685impl ProgressionTree {
686    pub fn new(name: impl Into<String>) -> Self {
687        Self { nodes: Vec::new(), name: name.into() }
688    }
689
690    pub fn add_node(mut self, node: ProgressionNode) -> Self {
691        self.nodes.push(node);
692        self
693    }
694
695    pub fn node_by_id(&self, id: &str) -> Option<&ProgressionNode> {
696        self.nodes.iter().find(|n| n.id == id)
697    }
698
699    /// Topological sort for display ordering — returns node IDs in dependency order.
700    pub fn topological_order(&self) -> Vec<String> {
701        let mut visited = HashSet::new();
702        let mut order = Vec::new();
703        for node in &self.nodes {
704            self.visit_node(&node.id, &mut visited, &mut order);
705        }
706        order
707    }
708
709    fn visit_node(&self, id: &str, visited: &mut HashSet<String>, order: &mut Vec<String>) {
710        if visited.contains(id) { return; }
711        visited.insert(id.to_string());
712        if let Some(node) = self.node_by_id(id) {
713            for req in &node.requires {
714                self.visit_node(req, visited, order);
715            }
716        }
717        order.push(id.to_string());
718    }
719
720    pub fn tiers(&self) -> Vec<Vec<&ProgressionNode>> {
721        let max_tier = self.nodes.iter().map(|n| n.tier).max().unwrap_or(0);
722        (0..=max_tier).map(|t| {
723            self.nodes.iter().filter(|n| n.tier == t).collect()
724        }).collect()
725    }
726}
727
728// ─── Progression State ───────────────────────────────────────────────────────────
729
730#[derive(Debug, Clone, Default)]
731pub struct ProgressionState {
732    pub unlocked: HashSet<String>,
733    pub currency: u32,
734    pub total_spent: u32,
735}
736
737impl ProgressionState {
738    pub fn new(starting_currency: u32) -> Self {
739        Self {
740            unlocked: HashSet::new(),
741            currency: starting_currency,
742            total_spent: 0,
743        }
744    }
745
746    pub fn can_unlock(&self, tree: &ProgressionTree, node_id: &str) -> bool {
747        if self.unlocked.contains(node_id) { return false; }
748        if let Some(node) = tree.node_by_id(node_id) {
749            if self.currency < node.cost { return false; }
750            for req in &node.requires {
751                if !self.unlocked.contains(req) { return false; }
752            }
753            true
754        } else {
755            false
756        }
757    }
758
759    pub fn unlock(&mut self, tree: &ProgressionTree, node_id: &str) -> bool {
760        if !self.can_unlock(tree, node_id) { return false; }
761        if let Some(node) = tree.node_by_id(node_id) {
762            self.currency -= node.cost;
763            self.total_spent += node.cost;
764            self.unlocked.insert(node_id.to_string());
765            true
766        } else {
767            false
768        }
769    }
770
771    pub fn add_currency(&mut self, amount: u32) {
772        self.currency += amount;
773    }
774
775    pub fn is_unlocked(&self, node_id: &str) -> bool {
776        self.unlocked.contains(node_id)
777    }
778
779    pub fn available_to_unlock<'a>(&self, tree: &'a ProgressionTree) -> Vec<&'a ProgressionNode> {
780        tree.nodes.iter().filter(|n| self.can_unlock(tree, &n.id)).collect()
781    }
782}
783
784// ─── Progression Presets ─────────────────────────────────────────────────────────
785
786pub struct ProgressionPreset;
787
788impl ProgressionPreset {
789    pub fn warrior_tree() -> ProgressionTree {
790        ProgressionTree::new("Warrior")
791            .add_node(ProgressionNode::new("power_strike", "Power Strike", "Increase basic attack damage by 15%.", 50, 0)
792                .with_icon('⚔'))
793            .add_node(ProgressionNode::new("iron_skin", "Iron Skin", "Increase armor by 20%.", 50, 0)
794                .with_icon('🛡'))
795            .add_node(ProgressionNode::new("battle_cry", "Battle Cry", "AOE taunt nearby enemies.", 100, 1)
796                .with_requires(vec!["power_strike"]).with_icon('📢'))
797            .add_node(ProgressionNode::new("shield_wall", "Shield Wall", "Reduce incoming damage by 25% for 5s.", 100, 1)
798                .with_requires(vec!["iron_skin"]).with_icon('🛡'))
799            .add_node(ProgressionNode::new("berserker", "Berserker", "Below 30% HP, gain +50% attack speed.", 200, 2)
800                .with_requires(vec!["battle_cry", "power_strike"]).with_icon('😡'))
801            .add_node(ProgressionNode::new("juggernaut", "Juggernaut", "Become unstoppable for 3 seconds.", 300, 3)
802                .with_requires(vec!["shield_wall", "berserker"]).with_icon('💪'))
803    }
804
805    pub fn mage_tree() -> ProgressionTree {
806        ProgressionTree::new("Mage")
807            .add_node(ProgressionNode::new("arcane_bolt", "Arcane Bolt", "Unlock arcane projectile.", 50, 0)
808                .with_icon('✦'))
809            .add_node(ProgressionNode::new("mana_shield", "Mana Shield", "Convert 10% mana damage to health.", 50, 0)
810                .with_icon('🔵'))
811            .add_node(ProgressionNode::new("fireball", "Fireball", "Unlock explosive fire spell.", 100, 1)
812                .with_requires(vec!["arcane_bolt"]).with_icon('🔥'))
813            .add_node(ProgressionNode::new("ice_lance", "Ice Lance", "Freezing projectile.", 100, 1)
814                .with_requires(vec!["arcane_bolt"]).with_icon('❄'))
815            .add_node(ProgressionNode::new("meteor", "Meteor", "Call down a devastating meteor.", 200, 2)
816                .with_requires(vec!["fireball"]).with_icon('☄'))
817            .add_node(ProgressionNode::new("blizzard", "Blizzard", "Persistent ice storm.", 200, 2)
818                .with_requires(vec!["ice_lance"]).with_icon('🌨'))
819            .add_node(ProgressionNode::new("archmage", "Archmage", "Reduce all spell cooldowns by 30%.", 300, 3)
820                .with_requires(vec!["meteor", "blizzard"]).with_icon('👑'))
821    }
822
823    pub fn rogue_tree() -> ProgressionTree {
824        ProgressionTree::new("Rogue")
825            .add_node(ProgressionNode::new("backstab", "Backstab", "+50% damage when attacking from behind.", 50, 0)
826                .with_icon('🗡'))
827            .add_node(ProgressionNode::new("evasion", "Evasion", "+15% dodge chance.", 50, 0)
828                .with_icon('💨'))
829            .add_node(ProgressionNode::new("shadow_step", "Shadow Step", "Teleport behind target.", 100, 1)
830                .with_requires(vec!["evasion"]).with_icon('🌑'))
831            .add_node(ProgressionNode::new("poison_blade", "Poison Blade", "Attacks apply poison.", 100, 1)
832                .with_requires(vec!["backstab"]).with_icon('☠'))
833            .add_node(ProgressionNode::new("vanish", "Vanish", "Become invisible for 5 seconds.", 200, 2)
834                .with_requires(vec!["shadow_step"]).with_icon('👻'))
835            .add_node(ProgressionNode::new("death_mark", "Death Mark", "Mark a target for triple damage.", 300, 3)
836                .with_requires(vec!["vanish", "poison_blade"]).with_icon('💀'))
837    }
838}
839
840// ─── Challenge Objective Type ────────────────────────────────────────────────────
841
842#[derive(Debug, Clone)]
843pub enum ObjectiveType {
844    KillEnemies { enemy_type: Option<String>, count: u32 },
845    DealDamage(f64),
846    CollectGold(u64),
847    CompleteLevels(u32),
848    SurviveTime(f32),
849    AchieveCombo(u32),
850    CraftItems(u32),
851    OpenChests(u32),
852    ScorePoints(u64),
853    WinWithoutDying,
854    Custom(String),
855}
856
857impl ObjectiveType {
858    pub fn description(&self) -> String {
859        match self {
860            ObjectiveType::KillEnemies { enemy_type, count } => {
861                if let Some(et) = enemy_type {
862                    format!("Kill {} {} enemies", count, et)
863                } else {
864                    format!("Kill {} enemies", count)
865                }
866            }
867            ObjectiveType::DealDamage(n) => format!("Deal {:.0} damage", n),
868            ObjectiveType::CollectGold(n) => format!("Collect {} gold", n),
869            ObjectiveType::CompleteLevels(n) => format!("Complete {} levels", n),
870            ObjectiveType::SurviveTime(secs) => format!("Survive for {:.0} seconds", secs),
871            ObjectiveType::AchieveCombo(n) => format!("Achieve a {}-hit combo", n),
872            ObjectiveType::CraftItems(n) => format!("Craft {} items", n),
873            ObjectiveType::OpenChests(n) => format!("Open {} chests", n),
874            ObjectiveType::ScorePoints(n) => format!("Score {} points", n),
875            ObjectiveType::WinWithoutDying => "Win without dying".to_string(),
876            ObjectiveType::Custom(s) => s.clone(),
877        }
878    }
879}
880
881// ─── Challenge Objective ─────────────────────────────────────────────────────────
882
883#[derive(Debug, Clone)]
884pub struct ChallengeObjective {
885    pub description: String,
886    pub progress: u32,
887    pub required: u32,
888    pub objective_type: ObjectiveType,
889    pub completed: bool,
890}
891
892impl ChallengeObjective {
893    pub fn new(objective_type: ObjectiveType, required: u32) -> Self {
894        let description = objective_type.description();
895        Self {
896            description,
897            progress: 0,
898            required,
899            objective_type,
900            completed: false,
901        }
902    }
903
904    pub fn advance(&mut self, amount: u32) {
905        if !self.completed {
906            self.progress = (self.progress + amount).min(self.required);
907            if self.progress >= self.required {
908                self.completed = true;
909            }
910        }
911    }
912
913    pub fn fraction(&self) -> f32 {
914        if self.required == 0 { return 1.0; }
915        self.progress as f32 / self.required as f32
916    }
917}
918
919// ─── Challenge Reward ────────────────────────────────────────────────────────────
920
921#[derive(Debug, Clone)]
922pub struct ChallengeReward {
923    pub gold: u32,
924    pub xp: u32,
925    pub item_name: Option<String>,
926    pub progression_currency: u32,
927}
928
929impl ChallengeReward {
930    pub fn gold_xp(gold: u32, xp: u32) -> Self {
931        Self { gold, xp, item_name: None, progression_currency: 0 }
932    }
933
934    pub fn description(&self) -> String {
935        let mut parts = Vec::new();
936        if self.gold > 0 { parts.push(format!("{} gold", self.gold)); }
937        if self.xp > 0 { parts.push(format!("{} XP", self.xp)); }
938        if let Some(ref item) = self.item_name { parts.push(item.clone()); }
939        if self.progression_currency > 0 { parts.push(format!("{} skill points", self.progression_currency)); }
940        if parts.is_empty() { "No reward".to_string() } else { parts.join(", ") }
941    }
942}
943
944// ─── Challenge ───────────────────────────────────────────────────────────────────
945
946#[derive(Debug, Clone)]
947pub struct Challenge {
948    pub id: String,
949    pub name: String,
950    pub description: String,
951    pub expiry_secs: u64,
952    pub objectives: Vec<ChallengeObjective>,
953    pub reward: ChallengeReward,
954    pub is_weekly: bool,
955    pub completed: bool,
956}
957
958impl Challenge {
959    pub fn new(
960        id: impl Into<String>,
961        name: impl Into<String>,
962        description: impl Into<String>,
963        expiry_secs: u64,
964        objectives: Vec<ChallengeObjective>,
965        reward: ChallengeReward,
966    ) -> Self {
967        Self {
968            id: id.into(),
969            name: name.into(),
970            description: description.into(),
971            expiry_secs,
972            objectives,
973            reward,
974            is_weekly: false,
975            completed: false,
976        }
977    }
978
979    pub fn weekly(mut self) -> Self {
980        self.is_weekly = true;
981        self
982    }
983
984    pub fn is_expired(&self, now_secs: u64) -> bool {
985        now_secs >= self.expiry_secs
986    }
987
988    pub fn check_completion(&mut self) {
989        if !self.completed && self.objectives.iter().all(|o| o.completed) {
990            self.completed = true;
991        }
992    }
993
994    pub fn progress_summary(&self) -> String {
995        let done = self.objectives.iter().filter(|o| o.completed).count();
996        format!("{}/{} objectives", done, self.objectives.len())
997    }
998}
999
1000// ─── Challenge Generator ─────────────────────────────────────────────────────────
1001
1002pub struct ChallengeGenerator;
1003
1004impl ChallengeGenerator {
1005    /// Generate daily challenges deterministically from day number + seed.
1006    pub fn generate_daily(day_number: u64, seed: u64) -> Vec<Challenge> {
1007        let mut challenges = Vec::new();
1008        let rng_base = Self::hash(day_number, seed);
1009        let now = SystemTime::now()
1010            .duration_since(UNIX_EPOCH)
1011            .unwrap_or_default()
1012            .as_secs();
1013        let expiry = Self::next_midnight_utc(now);
1014
1015        // Generate 3 daily challenges
1016        for i in 0..3u64 {
1017            let rng = Self::hash(rng_base, i);
1018            let challenge = Self::pick_challenge(rng, expiry, false, day_number, i);
1019            challenges.push(challenge);
1020        }
1021        challenges
1022    }
1023
1024    /// Generate weekly challenges from week number + seed.
1025    pub fn generate_weekly(week_number: u64, seed: u64) -> Vec<Challenge> {
1026        let mut challenges = Vec::new();
1027        let rng_base = Self::hash(week_number, seed.wrapping_add(99999));
1028        let now = SystemTime::now()
1029            .duration_since(UNIX_EPOCH)
1030            .unwrap_or_default()
1031            .as_secs();
1032        let expiry = now + 7 * 86400;
1033
1034        for i in 0..2u64 {
1035            let rng = Self::hash(rng_base, i);
1036            let challenge = Self::pick_challenge(rng, expiry, true, week_number, i);
1037            challenges.push(challenge);
1038        }
1039        challenges
1040    }
1041
1042    fn pick_challenge(rng: u64, expiry: u64, weekly: bool, period: u64, idx: u64) -> Challenge {
1043        let challenge_types = ["kill", "score", "survive", "combo", "craft", "collect", "explore"];
1044        let ctype = challenge_types[(rng % challenge_types.len() as u64) as usize];
1045        let scale = if weekly { 5u32 } else { 1u32 };
1046
1047        match ctype {
1048            "kill" => {
1049                let count = (20 + (rng >> 8) % 80) as u32 * scale;
1050                Challenge::new(
1051                    format!("daily_kill_{}_{}", period, idx),
1052                    "Elimination",
1053                    format!("Kill {} enemies today", count),
1054                    expiry,
1055                    vec![ChallengeObjective::new(ObjectiveType::KillEnemies { enemy_type: None, count }, count)],
1056                    ChallengeReward::gold_xp(100 * scale, 200 * scale),
1057                )
1058            }
1059            "score" => {
1060                let target = (1000 + (rng >> 4) % 9000) as u64 * scale as u64;
1061                Challenge::new(
1062                    format!("daily_score_{}_{}", period, idx),
1063                    "High Score Run",
1064                    format!("Score {} points in a single run", target),
1065                    expiry,
1066                    vec![ChallengeObjective::new(ObjectiveType::ScorePoints(target), target as u32)],
1067                    ChallengeReward::gold_xp(150 * scale, 300 * scale),
1068                )
1069            }
1070            "survive" => {
1071                let secs = (120 + (rng >> 6) % 180) as f32 * scale as f32;
1072                Challenge::new(
1073                    format!("daily_survive_{}_{}", period, idx),
1074                    "Endurance",
1075                    format!("Survive for {:.0} seconds", secs),
1076                    expiry,
1077                    vec![ChallengeObjective::new(ObjectiveType::SurviveTime(secs), secs as u32)],
1078                    ChallengeReward::gold_xp(120 * scale, 250 * scale),
1079                )
1080            }
1081            "combo" => {
1082                let combo = (10 + (rng >> 3) % 40) as u32 * scale;
1083                Challenge::new(
1084                    format!("daily_combo_{}_{}", period, idx),
1085                    "Combo Artist",
1086                    format!("Achieve a {}-hit combo", combo),
1087                    expiry,
1088                    vec![ChallengeObjective::new(ObjectiveType::AchieveCombo(combo), combo)],
1089                    ChallengeReward::gold_xp(80 * scale, 180 * scale),
1090                )
1091            }
1092            "craft" => {
1093                let count = (3 + (rng >> 2) % 7) as u32 * scale;
1094                Challenge::new(
1095                    format!("daily_craft_{}_{}", period, idx),
1096                    "Craftsman",
1097                    format!("Craft {} items today", count),
1098                    expiry,
1099                    vec![ChallengeObjective::new(ObjectiveType::CraftItems(count), count)],
1100                    ChallengeReward::gold_xp(90 * scale, 150 * scale),
1101                )
1102            }
1103            "collect" => {
1104                let gold = (200 + (rng >> 7) % 800) as u64 * scale as u64;
1105                Challenge::new(
1106                    format!("daily_collect_{}_{}", period, idx),
1107                    "Gold Rush",
1108                    format!("Collect {} gold today", gold),
1109                    expiry,
1110                    vec![ChallengeObjective::new(ObjectiveType::CollectGold(gold), gold as u32)],
1111                    ChallengeReward::gold_xp(200 * scale, 100 * scale),
1112                )
1113            }
1114            _ => {
1115                let levels = (1 + (rng >> 5) % 5) as u32 * scale;
1116                Challenge::new(
1117                    format!("daily_explore_{}_{}", period, idx),
1118                    "Level Clearer",
1119                    format!("Complete {} levels today", levels),
1120                    expiry,
1121                    vec![ChallengeObjective::new(ObjectiveType::CompleteLevels(levels), levels)],
1122                    ChallengeReward::gold_xp(130 * scale, 220 * scale),
1123                )
1124            }
1125        }
1126    }
1127
1128    fn hash(a: u64, b: u64) -> u64 {
1129        let mut h = a.wrapping_add(b.wrapping_mul(6364136223846793005));
1130        h ^= h >> 33;
1131        h = h.wrapping_mul(0xff51afd7ed558ccd);
1132        h ^= h >> 33;
1133        h = h.wrapping_mul(0xc4ceb9fe1a85ec53);
1134        h ^= h >> 33;
1135        h
1136    }
1137
1138    fn next_midnight_utc(now: u64) -> u64 {
1139        let secs_since_midnight = now % 86400;
1140        now - secs_since_midnight + 86400
1141    }
1142
1143    pub fn day_number(epoch_secs: u64) -> u64 {
1144        epoch_secs / 86400
1145    }
1146
1147    pub fn week_number(epoch_secs: u64) -> u64 {
1148        epoch_secs / (86400 * 7)
1149    }
1150}
1151
1152// ─── Challenge Tracker ───────────────────────────────────────────────────────────
1153
1154pub struct ChallengeTracker {
1155    pub active: Vec<Challenge>,
1156    pub completed: Vec<String>,
1157    pub reroll_tokens: u32,
1158    seed: u64,
1159}
1160
1161impl ChallengeTracker {
1162    pub fn new(seed: u64) -> Self {
1163        let now = SystemTime::now()
1164            .duration_since(UNIX_EPOCH)
1165            .unwrap_or_default()
1166            .as_secs();
1167        let day = ChallengeGenerator::day_number(now);
1168        let week = ChallengeGenerator::week_number(now);
1169        let mut active = ChallengeGenerator::generate_daily(day, seed);
1170        active.extend(ChallengeGenerator::generate_weekly(week, seed));
1171        Self { active, completed: Vec::new(), reroll_tokens: 3, seed }
1172    }
1173
1174    pub fn refresh_if_expired(&mut self) {
1175        let now = SystemTime::now()
1176            .duration_since(UNIX_EPOCH)
1177            .unwrap_or_default()
1178            .as_secs();
1179        self.active.retain(|c| !c.is_expired(now));
1180        let day = ChallengeGenerator::day_number(now);
1181        let week = ChallengeGenerator::week_number(now);
1182        let daily_count = self.active.iter().filter(|c| !c.is_weekly).count();
1183        let weekly_count = self.active.iter().filter(|c| c.is_weekly).count();
1184        if daily_count < 3 {
1185            let new_daily = ChallengeGenerator::generate_daily(day, self.seed);
1186            for c in new_daily {
1187                if self.active.len() < 5 {
1188                    self.active.push(c);
1189                }
1190            }
1191        }
1192        if weekly_count < 2 {
1193            let new_weekly = ChallengeGenerator::generate_weekly(week, self.seed);
1194            for c in new_weekly {
1195                if self.active.len() < 7 {
1196                    self.active.push(c);
1197                }
1198            }
1199        }
1200    }
1201
1202    pub fn reroll(&mut self, challenge_id: &str) -> bool {
1203        if self.reroll_tokens == 0 { return false; }
1204        let now = SystemTime::now()
1205            .duration_since(UNIX_EPOCH)
1206            .unwrap_or_default()
1207            .as_secs();
1208        let day = ChallengeGenerator::day_number(now);
1209        if let Some(pos) = self.active.iter().position(|c| c.id == challenge_id) {
1210            let was_weekly = self.active[pos].is_weekly;
1211            self.active.remove(pos);
1212            self.reroll_tokens -= 1;
1213            let reroll_seed = self.seed.wrapping_add(now);
1214            if was_weekly {
1215                let week = ChallengeGenerator::week_number(now);
1216                if let Some(c) = ChallengeGenerator::generate_weekly(week, reroll_seed).into_iter().next() {
1217                    self.active.push(c);
1218                }
1219            } else {
1220                if let Some(c) = ChallengeGenerator::generate_daily(day, reroll_seed).into_iter().next() {
1221                    self.active.push(c);
1222                }
1223            }
1224            true
1225        } else {
1226            false
1227        }
1228    }
1229
1230    pub fn advance_objective(&mut self, objective_kind: &str, amount: u32) {
1231        for challenge in &mut self.active {
1232            if challenge.completed { continue; }
1233            let kind_matches: Vec<usize> = challenge.objectives.iter().enumerate()
1234                .filter(|(_, o)| o.objective_type.description().to_lowercase().contains(objective_kind))
1235                .map(|(i, _)| i)
1236                .collect();
1237            for idx in kind_matches {
1238                challenge.objectives[idx].advance(amount);
1239            }
1240            challenge.check_completion();
1241        }
1242    }
1243
1244    pub fn complete_challenge(&mut self, id: &str) -> Option<ChallengeReward> {
1245        if let Some(pos) = self.active.iter().position(|c| c.id == id && c.completed) {
1246            let challenge = self.active.remove(pos);
1247            self.completed.push(challenge.id.clone());
1248            Some(challenge.reward)
1249        } else {
1250            None
1251        }
1252    }
1253
1254    pub fn active_daily(&self) -> Vec<&Challenge> {
1255        self.active.iter().filter(|c| !c.is_weekly).collect()
1256    }
1257
1258    pub fn active_weekly(&self) -> Vec<&Challenge> {
1259        self.active.iter().filter(|c| c.is_weekly).collect()
1260    }
1261}
1262
1263// ─── Mastery Bonus ───────────────────────────────────────────────────────────────
1264
1265#[derive(Debug, Clone)]
1266pub enum MasteryBonus {
1267    DamageBonus(f32),
1268    CooldownReduction(f32),
1269    ResourceGain(f32),
1270    CritChance(f32),
1271    CritMultiplier(f32),
1272    SpeedBonus(f32),
1273    DefenseBonus(f32),
1274    HealingBonus(f32),
1275    XpBonus(f32),
1276    GoldBonus(f32),
1277    ComboWindow(f32),
1278    DamageReduction(f32),
1279    SkillPowerBonus(f32),
1280}
1281
1282impl MasteryBonus {
1283    pub fn description(&self) -> String {
1284        match self {
1285            MasteryBonus::DamageBonus(v) => format!("+{:.0}% damage", v * 100.0),
1286            MasteryBonus::CooldownReduction(v) => format!("-{:.0}% cooldowns", v * 100.0),
1287            MasteryBonus::ResourceGain(v) => format!("+{:.0}% resource gain", v * 100.0),
1288            MasteryBonus::CritChance(v) => format!("+{:.0}% crit chance", v * 100.0),
1289            MasteryBonus::CritMultiplier(v) => format!("+{:.0}% crit damage", v * 100.0),
1290            MasteryBonus::SpeedBonus(v) => format!("+{:.0}% speed", v * 100.0),
1291            MasteryBonus::DefenseBonus(v) => format!("+{:.0}% defense", v * 100.0),
1292            MasteryBonus::HealingBonus(v) => format!("+{:.0}% healing", v * 100.0),
1293            MasteryBonus::XpBonus(v) => format!("+{:.0}% XP gain", v * 100.0),
1294            MasteryBonus::GoldBonus(v) => format!("+{:.0}% gold gain", v * 100.0),
1295            MasteryBonus::ComboWindow(v) => format!("+{:.1}s combo window", v),
1296            MasteryBonus::DamageReduction(v) => format!("-{:.0}% damage taken", v * 100.0),
1297            MasteryBonus::SkillPowerBonus(v) => format!("+{:.0}% skill power", v * 100.0),
1298        }
1299    }
1300
1301    pub fn value(&self) -> f32 {
1302        match self {
1303            MasteryBonus::DamageBonus(v) | MasteryBonus::CooldownReduction(v) |
1304            MasteryBonus::ResourceGain(v) | MasteryBonus::CritChance(v) |
1305            MasteryBonus::CritMultiplier(v) | MasteryBonus::SpeedBonus(v) |
1306            MasteryBonus::DefenseBonus(v) | MasteryBonus::HealingBonus(v) |
1307            MasteryBonus::XpBonus(v) | MasteryBonus::GoldBonus(v) |
1308            MasteryBonus::ComboWindow(v) | MasteryBonus::DamageReduction(v) |
1309            MasteryBonus::SkillPowerBonus(v) => *v,
1310        }
1311    }
1312}
1313
1314// ─── Mastery Level ───────────────────────────────────────────────────────────────
1315
1316#[derive(Debug, Clone)]
1317pub struct MasteryLevel {
1318    pub level: u32,
1319    pub xp: u64,
1320    pub xp_per_level: u64,
1321    pub bonuses: Vec<MasteryBonus>,
1322}
1323
1324impl MasteryLevel {
1325    pub fn new(xp_per_level: u64) -> Self {
1326        Self { level: 0, xp: 0, xp_per_level, bonuses: Vec::new() }
1327    }
1328
1329    pub fn add_xp(&mut self, amount: u64) -> u32 {
1330        self.xp += amount;
1331        let mut levels_gained = 0u32;
1332        while self.xp >= self.xp_required_for_next() {
1333            self.xp -= self.xp_required_for_next();
1334            self.level += 1;
1335            levels_gained += 1;
1336            self.apply_level_up_bonus();
1337        }
1338        levels_gained
1339    }
1340
1341    pub fn xp_required_for_next(&self) -> u64 {
1342        self.xp_per_level + self.level as u64 * (self.xp_per_level / 5)
1343    }
1344
1345    pub fn progress_fraction(&self) -> f32 {
1346        let needed = self.xp_required_for_next();
1347        if needed == 0 { return 1.0; }
1348        self.xp as f32 / needed as f32
1349    }
1350
1351    fn apply_level_up_bonus(&mut self) {
1352        let bonus = match self.level % 5 {
1353            1 => MasteryBonus::DamageBonus(0.02),
1354            2 => MasteryBonus::CritChance(0.01),
1355            3 => MasteryBonus::CooldownReduction(0.02),
1356            4 => MasteryBonus::ResourceGain(0.03),
1357            0 => MasteryBonus::SkillPowerBonus(0.05),
1358            _ => MasteryBonus::DamageBonus(0.01),
1359        };
1360        self.bonuses.push(bonus);
1361    }
1362
1363    pub fn total_damage_bonus(&self) -> f32 {
1364        self.bonuses.iter().filter_map(|b| {
1365            if let MasteryBonus::DamageBonus(v) = b { Some(*v) } else { None }
1366        }).sum()
1367    }
1368
1369    pub fn total_cdr(&self) -> f32 {
1370        self.bonuses.iter().filter_map(|b| {
1371            if let MasteryBonus::CooldownReduction(v) = b { Some(*v) } else { None }
1372        }).sum()
1373    }
1374}
1375
1376// ─── Mastery Book ────────────────────────────────────────────────────────────────
1377
1378pub struct MasteryBook {
1379    masteries: HashMap<String, MasteryLevel>,
1380    default_xp_per_level: u64,
1381}
1382
1383impl MasteryBook {
1384    pub fn new(default_xp_per_level: u64) -> Self {
1385        Self {
1386            masteries: HashMap::new(),
1387            default_xp_per_level,
1388        }
1389    }
1390
1391    pub fn get_or_create(&mut self, entity_type: &str) -> &mut MasteryLevel {
1392        let xp = self.default_xp_per_level;
1393        self.masteries.entry(entity_type.to_string())
1394            .or_insert_with(|| MasteryLevel::new(xp))
1395    }
1396
1397    pub fn add_xp(&mut self, entity_type: &str, amount: u64) -> u32 {
1398        let xp = self.default_xp_per_level;
1399        let mastery = self.masteries.entry(entity_type.to_string())
1400            .or_insert_with(|| MasteryLevel::new(xp));
1401        mastery.add_xp(amount)
1402    }
1403
1404    pub fn level_of(&self, entity_type: &str) -> u32 {
1405        self.masteries.get(entity_type).map(|m| m.level).unwrap_or(0)
1406    }
1407
1408    pub fn get(&self, entity_type: &str) -> Option<&MasteryLevel> {
1409        self.masteries.get(entity_type)
1410    }
1411
1412    pub fn all_masteries(&self) -> &HashMap<String, MasteryLevel> {
1413        &self.masteries
1414    }
1415
1416    pub fn highest_mastery(&self) -> Option<(&String, &MasteryLevel)> {
1417        self.masteries.iter().max_by_key(|(_, m)| m.level)
1418    }
1419
1420    pub fn total_mastery_levels(&self) -> u32 {
1421        self.masteries.values().map(|m| m.level).sum()
1422    }
1423
1424    pub fn global_damage_bonus(&self) -> f32 {
1425        self.masteries.values().map(|m| m.total_damage_bonus()).sum::<f32>().min(2.0)
1426    }
1427
1428    pub fn global_cdr(&self) -> f32 {
1429        self.masteries.values().map(|m| m.total_cdr()).sum::<f32>().min(0.5)
1430    }
1431}
1432
1433// ─── Tests ──────────────────────────────────────────────────────────────────────
1434
1435#[cfg(test)]
1436mod tests {
1437    use super::*;
1438
1439    #[test]
1440    fn test_achievement_condition_check() {
1441        let mut stats = SessionStats::new();
1442        stats.enemies_killed = 100;
1443        stats.boss_kills = 5;
1444        stats.critical_hits = 50;
1445        stats.damage_dealt = 15000.0;
1446
1447        assert!(AchievementCondition::TotalKills(100).check(&stats));
1448        assert!(!AchievementCondition::TotalKills(101).check(&stats));
1449        assert!(AchievementCondition::DealCritDamage(50).check(&stats));
1450        assert!(AchievementCondition::DealDamage(10000.0).check(&stats));
1451        assert!(!AchievementCondition::WinWithoutDamage.check(&stats));
1452    }
1453
1454    #[test]
1455    fn test_achievement_manager_unlock() {
1456        let mut mgr = AchievementManager::new();
1457        assert!(!mgr.is_unlocked("first_blood"));
1458        mgr.unlock("first_blood");
1459        assert!(mgr.is_unlocked("first_blood"));
1460        assert!(mgr.points() > 0);
1461    }
1462
1463    #[test]
1464    fn test_achievement_manager_progress() {
1465        let mut mgr = AchievementManager::new();
1466        // warrior achievement requires 100 kills, progress_max = 100
1467        mgr.progress("warrior", 50);
1468        assert!(!mgr.is_unlocked("warrior"));
1469        mgr.progress("warrior", 50);
1470        assert!(mgr.is_unlocked("warrior"));
1471    }
1472
1473    #[test]
1474    fn test_achievement_completion_percent() {
1475        let mut mgr = AchievementManager::new();
1476        let total = mgr.achievements.len() as f32;
1477        assert!((mgr.completion_percent() - 0.0).abs() < 1e-5);
1478        mgr.unlock("first_blood");
1479        let expected = 1.0 / total * 100.0;
1480        assert!((mgr.completion_percent() - expected).abs() < 0.5);
1481    }
1482
1483    #[test]
1484    fn test_achievement_notification_lifecycle() {
1485        let mut mgr = AchievementManager::new();
1486        mgr.unlock("first_blood");
1487        assert!(!mgr.notify_queue.is_empty());
1488        mgr.update(0.0);
1489        assert!(!mgr.active_notifications.is_empty());
1490        // Simulate time until done
1491        for _ in 0..300 {
1492            mgr.update(0.016);
1493        }
1494        assert!(mgr.active_notifications.is_empty());
1495    }
1496
1497    #[test]
1498    fn test_progression_tree_can_unlock() {
1499        let tree = ProgressionPreset::warrior_tree();
1500        let mut state = ProgressionState::new(100);
1501
1502        assert!(state.can_unlock(&tree, "power_strike"));
1503        assert!(state.can_unlock(&tree, "iron_skin"));
1504        // battle_cry requires power_strike
1505        assert!(!state.can_unlock(&tree, "battle_cry"));
1506
1507        state.unlock(&tree, "power_strike");
1508        assert!(state.is_unlocked("power_strike"));
1509        assert_eq!(state.currency, 50);
1510
1511        assert!(state.can_unlock(&tree, "battle_cry"));
1512    }
1513
1514    #[test]
1515    fn test_progression_topological_order() {
1516        let tree = ProgressionPreset::mage_tree();
1517        let order = tree.topological_order();
1518        // arcane_bolt should come before fireball
1519        let arcane_pos = order.iter().position(|n| n == "arcane_bolt").unwrap();
1520        let fireball_pos = order.iter().position(|n| n == "fireball").unwrap();
1521        assert!(arcane_pos < fireball_pos);
1522    }
1523
1524    #[test]
1525    fn test_challenge_generator_deterministic() {
1526        let challenges1 = ChallengeGenerator::generate_daily(1000, 42);
1527        let challenges2 = ChallengeGenerator::generate_daily(1000, 42);
1528        assert_eq!(challenges1.len(), challenges2.len());
1529        for (c1, c2) in challenges1.iter().zip(challenges2.iter()) {
1530            assert_eq!(c1.id, c2.id);
1531            assert_eq!(c1.name, c2.name);
1532        }
1533    }
1534
1535    #[test]
1536    fn test_challenge_objective_advance() {
1537        let mut obj = ChallengeObjective::new(
1538            ObjectiveType::KillEnemies { enemy_type: None, count: 20 },
1539            20,
1540        );
1541        assert!(!obj.completed);
1542        obj.advance(10);
1543        assert!(!obj.completed);
1544        obj.advance(10);
1545        assert!(obj.completed);
1546        assert_eq!(obj.progress, 20);
1547    }
1548
1549    #[test]
1550    fn test_mastery_level_xp() {
1551        let mut mastery = MasteryLevel::new(100);
1552        assert_eq!(mastery.level, 0);
1553        let levels = mastery.add_xp(100);
1554        assert_eq!(levels, 1);
1555        assert_eq!(mastery.level, 1);
1556        assert!(!mastery.bonuses.is_empty());
1557    }
1558
1559    #[test]
1560    fn test_mastery_book() {
1561        let mut book = MasteryBook::new(100);
1562        let levels = book.add_xp("goblin", 300);
1563        assert!(levels > 0);
1564        assert!(book.level_of("goblin") > 0);
1565        assert!(book.total_mastery_levels() > 0);
1566    }
1567
1568    #[test]
1569    fn test_mastery_book_global_bonuses() {
1570        let mut book = MasteryBook::new(50);
1571        for _ in 0..20 {
1572            book.add_xp("goblin", 100);
1573        }
1574        for _ in 0..20 {
1575            book.add_xp("orc", 100);
1576        }
1577        let damage = book.global_damage_bonus();
1578        assert!(damage > 0.0);
1579        assert!(damage <= 2.0); // capped at 200%
1580    }
1581
1582    #[test]
1583    fn test_achievement_notification_state() {
1584        let achievements = build_default_achievements();
1585        let ach = achievements.into_iter().next().unwrap();
1586        let mut notif = AchievementNotification::new(ach);
1587        assert_eq!(notif.state, NotificationState::SlidingIn);
1588        for _ in 0..30 {
1589            notif.update(0.02);
1590        }
1591        assert_eq!(notif.state, NotificationState::Holding);
1592        for _ in 0..200 {
1593            notif.update(0.02);
1594        }
1595        assert_eq!(notif.state, NotificationState::SlidingOut);
1596        for _ in 0..30 {
1597            notif.update(0.02);
1598        }
1599        assert_eq!(notif.state, NotificationState::Done);
1600        assert!(notif.is_done());
1601    }
1602
1603    #[test]
1604    fn test_category_all() {
1605        let cats = AchievementCategory::all();
1606        assert!(cats.len() >= 7);
1607        assert!(cats.contains(&AchievementCategory::Hidden));
1608    }
1609
1610    #[test]
1611    fn test_challenge_tracker_reroll() {
1612        let mut tracker = ChallengeTracker::new(42);
1613        let initial_count = tracker.active.len();
1614        assert!(initial_count > 0);
1615        let initial_tokens = tracker.reroll_tokens;
1616        if let Some(first_id) = tracker.active.first().map(|c| c.id.clone()) {
1617            let result = tracker.reroll(&first_id);
1618            assert!(result);
1619            assert_eq!(tracker.reroll_tokens, initial_tokens - 1);
1620        }
1621    }
1622}