Skip to main content

logicaffeine_web/
achievements.rs

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, // Checked separately in lesson completion
155            _ => 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}