logicaffeine_web/
unlock.rs1use crate::content::ContentEngine;
9use crate::progress::UserProgress;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ModuleState {
14 Locked,
16 Available,
18 Started,
20 Progressing,
22 Completed,
24 Perfected,
26}
27
28pub fn get_module_state(
30 progress: &UserProgress,
31 engine: &ContentEngine,
32 era_id: &str,
33 module_id: &str,
34) -> ModuleState {
35 if !check_module_unlocked(progress, engine, era_id, module_id) {
37 return ModuleState::Locked;
38 }
39
40 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
59pub 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 if module_index == 0 {
79 return true;
80 }
81
82 let is_final_module = total_modules >= 2 && module_index >= total_modules - 2;
84
85 if is_final_module {
86 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 let prev_module_id = module_ids[module_index - 1];
98 progress.modules.get(prev_module_id).map_or(false, |mp| mp.completed)
99}
100
101pub 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
118pub 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 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 let progress = UserProgress::new();
173 assert!(!check_module_unlocked(&progress, &engine, &era.meta.id, second_id));
174
175 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 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 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 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 completed[0].2 = 100;
209 let progress = make_progress_with_completed(&completed);
210
211 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 assert_eq!(locked.len(), era.modules.len() - 1);
230
231 if let Some(first_module) = era.modules.first() {
233 assert!(!locked.contains(&first_module.meta.id));
234 }
235 }
236 }
237}