Skip to main content

hypixel/util/
skyblock.rs

1//! SkyBlock-specific leveling curves: catacombs and dungeon classes, slayers,
2//! pets, Heart of the Mountain, and the SkyBlock profile level, plus
3//! [`member_summary`] which resolves a whole member at once.
4
5use 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
13/// Cumulative XP required to reach catacombs levels 1 through 50.
14///
15/// Dungeon classes (healer, mage, berserk, archer, tank) share this curve.
16pub 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
69/// Resolve catacombs experience into a level (0-50).
70pub fn catacombs_level(xp: f64) -> LevelProgress {
71    level_from_cumulative(xp, &CATACOMBS_XP)
72}
73
74/// Resolve dungeon class experience into a level (0-50).
75///
76/// Classes share the catacombs curve; this is an alias of [`catacombs_level`].
77pub fn dungeon_class_level(xp: f64) -> LevelProgress {
78    catacombs_level(xp)
79}
80
81/// A slayer boss type.
82#[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    /// Parse a boss from its API key (`zombie`, `spider`, `wolf`, `enderman`,
102    /// `blaze`, `vampire`), as used in the profile `slayer.slayer_bosses` map.
103    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    /// The API key for this boss.
116    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    /// Cumulative XP thresholds for this boss's slayer levels.
128    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
137/// Resolve slayer experience into a level (0-9, or 0-5 for vampire).
138pub fn slayer_level(boss: SlayerBoss, xp: f64) -> LevelProgress {
139    level_from_cumulative(xp, boss.xp_table())
140}
141
142/// Cumulative XP required to reach Heart of the Mountain tiers 2 through 10.
143pub 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
147/// Resolve Heart of the Mountain experience (`mining_core.experience`) into a
148/// tier. Tiers are 1-based: zero XP is tier 1, the cap is tier 10.
149pub 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/// A pet rarity tier, determining which slice of the pet XP curve applies.
159#[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    /// Parse a rarity from its API value (the pet `tier` field).
171    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
194/// Per-level pet XP requirements. Each rarity reads 99 consecutive entries
195/// starting at its offset (higher rarities cost more per level).
196const 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
211/// Resolve pet experience into a level (1-100) for the given rarity.
212///
213/// Covers the standard pet curve; pets with custom curves (e.g. the golden
214/// dragon's extension to level 200) resolve as level 100 at their standard cap.
215pub 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
231/// Resolve SkyBlock profile experience (`leveling.experience`) into a level.
232/// Every level costs a flat 100 XP.
233pub 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
246/// Skills excluded from the skill average (cosmetic progressions).
247const SKILL_AVERAGE_EXCLUDED: [&str; 2] = ["RUNECRAFTING", "SOCIAL"];
248
249/// Every leveling-related stat of a profile member, resolved in one pass.
250#[derive(Debug, Clone, Default, Serialize)]
251pub struct MemberSummary {
252    /// SkyBlock profile level.
253    pub skyblock: Option<LevelProgress>,
254    /// Skill progress keyed by skill name (`MINING`, `COMBAT`, ...). Only
255    /// present when a skills resource was supplied.
256    pub skills: HashMap<String, LevelProgress>,
257    /// Raw skill XP keyed by skill name, available even without a resource.
258    pub skill_xp: HashMap<String, f64>,
259    /// Mean fractional skill level, excluding runecrafting and social.
260    pub skill_average: Option<f64>,
261    /// Catacombs level.
262    pub catacombs: Option<LevelProgress>,
263    /// Dungeon class levels keyed by class name (`mage`, `tank`, ...).
264    pub classes: HashMap<String, LevelProgress>,
265    /// Slayer levels keyed by boss (`zombie`, `wolf`, ...).
266    pub slayers: HashMap<String, LevelProgress>,
267    /// Heart of the Mountain tier.
268    pub hotm: Option<LevelProgress>,
269    /// Purse balance from the currencies section.
270    pub coin_purse: Option<f64>,
271}
272
273/// Resolve every leveling stat of a member in one pass.
274///
275/// Pass the `resources/skyblock/skills` response to also resolve skill levels
276/// and the skill average; without it only raw skill XP is reported.
277pub 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        // Totals to level 100 per rarity, from the standard pet curve.
369        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}