1use crate::progress::UserProgress;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
5pub struct XpReward {
6 pub base: u64,
7 pub combo_bonus: u64,
8 pub streak_bonus: u64,
9 pub critical_bonus: u64,
10 pub first_try_bonus: u64,
11 pub total: u64,
12 pub is_critical: bool,
13}
14
15pub fn calculate_xp_reward(
16 difficulty: u32,
17 combo: u32,
18 streak_days: u32,
19 first_try: bool,
20 rng_seed: u64,
21) -> XpReward {
22 let base = 10 + (difficulty.saturating_sub(1) * 5) as u64;
23
24 let combo_mult = 1.0 + (combo.min(10) as f64 * 0.1);
25 let combo_bonus = ((base as f64) * (combo_mult - 1.0)) as u64;
26
27 let streak_bonus = (streak_days.min(7) * 2) as u64;
28
29 let first_try_bonus = if first_try { 5 } else { 0 };
30
31 let is_critical = (rng_seed % 10) == 0;
32 let critical_bonus = if is_critical { base } else { 0 };
33
34 let total = base + combo_bonus + streak_bonus + first_try_bonus + critical_bonus;
35
36 XpReward {
37 base,
38 combo_bonus,
39 streak_bonus,
40 critical_bonus,
41 first_try_bonus,
42 total,
43 is_critical,
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48pub enum StreakStatus {
49 Active { days: u32 },
50 AtRisk,
51 Frozen,
52 Lost { was: u32 },
53}
54
55pub fn update_streak(progress: &mut UserProgress, today: &str) -> StreakStatus {
56 match &progress.last_streak_date {
57 None => {
58 progress.streak_days = 1;
59 progress.last_streak_date = Some(today.to_string());
60 StreakStatus::Active { days: 1 }
61 }
62 Some(last) if last == today => {
63 StreakStatus::Active { days: progress.streak_days }
64 }
65 Some(last) if is_yesterday(last, today) => {
66 progress.streak_days += 1;
67 progress.last_streak_date = Some(today.to_string());
68 StreakStatus::Active { days: progress.streak_days }
69 }
70 Some(last) if is_two_days_ago(last, today) && progress.streak_freezes > 0 => {
71 progress.streak_freezes -= 1;
72 progress.last_streak_date = Some(today.to_string());
73 StreakStatus::Frozen
74 }
75 Some(_) => {
76 let was = progress.streak_days;
77 progress.streak_days = 1;
78 progress.last_streak_date = Some(today.to_string());
79 StreakStatus::Lost { was }
80 }
81 }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ComboResult {
86 pub new_combo: u32,
87 pub is_new_record: bool,
88 pub multiplier: f64,
89}
90
91pub fn update_combo(progress: &mut UserProgress, correct: bool) -> ComboResult {
92 if correct {
93 progress.combo += 1;
94 let is_new_record = progress.combo > progress.best_combo;
95 if is_new_record {
96 progress.best_combo = progress.combo;
97 }
98 let multiplier = 1.0 + (progress.combo.min(10) as f64 * 0.1);
99 ComboResult { new_combo: progress.combo, is_new_record, multiplier }
100 } else {
101 progress.combo = 0;
102 ComboResult { new_combo: 0, is_new_record: false, multiplier: 1.0 }
103 }
104}
105
106pub fn level_title(level: u32) -> &'static str {
107 match level {
108 1 => "Novice",
109 2..=4 => "Apprentice",
110 5..=9 => "Student",
111 10..=14 => "Scholar",
112 15..=19 => "Adept",
113 20..=29 => "Expert",
114 30..=39 => "Master",
115 40..=49 => "Sage",
116 _ => "Grandmaster",
117 }
118}
119
120pub fn xp_progress_to_next_level(xp: u64, level: u32) -> (u64, u64, f64) {
121 let current_threshold = crate::progress::xp_for_level(level);
122 let next_threshold = crate::progress::xp_for_level(level + 1);
123 let progress = xp.saturating_sub(current_threshold);
124 let needed = next_threshold - current_threshold;
125 let percentage = if needed > 0 {
126 (progress as f64) / (needed as f64)
127 } else {
128 0.0
129 };
130 (progress, needed, percentage)
131}
132
133pub struct FreezeGrant {
134 pub freezes: u8,
135 pub reason: &'static str,
136}
137
138pub fn check_level_up_freeze_grants(old_level: u32, new_level: u32) -> Option<FreezeGrant> {
139 let freeze_count = (old_level + 1..=new_level)
140 .filter(|l| l % 5 == 0)
141 .count() as u8;
142
143 if freeze_count > 0 {
144 Some(FreezeGrant {
145 freezes: freeze_count,
146 reason: "Level milestone reward",
147 })
148 } else {
149 None
150 }
151}
152
153pub fn is_sunday(date: &str) -> bool {
154 if let Ok(num) = parse_date_to_days(date) {
155 (num + 4) % 7 == 0
156 } else {
157 false
158 }
159}
160
161fn is_yesterday(last: &str, today: &str) -> bool {
162 if let (Ok(l), Ok(t)) = (parse_date_to_days(last), parse_date_to_days(today)) {
163 t - l == 1
164 } else {
165 false
166 }
167}
168
169fn is_two_days_ago(last: &str, today: &str) -> bool {
170 if let (Ok(l), Ok(t)) = (parse_date_to_days(last), parse_date_to_days(today)) {
171 t - l == 2
172 } else {
173 false
174 }
175}
176
177fn parse_date_to_days(date: &str) -> Result<i64, ()> {
178 let parts: Vec<&str> = date.split('-').collect();
179 if parts.len() != 3 {
180 return Err(());
181 }
182
183 let year: i64 = parts[0].parse().map_err(|_| ())?;
184 let month: i64 = parts[1].parse().map_err(|_| ())?;
185 let day: i64 = parts[2].parse().map_err(|_| ())?;
186
187 let days = year * 365 + (year / 4) - (year / 100) + (year / 400)
188 + (month * 30) + day;
189 Ok(days)
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn test_xp_reward_base() {
198 let reward = calculate_xp_reward(1, 0, 0, false, 1);
199 assert_eq!(reward.base, 10);
200 assert_eq!(reward.combo_bonus, 0);
201 assert_eq!(reward.total, 10);
202 assert!(!reward.is_critical);
203 }
204
205 #[test]
206 fn test_xp_reward_with_combo() {
207 let reward = calculate_xp_reward(1, 5, 0, false, 1);
208 assert_eq!(reward.base, 10);
209 assert_eq!(reward.combo_bonus, 5); assert_eq!(reward.total, 15);
211 }
212
213 #[test]
214 fn test_xp_reward_critical() {
215 let reward = calculate_xp_reward(1, 0, 0, false, 10); assert!(reward.is_critical);
217 assert_eq!(reward.critical_bonus, 10);
218 assert_eq!(reward.total, 20);
219 }
220
221 #[test]
222 fn test_xp_reward_full() {
223 let reward = calculate_xp_reward(3, 10, 7, true, 1);
225 assert_eq!(reward.base, 20);
231 assert_eq!(reward.combo_bonus, 20);
232 assert_eq!(reward.streak_bonus, 14);
233 assert_eq!(reward.first_try_bonus, 5);
234 assert_eq!(reward.total, 59);
235 }
236
237 #[test]
238 fn test_combo_increment() {
239 let mut progress = UserProgress::new();
240
241 let result = update_combo(&mut progress, true);
242 assert_eq!(result.new_combo, 1);
243 assert!(result.is_new_record);
244
245 let result = update_combo(&mut progress, true);
246 assert_eq!(result.new_combo, 2);
247 assert!(result.is_new_record);
248
249 let result = update_combo(&mut progress, false);
250 assert_eq!(result.new_combo, 0);
251 assert!(!result.is_new_record);
252 }
253
254 #[test]
255 fn test_combo_multiplier() {
256 let mut progress = UserProgress::new();
257
258 for _ in 0..10 {
259 update_combo(&mut progress, true);
260 }
261
262 let result = update_combo(&mut progress, true);
263 assert!((result.multiplier - 2.0).abs() < 0.01);
264 }
265
266 #[test]
267 fn test_level_titles() {
268 assert_eq!(level_title(1), "Novice");
269 assert_eq!(level_title(5), "Student");
270 assert_eq!(level_title(10), "Scholar");
271 assert_eq!(level_title(50), "Grandmaster");
272 }
273
274 #[test]
275 fn test_level_up_freeze_grants() {
276 assert!(check_level_up_freeze_grants(1, 4).is_none());
277
278 let grant = check_level_up_freeze_grants(4, 5).unwrap();
279 assert_eq!(grant.freezes, 1);
280
281 let grant = check_level_up_freeze_grants(1, 10).unwrap();
282 assert_eq!(grant.freezes, 2); }
284
285 #[test]
286 fn test_is_yesterday() {
287 assert!(is_yesterday("2025-01-01", "2025-01-02"));
288 assert!(!is_yesterday("2025-01-01", "2025-01-03"));
289 }
290}