Skip to main content

logicaffeine_web/
unlock.rs

1//! Module Unlock Logic
2//!
3//! Rules:
4//! - First module in each era is always unlocked
5//! - Subsequent modules unlock when the previous module is completed
6//! - Last two modules in each era are locked until at least one module has 100% completion
7
8use crate::content::ContentEngine;
9use crate::progress::UserProgress;
10
11/// State of a module for the user
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ModuleState {
14    /// Module is locked and cannot be accessed
15    Locked,
16    /// Module is unlocked but not started
17    Available,
18    /// Module has been started (score < 50%)
19    Started,
20    /// Module is in progress (50-89% score)
21    Progressing,
22    /// Module has been completed but not perfected (90%+ score)
23    Completed,
24    /// Module has been perfected (100% or 3 stars)
25    Perfected,
26}
27
28/// Get the current state of a module for the user
29pub fn get_module_state(
30    progress: &UserProgress,
31    engine: &ContentEngine,
32    era_id: &str,
33    module_id: &str,
34) -> ModuleState {
35    // Check if locked
36    if !check_module_unlocked(progress, engine, era_id, module_id) {
37        return ModuleState::Locked;
38    }
39
40    // Check progress
41    match progress.modules.get(module_id) {
42        None => ModuleState::Available,
43        Some(mp) => {
44            if mp.completed && (mp.best_score >= 90 || mp.stars >= 3) {
45                ModuleState::Perfected
46            } else if mp.completed {
47                ModuleState::Completed
48            } else if mp.best_score >= 50 {
49                ModuleState::Progressing
50            } else if mp.attempts > 0 || mp.best_score > 0 {
51                ModuleState::Started
52            } else {
53                ModuleState::Available
54            }
55        }
56    }
57}
58
59/// Check if a specific module is unlocked for the user
60pub fn check_module_unlocked(
61    progress: &UserProgress,
62    engine: &ContentEngine,
63    era_id: &str,
64    module_id: &str,
65) -> bool {
66    let Some(era) = engine.get_era(era_id) else {
67        return false;
68    };
69
70    let module_ids: Vec<&str> = era.modules.iter().map(|m| m.meta.id.as_str()).collect();
71    let Some(module_index) = module_ids.iter().position(|&id| id == module_id) else {
72        return false;
73    };
74
75    let total_modules = module_ids.len();
76
77    // First module is always unlocked
78    if module_index == 0 {
79        return true;
80    }
81
82    // Check if this is one of the last two modules
83    let is_final_module = total_modules >= 2 && module_index >= total_modules - 2;
84
85    if is_final_module {
86        // Last two modules require at least one module to be 100% complete
87        let has_perfect_completion = module_ids.iter().take(total_modules.saturating_sub(2)).any(|&mid| {
88            progress.modules.get(mid).map_or(false, |mp| mp.completed && mp.best_score >= 100)
89        });
90
91        if !has_perfect_completion {
92            return false;
93        }
94    }
95
96    // Check if previous module is completed
97    let prev_module_id = module_ids[module_index - 1];
98    progress.modules.get(prev_module_id).map_or(false, |mp| mp.completed)
99}
100
101/// Get list of locked module IDs for an era
102pub fn get_locked_module_ids(
103    progress: &UserProgress,
104    engine: &ContentEngine,
105    era_id: &str,
106) -> Vec<String> {
107    let Some(era) = engine.get_era(era_id) else {
108        return Vec::new();
109    };
110
111    era.modules
112        .iter()
113        .filter(|m| !check_module_unlocked(progress, engine, era_id, &m.meta.id))
114        .map(|m| m.meta.id.clone())
115        .collect()
116}
117
118/// Check if any module in the era has 100% completion
119pub fn has_perfect_module(progress: &UserProgress, engine: &ContentEngine, era_id: &str) -> bool {
120    let Some(era) = engine.get_era(era_id) else {
121        return false;
122    };
123
124    era.modules.iter().any(|m| {
125        progress.modules.get(&m.meta.id).map_or(false, |mp| mp.completed && mp.best_score >= 100)
126    })
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::progress::ModuleProgress;
133
134    fn make_progress_with_completed(completed_modules: &[(&str, bool, u32)]) -> UserProgress {
135        let mut progress = UserProgress::new();
136        for (id, completed, score) in completed_modules {
137            progress.modules.insert(id.to_string(), ModuleProgress {
138                module_id: id.to_string(),
139                unlocked: true,
140                completed: *completed,
141                stars: 0,
142                best_score: *score,
143                attempts: 1,
144            });
145        }
146        progress
147    }
148
149    #[test]
150    fn test_first_module_always_unlocked() {
151        let progress = UserProgress::new();
152        let engine = ContentEngine::new();
153
154        // First module of first era should always be unlocked
155        if let Some(era) = engine.eras().first() {
156            if let Some(module) = era.modules.first() {
157                assert!(check_module_unlocked(&progress, &engine, &era.meta.id, &module.meta.id));
158            }
159        }
160    }
161
162    #[test]
163    fn test_module_locked_until_previous_complete() {
164        let engine = ContentEngine::new();
165
166        if let Some(era) = engine.eras().first() {
167            if era.modules.len() >= 2 {
168                let first_id = &era.modules[0].meta.id;
169                let second_id = &era.modules[1].meta.id;
170
171                // Without completing first module, second should be locked
172                let progress = UserProgress::new();
173                assert!(!check_module_unlocked(&progress, &engine, &era.meta.id, second_id));
174
175                // After completing first module, second should be unlocked
176                let progress = make_progress_with_completed(&[(first_id.as_str(), true, 80)]);
177                assert!(check_module_unlocked(&progress, &engine, &era.meta.id, second_id));
178            }
179        }
180    }
181
182    #[test]
183    fn test_last_two_locked_until_one_module_100_complete() {
184        let engine = ContentEngine::new();
185
186        // Find an era with at least 4 modules
187        for era in engine.eras() {
188            if era.modules.len() >= 4 {
189                let module_ids: Vec<&str> = era.modules.iter().map(|m| m.meta.id.as_str()).collect();
190                let last_module_id = module_ids[module_ids.len() - 1];
191                let second_last_id = module_ids[module_ids.len() - 2];
192
193                // Complete all modules except last two, but none at 100%
194                let mut completed: Vec<(&str, bool, u32)> = module_ids[..module_ids.len()-2]
195                    .iter()
196                    .map(|id| (*id, true, 80u32))
197                    .collect();
198
199                let progress = make_progress_with_completed(&completed);
200
201                // Last two should still be locked (no 100% completion)
202                assert!(!check_module_unlocked(&progress, &engine, &era.meta.id, second_last_id),
203                    "Second-to-last module should be locked without 100% completion");
204                assert!(!check_module_unlocked(&progress, &engine, &era.meta.id, last_module_id),
205                    "Last module should be locked without 100% completion");
206
207                // Now complete one module at 100%
208                completed[0].2 = 100;
209                let progress = make_progress_with_completed(&completed);
210
211                // Second-to-last should now be unlocked
212                assert!(check_module_unlocked(&progress, &engine, &era.meta.id, second_last_id),
213                    "Second-to-last module should be unlocked with 100% completion");
214
215                break;
216            }
217        }
218    }
219
220    #[test]
221    fn test_get_locked_module_ids() {
222        let progress = UserProgress::new();
223        let engine = ContentEngine::new();
224
225        if let Some(era) = engine.eras().first() {
226            let locked = get_locked_module_ids(&progress, &engine, &era.meta.id);
227
228            // All modules except the first should be locked initially
229            assert_eq!(locked.len(), era.modules.len() - 1);
230
231            // First module should NOT be in the locked list
232            if let Some(first_module) = era.modules.first() {
233                assert!(!locked.contains(&first_module.meta.id));
234            }
235        }
236    }
237}