1use std::collections::{HashMap, HashSet, VecDeque};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use super::SessionStats;
10
11#[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#[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, }
201 }
202}
203
204#[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
295pub fn build_default_achievements() -> Vec<Achievement> {
298 vec![
299 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 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 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 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 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 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 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#[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
450pub 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 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 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 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#[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#[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 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#[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
782pub 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#[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#[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#[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#[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
998pub struct ChallengeGenerator;
1001
1002impl ChallengeGenerator {
1003 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 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 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
1150pub 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#[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#[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
1374pub 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#[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 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 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 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 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); }
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}