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