Skip to main content

logicaffeine_web/
game.rs

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); // 10 * 0.5 = 5
210        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); // seed % 10 == 0
216        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        // difficulty 3, combo 10, streak 7, first try, non-crit
224        let reward = calculate_xp_reward(3, 10, 7, true, 1);
225        // base = 10 + (2 * 5) = 20
226        // combo = 20 * 1.0 = 20
227        // streak = 14
228        // first_try = 5
229        // total = 20 + 20 + 14 + 5 = 59
230        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); // levels 5 and 10
283    }
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}