1use crate::progress::UserProgress;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct Achievement {
5 pub id: &'static str,
6 pub title: &'static str,
7 pub description: &'static str,
8 pub xp_reward: u64,
9 pub unlocks_title: Option<&'static str>,
10 pub grants_freeze: bool,
11}
12
13pub const ACHIEVEMENTS: &[Achievement] = &[
14 Achievement {
15 id: "first_blood",
16 title: "First Blood",
17 description: "Answer your first question correctly",
18 xp_reward: 50,
19 unlocks_title: None,
20 grants_freeze: false,
21 },
22 Achievement {
23 id: "combo_5",
24 title: "On Fire",
25 description: "Get a 5-answer combo",
26 xp_reward: 100,
27 unlocks_title: None,
28 grants_freeze: false,
29 },
30 Achievement {
31 id: "combo_10",
32 title: "Unstoppable",
33 description: "Get a 10-answer combo",
34 xp_reward: 250,
35 unlocks_title: Some("Logic Machine"),
36 grants_freeze: false,
37 },
38 Achievement {
39 id: "combo_25",
40 title: "Terminator",
41 description: "Get a 25-answer combo",
42 xp_reward: 500,
43 unlocks_title: Some("Automaton"),
44 grants_freeze: false,
45 },
46 Achievement {
47 id: "streak_3",
48 title: "Getting Started",
49 description: "Maintain a 3-day streak",
50 xp_reward: 75,
51 unlocks_title: None,
52 grants_freeze: false,
53 },
54 Achievement {
55 id: "streak_7",
56 title: "Week Warrior",
57 description: "Maintain a 7-day streak",
58 xp_reward: 200,
59 unlocks_title: Some("Dedicated"),
60 grants_freeze: true,
61 },
62 Achievement {
63 id: "streak_14",
64 title: "Fortnight Fighter",
65 description: "Maintain a 14-day streak",
66 xp_reward: 400,
67 unlocks_title: None,
68 grants_freeze: true,
69 },
70 Achievement {
71 id: "streak_30",
72 title: "Monthly Master",
73 description: "Maintain a 30-day streak",
74 xp_reward: 1000,
75 unlocks_title: Some("Logician"),
76 grants_freeze: true,
77 },
78 Achievement {
79 id: "perfect_module",
80 title: "Flawless",
81 description: "Complete a module with no mistakes",
82 xp_reward: 300,
83 unlocks_title: None,
84 grants_freeze: false,
85 },
86 Achievement {
87 id: "century",
88 title: "Century",
89 description: "Answer 100 questions correctly",
90 xp_reward: 500,
91 unlocks_title: Some("Scholar"),
92 grants_freeze: false,
93 },
94 Achievement {
95 id: "millennium",
96 title: "Millennium",
97 description: "Answer 1000 questions correctly",
98 xp_reward: 2000,
99 unlocks_title: Some("Sage"),
100 grants_freeze: false,
101 },
102 Achievement {
103 id: "level_10",
104 title: "Double Digits",
105 description: "Reach level 10",
106 xp_reward: 300,
107 unlocks_title: None,
108 grants_freeze: false,
109 },
110 Achievement {
111 id: "level_25",
112 title: "Quarter Century",
113 description: "Reach level 25",
114 xp_reward: 750,
115 unlocks_title: Some("Adept"),
116 grants_freeze: false,
117 },
118 Achievement {
119 id: "level_50",
120 title: "Half Century",
121 description: "Reach level 50",
122 xp_reward: 1500,
123 unlocks_title: Some("Grandmaster"),
124 grants_freeze: false,
125 },
126];
127
128pub fn get_achievement(id: &str) -> Option<&'static Achievement> {
129 ACHIEVEMENTS.iter().find(|a| a.id == id)
130}
131
132pub fn check_achievements(progress: &UserProgress) -> Vec<&'static Achievement> {
133 let mut newly_unlocked = Vec::new();
134
135 for achievement in ACHIEVEMENTS {
136 if progress.achievements.contains(achievement.id) {
137 continue;
138 }
139
140 let earned = match achievement.id {
141 "first_blood" => total_correct(progress) >= 1,
142 "combo_5" => progress.best_combo >= 5,
143 "combo_10" => progress.best_combo >= 10,
144 "combo_25" => progress.best_combo >= 25,
145 "streak_3" => progress.streak_days >= 3,
146 "streak_7" => progress.streak_days >= 7,
147 "streak_14" => progress.streak_days >= 14,
148 "streak_30" => progress.streak_days >= 30,
149 "century" => total_correct(progress) >= 100,
150 "millennium" => total_correct(progress) >= 1000,
151 "level_10" => progress.level >= 10,
152 "level_25" => progress.level >= 25,
153 "level_50" => progress.level >= 50,
154 "perfect_module" => false, _ => false,
156 };
157
158 if earned {
159 newly_unlocked.push(achievement);
160 }
161 }
162
163 newly_unlocked
164}
165
166fn total_correct(progress: &UserProgress) -> u32 {
167 progress.exercises.values().map(|e| e.correct_count).sum()
168}
169
170pub fn unlock_achievement(progress: &mut UserProgress, achievement: &Achievement) {
171 progress.achievements.insert(achievement.id.to_string());
172 progress.xp += achievement.xp_reward;
173 progress.level = crate::progress::calculate_level(progress.xp);
174
175 if let Some(title) = achievement.unlocks_title {
176 if progress.title.is_none() {
177 progress.title = Some(title.to_string());
178 }
179 }
180
181 if achievement.grants_freeze && progress.streak_freezes < 3 {
182 progress.streak_freezes += 1;
183 }
184
185 progress.save();
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn test_get_achievement() {
194 let achievement = get_achievement("first_blood");
195 assert!(achievement.is_some());
196 assert_eq!(achievement.unwrap().title, "First Blood");
197 }
198
199 #[test]
200 fn test_achievement_not_found() {
201 let achievement = get_achievement("nonexistent");
202 assert!(achievement.is_none());
203 }
204
205 #[test]
206 fn test_check_achievements_first_blood() {
207 let mut progress = UserProgress::new();
208 progress.record_attempt("test", true);
209
210 let newly_unlocked = check_achievements(&progress);
211 assert!(newly_unlocked.iter().any(|a| a.id == "first_blood"));
212 }
213
214 #[test]
215 fn test_check_achievements_combo() {
216 let mut progress = UserProgress::new();
217 progress.best_combo = 5;
218
219 let newly_unlocked = check_achievements(&progress);
220 assert!(newly_unlocked.iter().any(|a| a.id == "combo_5"));
221 assert!(!newly_unlocked.iter().any(|a| a.id == "combo_10"));
222 }
223
224 #[test]
225 fn test_streak_achievements_grant_freeze() {
226 let streak_7 = get_achievement("streak_7").unwrap();
227 assert!(streak_7.grants_freeze);
228
229 let first_blood = get_achievement("first_blood").unwrap();
230 assert!(!first_blood.grants_freeze);
231 }
232}