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