use std::collections::HashMap;
use serde::Serialize;
use crate::models::resources::SkillsResource;
use crate::models::skyblock::SkyBlockMember;
use crate::util::leveling::{LevelProgress, level_from_cumulative, skill_level};
pub const CATACOMBS_XP: [f64; 50] = [
50.0,
125.0,
235.0,
395.0,
625.0,
955.0,
1425.0,
2095.0,
3045.0,
4385.0,
6275.0,
8940.0,
12700.0,
17960.0,
25340.0,
35640.0,
50040.0,
70040.0,
97640.0,
135640.0,
188140.0,
259640.0,
356640.0,
488640.0,
668640.0,
911640.0,
1239640.0,
1684640.0,
2284640.0,
3084640.0,
4149640.0,
5559640.0,
7459640.0,
9959640.0,
13259640.0,
17559640.0,
23159640.0,
30359640.0,
39559640.0,
51559640.0,
66559640.0,
85559640.0,
109559640.0,
139559640.0,
177559640.0,
225559640.0,
285559640.0,
360559640.0,
453559640.0,
569809640.0,
];
pub fn catacombs_level(xp: f64) -> LevelProgress {
level_from_cumulative(xp, &CATACOMBS_XP)
}
pub fn dungeon_class_level(xp: f64) -> LevelProgress {
catacombs_level(xp)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SlayerBoss {
Zombie,
Spider,
Wolf,
Enderman,
Blaze,
Vampire,
}
const SLAYER_XP_ZOMBIE_SPIDER: [f64; 9] = [
5.0, 15.0, 200.0, 1000.0, 5000.0, 20000.0, 100000.0, 400000.0, 1000000.0,
];
const SLAYER_XP_STANDARD: [f64; 9] = [
10.0, 30.0, 250.0, 1500.0, 5000.0, 20000.0, 100000.0, 400000.0, 1000000.0,
];
const SLAYER_XP_VAMPIRE: [f64; 5] = [20.0, 75.0, 240.0, 840.0, 2400.0];
impl SlayerBoss {
pub fn from_key(key: &str) -> Option<Self> {
match key.to_ascii_lowercase().as_str() {
"zombie" => Some(Self::Zombie),
"spider" => Some(Self::Spider),
"wolf" => Some(Self::Wolf),
"enderman" => Some(Self::Enderman),
"blaze" => Some(Self::Blaze),
"vampire" => Some(Self::Vampire),
_ => None,
}
}
pub fn key(&self) -> &'static str {
match self {
Self::Zombie => "zombie",
Self::Spider => "spider",
Self::Wolf => "wolf",
Self::Enderman => "enderman",
Self::Blaze => "blaze",
Self::Vampire => "vampire",
}
}
pub fn xp_table(&self) -> &'static [f64] {
match self {
Self::Zombie | Self::Spider => &SLAYER_XP_ZOMBIE_SPIDER,
Self::Wolf | Self::Enderman | Self::Blaze => &SLAYER_XP_STANDARD,
Self::Vampire => &SLAYER_XP_VAMPIRE,
}
}
}
pub fn slayer_level(boss: SlayerBoss, xp: f64) -> LevelProgress {
level_from_cumulative(xp, boss.xp_table())
}
pub const HOTM_XP: [f64; 9] = [
3000.0, 12000.0, 37000.0, 97000.0, 197000.0, 347000.0, 557000.0, 847000.0, 1247000.0,
];
pub fn hotm_tier(xp: f64) -> LevelProgress {
let progress = level_from_cumulative(xp, &HOTM_XP);
LevelProgress {
level: progress.level + 1,
fractional_level: progress.fractional_level + 1.0,
..progress
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PetRarity {
Common,
Uncommon,
Rare,
Epic,
Legendary,
Mythic,
}
impl PetRarity {
pub fn from_key(key: &str) -> Option<Self> {
match key.to_ascii_uppercase().as_str() {
"COMMON" => Some(Self::Common),
"UNCOMMON" => Some(Self::Uncommon),
"RARE" => Some(Self::Rare),
"EPIC" => Some(Self::Epic),
"LEGENDARY" => Some(Self::Legendary),
"MYTHIC" => Some(Self::Mythic),
_ => None,
}
}
fn offset(&self) -> usize {
match self {
Self::Common => 0,
Self::Uncommon => 6,
Self::Rare => 11,
Self::Epic => 16,
Self::Legendary | Self::Mythic => 20,
}
}
}
const PET_XP_INCREMENTS: [f64; 119] = [
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,
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,
1150.0, 1260.0, 1380.0, 1510.0, 1650.0, 1800.0, 1960.0, 2130.0, 2310.0, 2500.0, 2700.0, 2920.0,
3160.0, 3420.0, 3700.0, 4000.0, 4350.0, 4750.0, 5200.0, 5700.0, 6300.0, 7000.0, 7800.0, 8700.0,
9700.0, 10800.0, 12000.0, 13300.0, 14700.0, 16200.0, 17800.0, 19500.0, 21300.0, 23200.0,
25200.0, 27400.0, 29800.0, 32400.0, 35200.0, 38200.0, 41400.0, 44800.0, 48400.0, 52200.0,
56200.0, 60400.0, 64800.0, 69400.0, 74200.0, 79200.0, 84700.0, 90700.0, 97200.0, 104200.0,
111700.0, 119700.0, 128200.0, 137200.0, 146700.0, 156700.0, 167700.0, 179700.0, 192700.0,
206700.0, 221700.0, 237700.0, 254700.0, 272700.0, 291700.0, 311700.0, 333700.0, 357700.0,
383700.0, 411700.0, 441700.0, 476700.0, 516700.0, 561700.0, 611700.0, 666700.0, 726700.0,
791700.0, 861700.0, 936700.0, 1016700.0, 1101700.0, 1191700.0, 1286700.0, 1386700.0, 1496700.0,
1616700.0, 1746700.0, 1886700.0,
];
pub fn pet_level(rarity: PetRarity, xp: f64) -> LevelProgress {
let offset = rarity.offset();
let mut cumulative = Vec::with_capacity(99);
let mut total = 0.0;
for increment in &PET_XP_INCREMENTS[offset..offset + 99] {
total += increment;
cumulative.push(total);
}
let progress = level_from_cumulative(xp, &cumulative);
LevelProgress {
level: progress.level + 1,
fractional_level: progress.fractional_level + 1.0,
..progress
}
}
pub fn skyblock_level(xp: f64) -> LevelProgress {
let xp = xp.max(0.0);
let level = (xp / 100.0).floor() as u32;
let into = xp - level as f64 * 100.0;
LevelProgress {
level,
total_xp: xp,
xp_into_level: into,
xp_to_next: Some(100.0 - into),
fractional_level: xp / 100.0,
}
}
const SKILL_AVERAGE_EXCLUDED: [&str; 2] = ["RUNECRAFTING", "SOCIAL"];
#[derive(Debug, Clone, Default, Serialize)]
pub struct MemberSummary {
pub skyblock: Option<LevelProgress>,
pub skills: HashMap<String, LevelProgress>,
pub skill_xp: HashMap<String, f64>,
pub skill_average: Option<f64>,
pub catacombs: Option<LevelProgress>,
pub classes: HashMap<String, LevelProgress>,
pub slayers: HashMap<String, LevelProgress>,
pub hotm: Option<LevelProgress>,
pub coin_purse: Option<f64>,
}
pub fn member_summary(
member: &SkyBlockMember,
skills_resource: Option<&SkillsResource>,
) -> MemberSummary {
let mut summary = MemberSummary {
skyblock: member
.leveling
.as_ref()
.map(|l| skyblock_level(l.experience)),
catacombs: member
.dungeons
.as_ref()
.and_then(|d| d.dungeon_types.get("catacombs"))
.map(|c| catacombs_level(c.experience)),
hotm: member.mining_core.as_ref().map(|m| hotm_tier(m.experience)),
coin_purse: member.currencies.as_ref().map(|c| c.coin_purse),
..MemberSummary::default()
};
if let Some(dungeons) = &member.dungeons {
for (class, progress) in &dungeons.player_classes {
summary
.classes
.insert(class.clone(), dungeon_class_level(progress.experience));
}
}
if let Some(slayer) = &member.slayer {
for (key, progress) in &slayer.slayer_bosses {
if let Some(boss) = SlayerBoss::from_key(key) {
summary
.slayers
.insert(key.clone(), slayer_level(boss, progress.xp));
}
}
}
if let Some(player_data) = &member.player_data {
for (key, xp) in &player_data.experience {
let name = key.strip_prefix("SKILL_").unwrap_or(key).to_string();
summary.skill_xp.insert(name.clone(), *xp);
if let Some(skill) = skills_resource.and_then(|r| r.skills.get(&name)) {
summary.skills.insert(name, skill_level(*xp, skill));
}
}
let averaged: Vec<f64> = summary
.skills
.iter()
.filter(|(name, _)| !SKILL_AVERAGE_EXCLUDED.contains(&name.as_str()))
.map(|(_, progress)| progress.fractional_level)
.collect();
if !averaged.is_empty() {
summary.skill_average = Some(averaged.iter().sum::<f64>() / averaged.len() as f64);
}
}
summary
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catacombs_checkpoints() {
assert_eq!(catacombs_level(0.0).level, 0);
assert_eq!(catacombs_level(3_084_640.0).level, 30);
assert_eq!(catacombs_level(569_809_640.0).level, 50);
assert_eq!(catacombs_level(1e12).level, 50);
}
#[test]
fn slayer_checkpoints() {
assert_eq!(slayer_level(SlayerBoss::Zombie, 1_000_000.0).level, 9);
assert_eq!(slayer_level(SlayerBoss::Wolf, 1_499.0).level, 3);
assert_eq!(slayer_level(SlayerBoss::Vampire, 2_400.0).level, 5);
assert_eq!(SlayerBoss::from_key("BLAZE"), Some(SlayerBoss::Blaze));
assert_eq!(SlayerBoss::from_key("dragon"), None);
}
#[test]
fn hotm_checkpoints() {
assert_eq!(hotm_tier(0.0).level, 1);
assert_eq!(hotm_tier(3_000.0).level, 2);
assert_eq!(hotm_tier(1_246_999.0).level, 9);
assert_eq!(hotm_tier(1_247_000.0).level, 10);
}
#[test]
fn pet_checkpoints() {
assert_eq!(pet_level(PetRarity::Common, 0.0).level, 1);
assert_eq!(pet_level(PetRarity::Common, 5_624_785.0).level, 100);
assert_eq!(pet_level(PetRarity::Legendary, 25_353_229.0).level, 99);
assert_eq!(pet_level(PetRarity::Legendary, 25_353_230.0).level, 100);
assert_eq!(PetRarity::from_key("legendary"), Some(PetRarity::Legendary));
}
#[test]
fn skyblock_level_is_flat_100() {
let progress = skyblock_level(12_345.0);
assert_eq!(progress.level, 123);
assert_eq!(progress.xp_into_level, 45.0);
assert_eq!(progress.fractional_level, 123.45);
}
#[test]
fn member_summary_resolves_sections() {
let member: SkyBlockMember = serde_json::from_value(serde_json::json!({
"leveling": { "experience": 25_000 },
"player_data": { "experience": { "SKILL_MINING": 50.0 } },
"currencies": { "coin_purse": 1_234.5 },
"dungeons": {
"dungeon_types": { "catacombs": { "experience": 200.0 } },
"player_classes": { "mage": { "experience": 50.0 } }
},
"slayer": { "slayer_bosses": { "zombie": { "xp": 1_000.0 } } },
"mining_core": { "experience": 12_000.0 }
}))
.unwrap();
let skills: crate::models::resources::SkillsResource =
serde_json::from_value(serde_json::json!({
"version": "1",
"skills": {
"MINING": {
"name": "Mining",
"maxLevel": 2,
"levels": [
{ "level": 1, "totalExpRequired": 25.0 },
{ "level": 2, "totalExpRequired": 100.0 }
]
}
}
}))
.unwrap();
let summary = member_summary(&member, Some(&skills));
assert_eq!(summary.skyblock.unwrap().level, 250);
assert_eq!(summary.skills["MINING"].level, 1);
assert_eq!(summary.skill_xp["MINING"], 50.0);
let average = summary.skill_average.unwrap();
assert!((average - (1.0 + 25.0 / 75.0)).abs() < 1e-9);
assert_eq!(summary.catacombs.unwrap().level, 2);
assert_eq!(summary.classes["mage"].level, 1);
assert_eq!(summary.slayers["zombie"].level, 4);
assert_eq!(summary.hotm.unwrap().level, 3);
assert_eq!(summary.coin_purse, Some(1_234.5));
}
}