use crate::{PrimaryAttributes, ProgressionXp};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ProgressionCurve {
pub baseline_display: u16,
pub xp_base: f64,
pub xp_growth: f64,
}
impl Default for ProgressionCurve {
fn default() -> Self {
Self {
baseline_display: 15,
xp_base: 100.0,
xp_growth: 1.12,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LevelProgress {
pub current: u16,
pub next: u16,
pub xp: f64,
pub xp_floor: f64,
pub xp_ceiling: f64,
pub progress: f64,
pub xp_into_level: f64,
pub xp_to_next: f64,
pub at_cap: bool,
}
impl ProgressionCurve {
pub fn xp_for_display_level(&self, display: f64) -> f64 {
if display <= 1.0 {
return 0.0;
}
self.xp_base * self.xp_growth.powf(display - 1.0)
}
pub fn display_from_xp(&self, xp: f64) -> u16 {
let baseline_f = self.baseline_display as f64;
let bootstrap = self.xp_for_display_level(baseline_f);
if xp < bootstrap * 0.999 {
return self.baseline_display;
}
if xp <= 0.0 {
return self.baseline_display;
}
let ratio = xp / self.xp_base.max(f64::MIN_POSITIVE);
if ratio <= 1.0 {
return self.baseline_display;
}
let level = 1.0 + ratio.ln() / self.xp_growth.ln();
level.floor().max(baseline_f) as u16
}
pub fn skill_internal_from_xp(&self, xp: f64) -> u16 {
if xp <= 0.0 {
return 0;
}
let ratio = xp / self.xp_base.max(f64::MIN_POSITIVE);
if ratio <= 1.0 {
return 0;
}
let display = 1.0 + ratio.ln() / self.xp_growth.ln();
((display.floor() * 10.0) as u16).min(1000)
}
pub fn primary_progress(&self, xp: f64) -> LevelProgress {
let current = self.display_from_xp(xp);
let next = current.saturating_add(1).min(100);
let floor = self.xp_for_display_level(current as f64);
let ceiling = self.xp_for_display_level(next as f64);
self.level_progress(xp, current, next, floor, ceiling)
}
pub fn skill_progress(&self, xp: f64) -> LevelProgress {
let internal = self.skill_internal_from_xp(xp);
let current = (internal / 100).min(10);
let next = current.saturating_add(1).min(10);
let floor = if current == 0 {
0.0
} else {
self.xp_for_display_level((current * 10) as f64)
};
let ceiling = if current >= 10 {
self.xp_for_display_level(100.0)
} else {
self.xp_for_display_level((next * 10) as f64)
};
self.level_progress(xp, current, next, floor, ceiling)
}
pub fn band_progress(&self, xp: f64, current_level: u16, next_level: u16) -> LevelProgress {
let floor = self.xp_for_display_level(current_level as f64);
let ceiling = self.xp_for_display_level(next_level as f64);
self.level_progress(xp, current_level, next_level, floor, ceiling)
}
fn level_progress(
&self,
xp: f64,
current: u16,
next: u16,
floor: f64,
ceiling: f64,
) -> LevelProgress {
let at_cap = current >= next && current >= 100;
let span = (ceiling - floor).max(f64::MIN_POSITIVE);
let into = (xp - floor).max(0.0);
let progress = if at_cap {
1.0
} else {
(into / span).clamp(0.0, 1.0)
};
LevelProgress {
current,
next,
xp,
xp_floor: floor,
xp_ceiling: ceiling,
progress,
xp_into_level: into,
xp_to_next: if at_cap { 0.0 } else { (ceiling - xp).max(0.0) },
at_cap,
}
}
}
impl LevelProgress {
pub fn bar_label(&self, width: usize) -> String {
let width = width.max(8);
let filled = ((self.progress * width as f64).round() as usize).min(width);
let empty = width.saturating_sub(filled);
format!(
"[{}{}] {:>3.0}%",
"#".repeat(filled),
".".repeat(empty),
self.progress * 100.0
)
}
pub fn detail_label(&self) -> String {
if self.at_cap {
format!("{:.0} XP (max)", self.xp)
} else {
format!(
"{:.0}/{:.0} XP · {:.0} to {}",
self.xp_into_level,
(self.xp_ceiling - self.xp_floor).max(0.0),
self.xp_to_next,
self.next
)
}
}
}
pub const PRIMARY_STAT_ROWS: [(&str, fn(&ProgressionXp) -> f64); 7] = [
("STR", |xp| xp.strength),
("DEX", |xp| xp.dexterity),
("INT", |xp| xp.intelligence),
("STA", |xp| xp.stamina),
("VIT", |xp| xp.vitality),
("WIS", |xp| xp.wisdom),
("CHA", |xp| xp.charisma),
];
pub const SKILL_ROWS: [(
&str,
fn(&ProgressionXp) -> f64,
fn(&crate::PlayerSkills) -> u16,
); 9] = [
("Logging", |xp| xp.logging, |s| s.logging.display_tier()),
("Mining", |xp| xp.mining, |s| s.mining.display_tier()),
(
"Evocation",
|xp| xp.evocation,
|s| s.evocation.display_tier(),
),
(
"Restoration",
|xp| xp.restoration,
|s| s.restoration.display_tier(),
),
("Swords", |xp| xp.swords, |s| s.swords.display_tier()),
("Archery", |xp| xp.archery, |s| s.archery.display_tier()),
("Crafting", |xp| xp.crafting, |s| s.crafting.display_tier()),
("Alchemy", |xp| xp.alchemy, |s| s.alchemy.display_tier()),
(
"Cartography",
|xp| xp.cartography,
|s| s.cartography.display_tier(),
),
];
pub fn primary_internal(attrs: &PrimaryAttributes, label: &str) -> u16 {
match label {
"STR" => attrs.strength,
"DEX" => attrs.dexterity,
"INT" => attrs.intelligence,
"STA" => attrs.stamina,
"VIT" => attrs.vitality,
"WIS" => attrs.wisdom,
"CHA" => attrs.charisma,
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn baseline_15_starts_at_floor() {
let curve = ProgressionCurve::default();
let xp = ProgressionXp::bootstrap_new(15, curve.xp_base, curve.xp_growth);
let prog = curve.primary_progress(xp.strength);
assert_eq!(prog.current, 15);
assert_eq!(prog.next, 16);
assert!(prog.progress < 0.01, "fresh 15 should be at start of bar");
}
#[test]
fn skill_tier_zero_has_ceiling() {
let curve = ProgressionCurve::default();
let prog = curve.skill_progress(0.0);
assert_eq!(prog.current, 0);
assert_eq!(prog.next, 1);
assert!(prog.xp_ceiling > 0.0);
}
}