1use std::collections::HashMap;
6
7use serde::Serialize;
8
9use crate::models::resources::SkillsResource;
10use crate::models::skyblock::SkyBlockMember;
11use crate::util::leveling::{LevelProgress, level_from_cumulative, skill_level};
12
13pub const CATACOMBS_XP: [f64; 50] = [
17 50.0,
18 125.0,
19 235.0,
20 395.0,
21 625.0,
22 955.0,
23 1425.0,
24 2095.0,
25 3045.0,
26 4385.0,
27 6275.0,
28 8940.0,
29 12700.0,
30 17960.0,
31 25340.0,
32 35640.0,
33 50040.0,
34 70040.0,
35 97640.0,
36 135640.0,
37 188140.0,
38 259640.0,
39 356640.0,
40 488640.0,
41 668640.0,
42 911640.0,
43 1239640.0,
44 1684640.0,
45 2284640.0,
46 3084640.0,
47 4149640.0,
48 5559640.0,
49 7459640.0,
50 9959640.0,
51 13259640.0,
52 17559640.0,
53 23159640.0,
54 30359640.0,
55 39559640.0,
56 51559640.0,
57 66559640.0,
58 85559640.0,
59 109559640.0,
60 139559640.0,
61 177559640.0,
62 225559640.0,
63 285559640.0,
64 360559640.0,
65 453559640.0,
66 569809640.0,
67];
68
69pub fn catacombs_level(xp: f64) -> LevelProgress {
71 level_from_cumulative(xp, &CATACOMBS_XP)
72}
73
74pub fn dungeon_class_level(xp: f64) -> LevelProgress {
78 catacombs_level(xp)
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum SlayerBoss {
84 Zombie,
85 Spider,
86 Wolf,
87 Enderman,
88 Blaze,
89 Vampire,
90}
91
92const SLAYER_XP_ZOMBIE_SPIDER: [f64; 9] = [
93 5.0, 15.0, 200.0, 1000.0, 5000.0, 20000.0, 100000.0, 400000.0, 1000000.0,
94];
95const SLAYER_XP_STANDARD: [f64; 9] = [
96 10.0, 30.0, 250.0, 1500.0, 5000.0, 20000.0, 100000.0, 400000.0, 1000000.0,
97];
98const SLAYER_XP_VAMPIRE: [f64; 5] = [20.0, 75.0, 240.0, 840.0, 2400.0];
99
100impl SlayerBoss {
101 pub fn from_key(key: &str) -> Option<Self> {
104 match key.to_ascii_lowercase().as_str() {
105 "zombie" => Some(Self::Zombie),
106 "spider" => Some(Self::Spider),
107 "wolf" => Some(Self::Wolf),
108 "enderman" => Some(Self::Enderman),
109 "blaze" => Some(Self::Blaze),
110 "vampire" => Some(Self::Vampire),
111 _ => None,
112 }
113 }
114
115 pub fn key(&self) -> &'static str {
117 match self {
118 Self::Zombie => "zombie",
119 Self::Spider => "spider",
120 Self::Wolf => "wolf",
121 Self::Enderman => "enderman",
122 Self::Blaze => "blaze",
123 Self::Vampire => "vampire",
124 }
125 }
126
127 pub fn xp_table(&self) -> &'static [f64] {
129 match self {
130 Self::Zombie | Self::Spider => &SLAYER_XP_ZOMBIE_SPIDER,
131 Self::Wolf | Self::Enderman | Self::Blaze => &SLAYER_XP_STANDARD,
132 Self::Vampire => &SLAYER_XP_VAMPIRE,
133 }
134 }
135}
136
137pub fn slayer_level(boss: SlayerBoss, xp: f64) -> LevelProgress {
139 level_from_cumulative(xp, boss.xp_table())
140}
141
142pub const HOTM_XP: [f64; 9] = [
144 3000.0, 12000.0, 37000.0, 97000.0, 197000.0, 347000.0, 557000.0, 847000.0, 1247000.0,
145];
146
147pub fn hotm_tier(xp: f64) -> LevelProgress {
150 let progress = level_from_cumulative(xp, &HOTM_XP);
151 LevelProgress {
152 level: progress.level + 1,
153 fractional_level: progress.fractional_level + 1.0,
154 ..progress
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
160pub enum PetRarity {
161 Common,
162 Uncommon,
163 Rare,
164 Epic,
165 Legendary,
166 Mythic,
167}
168
169impl PetRarity {
170 pub fn from_key(key: &str) -> Option<Self> {
172 match key.to_ascii_uppercase().as_str() {
173 "COMMON" => Some(Self::Common),
174 "UNCOMMON" => Some(Self::Uncommon),
175 "RARE" => Some(Self::Rare),
176 "EPIC" => Some(Self::Epic),
177 "LEGENDARY" => Some(Self::Legendary),
178 "MYTHIC" => Some(Self::Mythic),
179 _ => None,
180 }
181 }
182
183 fn offset(&self) -> usize {
184 match self {
185 Self::Common => 0,
186 Self::Uncommon => 6,
187 Self::Rare => 11,
188 Self::Epic => 16,
189 Self::Legendary | Self::Mythic => 20,
190 }
191 }
192}
193
194const PET_XP_INCREMENTS: [f64; 119] = [
197 100.0, 110.0, 120.0, 130.0, 145.0, 160.0, 175.0, 190.0, 210.0, 230.0, 250.0, 275.0, 300.0,
198 330.0, 360.0, 400.0, 440.0, 490.0, 540.0, 600.0, 660.0, 730.0, 800.0, 880.0, 960.0, 1050.0,
199 1150.0, 1260.0, 1380.0, 1510.0, 1650.0, 1800.0, 1960.0, 2130.0, 2310.0, 2500.0, 2700.0, 2920.0,
200 3160.0, 3420.0, 3700.0, 4000.0, 4350.0, 4750.0, 5200.0, 5700.0, 6300.0, 7000.0, 7800.0, 8700.0,
201 9700.0, 10800.0, 12000.0, 13300.0, 14700.0, 16200.0, 17800.0, 19500.0, 21300.0, 23200.0,
202 25200.0, 27400.0, 29800.0, 32400.0, 35200.0, 38200.0, 41400.0, 44800.0, 48400.0, 52200.0,
203 56200.0, 60400.0, 64800.0, 69400.0, 74200.0, 79200.0, 84700.0, 90700.0, 97200.0, 104200.0,
204 111700.0, 119700.0, 128200.0, 137200.0, 146700.0, 156700.0, 167700.0, 179700.0, 192700.0,
205 206700.0, 221700.0, 237700.0, 254700.0, 272700.0, 291700.0, 311700.0, 333700.0, 357700.0,
206 383700.0, 411700.0, 441700.0, 476700.0, 516700.0, 561700.0, 611700.0, 666700.0, 726700.0,
207 791700.0, 861700.0, 936700.0, 1016700.0, 1101700.0, 1191700.0, 1286700.0, 1386700.0, 1496700.0,
208 1616700.0, 1746700.0, 1886700.0,
209];
210
211pub fn pet_level(rarity: PetRarity, xp: f64) -> LevelProgress {
216 let offset = rarity.offset();
217 let mut cumulative = Vec::with_capacity(99);
218 let mut total = 0.0;
219 for increment in &PET_XP_INCREMENTS[offset..offset + 99] {
220 total += increment;
221 cumulative.push(total);
222 }
223 let progress = level_from_cumulative(xp, &cumulative);
224 LevelProgress {
225 level: progress.level + 1,
226 fractional_level: progress.fractional_level + 1.0,
227 ..progress
228 }
229}
230
231pub fn skyblock_level(xp: f64) -> LevelProgress {
234 let xp = xp.max(0.0);
235 let level = (xp / 100.0).floor() as u32;
236 let into = xp - level as f64 * 100.0;
237 LevelProgress {
238 level,
239 total_xp: xp,
240 xp_into_level: into,
241 xp_to_next: Some(100.0 - into),
242 fractional_level: xp / 100.0,
243 }
244}
245
246const SKILL_AVERAGE_EXCLUDED: [&str; 2] = ["RUNECRAFTING", "SOCIAL"];
248
249#[derive(Debug, Clone, Default, Serialize)]
251pub struct MemberSummary {
252 pub skyblock: Option<LevelProgress>,
254 pub skills: HashMap<String, LevelProgress>,
257 pub skill_xp: HashMap<String, f64>,
259 pub skill_average: Option<f64>,
261 pub catacombs: Option<LevelProgress>,
263 pub classes: HashMap<String, LevelProgress>,
265 pub slayers: HashMap<String, LevelProgress>,
267 pub hotm: Option<LevelProgress>,
269 pub coin_purse: Option<f64>,
271}
272
273pub fn member_summary(
278 member: &SkyBlockMember,
279 skills_resource: Option<&SkillsResource>,
280) -> MemberSummary {
281 let mut summary = MemberSummary {
282 skyblock: member
283 .leveling
284 .as_ref()
285 .map(|l| skyblock_level(l.experience)),
286 catacombs: member
287 .dungeons
288 .as_ref()
289 .and_then(|d| d.dungeon_types.get("catacombs"))
290 .map(|c| catacombs_level(c.experience)),
291 hotm: member.mining_core.as_ref().map(|m| hotm_tier(m.experience)),
292 coin_purse: member.currencies.as_ref().map(|c| c.coin_purse),
293 ..MemberSummary::default()
294 };
295
296 if let Some(dungeons) = &member.dungeons {
297 for (class, progress) in &dungeons.player_classes {
298 summary
299 .classes
300 .insert(class.clone(), dungeon_class_level(progress.experience));
301 }
302 }
303
304 if let Some(slayer) = &member.slayer {
305 for (key, progress) in &slayer.slayer_bosses {
306 if let Some(boss) = SlayerBoss::from_key(key) {
307 summary
308 .slayers
309 .insert(key.clone(), slayer_level(boss, progress.xp));
310 }
311 }
312 }
313
314 if let Some(player_data) = &member.player_data {
315 for (key, xp) in &player_data.experience {
316 let name = key.strip_prefix("SKILL_").unwrap_or(key).to_string();
317 summary.skill_xp.insert(name.clone(), *xp);
318 if let Some(skill) = skills_resource.and_then(|r| r.skills.get(&name)) {
319 summary.skills.insert(name, skill_level(*xp, skill));
320 }
321 }
322 let averaged: Vec<f64> = summary
323 .skills
324 .iter()
325 .filter(|(name, _)| !SKILL_AVERAGE_EXCLUDED.contains(&name.as_str()))
326 .map(|(_, progress)| progress.fractional_level)
327 .collect();
328 if !averaged.is_empty() {
329 summary.skill_average = Some(averaged.iter().sum::<f64>() / averaged.len() as f64);
330 }
331 }
332
333 summary
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn catacombs_checkpoints() {
342 assert_eq!(catacombs_level(0.0).level, 0);
343 assert_eq!(catacombs_level(3_084_640.0).level, 30);
344 assert_eq!(catacombs_level(569_809_640.0).level, 50);
345 assert_eq!(catacombs_level(1e12).level, 50);
346 }
347
348 #[test]
349 fn slayer_checkpoints() {
350 assert_eq!(slayer_level(SlayerBoss::Zombie, 1_000_000.0).level, 9);
351 assert_eq!(slayer_level(SlayerBoss::Wolf, 1_499.0).level, 3);
352 assert_eq!(slayer_level(SlayerBoss::Vampire, 2_400.0).level, 5);
353 assert_eq!(SlayerBoss::from_key("BLAZE"), Some(SlayerBoss::Blaze));
354 assert_eq!(SlayerBoss::from_key("dragon"), None);
355 }
356
357 #[test]
358 fn hotm_checkpoints() {
359 assert_eq!(hotm_tier(0.0).level, 1);
360 assert_eq!(hotm_tier(3_000.0).level, 2);
361 assert_eq!(hotm_tier(1_246_999.0).level, 9);
362 assert_eq!(hotm_tier(1_247_000.0).level, 10);
363 }
364
365 #[test]
366 fn pet_checkpoints() {
367 assert_eq!(pet_level(PetRarity::Common, 0.0).level, 1);
368 assert_eq!(pet_level(PetRarity::Common, 5_624_785.0).level, 100);
370 assert_eq!(pet_level(PetRarity::Legendary, 25_353_229.0).level, 99);
371 assert_eq!(pet_level(PetRarity::Legendary, 25_353_230.0).level, 100);
372 assert_eq!(PetRarity::from_key("legendary"), Some(PetRarity::Legendary));
373 }
374
375 #[test]
376 fn skyblock_level_is_flat_100() {
377 let progress = skyblock_level(12_345.0);
378 assert_eq!(progress.level, 123);
379 assert_eq!(progress.xp_into_level, 45.0);
380 assert_eq!(progress.fractional_level, 123.45);
381 }
382
383 #[test]
384 fn member_summary_resolves_sections() {
385 let member: SkyBlockMember = serde_json::from_value(serde_json::json!({
386 "leveling": { "experience": 25_000 },
387 "player_data": { "experience": { "SKILL_MINING": 50.0 } },
388 "currencies": { "coin_purse": 1_234.5 },
389 "dungeons": {
390 "dungeon_types": { "catacombs": { "experience": 200.0 } },
391 "player_classes": { "mage": { "experience": 50.0 } }
392 },
393 "slayer": { "slayer_bosses": { "zombie": { "xp": 1_000.0 } } },
394 "mining_core": { "experience": 12_000.0 }
395 }))
396 .unwrap();
397
398 let skills: crate::models::resources::SkillsResource =
399 serde_json::from_value(serde_json::json!({
400 "version": "1",
401 "skills": {
402 "MINING": {
403 "name": "Mining",
404 "maxLevel": 2,
405 "levels": [
406 { "level": 1, "totalExpRequired": 25.0 },
407 { "level": 2, "totalExpRequired": 100.0 }
408 ]
409 }
410 }
411 }))
412 .unwrap();
413
414 let summary = member_summary(&member, Some(&skills));
415 assert_eq!(summary.skyblock.unwrap().level, 250);
416 assert_eq!(summary.skills["MINING"].level, 1);
417 assert_eq!(summary.skill_xp["MINING"], 50.0);
418 let average = summary.skill_average.unwrap();
419 assert!((average - (1.0 + 25.0 / 75.0)).abs() < 1e-9);
420 assert_eq!(summary.catacombs.unwrap().level, 2);
421 assert_eq!(summary.classes["mage"].level, 1);
422 assert_eq!(summary.slayers["zombie"].level, 4);
423 assert_eq!(summary.hotm.unwrap().level, 3);
424 assert_eq!(summary.coin_purse, Some(1_234.5));
425 }
426}