Skip to main content

logicaffeine_web/
progress.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet};
3
4#[derive(Debug, Clone, Serialize, Deserialize, Default)]
5pub struct UserProgress {
6    pub xp: u64,
7    pub level: u32,
8    pub streak_days: u32,
9    pub last_session: Option<String>,
10    pub exercises: HashMap<String, ExerciseProgress>,
11    pub modules: HashMap<String, ModuleProgress>,
12    #[serde(default)]
13    pub combo: u32,
14    #[serde(default)]
15    pub best_combo: u32,
16    #[serde(default)]
17    pub streak_freezes: u8,
18    #[serde(default)]
19    pub last_streak_date: Option<String>,
20    #[serde(default)]
21    pub achievements: HashSet<String>,
22    #[serde(default)]
23    pub title: Option<String>,
24    #[serde(default)]
25    pub last_weekly_freeze_date: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ExerciseProgress {
30    pub exercise_id: String,
31    pub attempts: u32,
32    pub correct_count: u32,
33    pub last_attempt: Option<String>,
34    pub srs: SrsData,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SrsData {
39    pub ease_factor: f64,
40    pub interval: u32,
41    pub repetitions: u32,
42    pub next_review: Option<String>,
43}
44
45impl Default for SrsData {
46    fn default() -> Self {
47        Self {
48            ease_factor: 2.5,
49            interval: 1,
50            repetitions: 0,
51            next_review: None,
52        }
53    }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ModuleProgress {
58    pub module_id: String,
59    pub unlocked: bool,
60    pub completed: bool,
61    pub stars: u8,
62    pub best_score: u32,
63    pub attempts: u32,
64}
65
66impl Default for ModuleProgress {
67    fn default() -> Self {
68        Self {
69            module_id: String::new(),
70            unlocked: false,
71            completed: false,
72            stars: 0,
73            best_score: 0,
74            attempts: 0,
75        }
76    }
77}
78
79impl UserProgress {
80    pub fn new() -> Self {
81        Self {
82            level: 1,
83            ..Default::default()
84        }
85    }
86
87    pub fn load() -> Self {
88        #[cfg(target_arch = "wasm32")]
89        {
90            crate::storage::load_raw()
91                .and_then(|json| serde_json::from_str(&json).ok())
92                .unwrap_or_else(Self::new)
93        }
94        #[cfg(not(target_arch = "wasm32"))]
95        {
96            Self::new()
97        }
98    }
99
100    pub fn save(&self) {
101        #[cfg(target_arch = "wasm32")]
102        {
103            if let Ok(json) = serde_json::to_string(self) {
104                crate::storage::save_raw(&json);
105            }
106        }
107    }
108
109    pub fn add_xp(&mut self, amount: u64) {
110        self.xp += amount;
111        self.level = calculate_level(self.xp);
112        self.save();
113    }
114
115    pub fn record_attempt(&mut self, exercise_id: &str, correct: bool) {
116        let entry = self.exercises.entry(exercise_id.to_string()).or_insert_with(|| {
117            ExerciseProgress {
118                exercise_id: exercise_id.to_string(),
119                attempts: 0,
120                correct_count: 0,
121                last_attempt: None,
122                srs: SrsData::default(),
123            }
124        });
125
126        entry.attempts += 1;
127        if correct {
128            entry.correct_count += 1;
129        }
130
131        self.save();
132    }
133
134    pub fn get_exercise_progress(&self, exercise_id: &str) -> Option<&ExerciseProgress> {
135        self.exercises.get(exercise_id)
136    }
137
138    pub fn get_module_progress(&self, module_id: &str) -> Option<&ModuleProgress> {
139        self.modules.get(module_id)
140    }
141
142    pub fn update_module_score(&mut self, module_id: &str, score: u32) {
143        let entry = self.modules.entry(module_id.to_string()).or_insert_with(|| {
144            ModuleProgress {
145                module_id: module_id.to_string(),
146                ..Default::default()
147            }
148        });
149
150        entry.attempts += 1;
151        if score > entry.best_score {
152            entry.best_score = score;
153        }
154
155        self.save();
156    }
157}
158
159pub fn calculate_level(xp: u64) -> u32 {
160    ((xp as f64).sqrt() / 10.0).floor() as u32 + 1
161}
162
163pub fn xp_for_level(level: u32) -> u64 {
164    let l = level as u64;
165    l * l * 100
166}
167
168pub fn calculate_xp_reward(difficulty: u32, first_try: bool, streak_days: u32) -> u64 {
169    let base: u64 = 10;
170    let difficulty_bonus = (difficulty.saturating_sub(1) as u64) * 5;
171    let first_try_bonus = if first_try { 5 } else { 0 };
172    let streak_bonus = (streak_days.min(7) as u64) * 2;
173
174    base + difficulty_bonus + first_try_bonus + streak_bonus
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_level_calculation() {
183        assert_eq!(calculate_level(0), 1);
184        assert_eq!(calculate_level(100), 2);
185        assert_eq!(calculate_level(400), 3);
186        assert_eq!(calculate_level(900), 4);
187    }
188
189    #[test]
190    fn test_xp_reward() {
191        assert_eq!(calculate_xp_reward(1, false, 0), 10);
192        assert_eq!(calculate_xp_reward(1, true, 0), 15);
193        assert_eq!(calculate_xp_reward(2, false, 0), 15);
194        assert_eq!(calculate_xp_reward(1, false, 3), 16);
195        assert_eq!(calculate_xp_reward(3, true, 5), 10 + 10 + 5 + 10);
196    }
197
198    #[test]
199    fn test_user_progress_record() {
200        let mut progress = UserProgress::new();
201        progress.record_attempt("test_q1", true);
202        progress.record_attempt("test_q1", false);
203
204        let ex = progress.get_exercise_progress("test_q1").unwrap();
205        assert_eq!(ex.attempts, 2);
206        assert_eq!(ex.correct_count, 1);
207    }
208}