1use crate::{PrimaryAttributes, ProgressionXp};
7
8#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct ProgressionCurve {
11 pub baseline_display: u16,
12 pub xp_base: f64,
13 pub xp_growth: f64,
14}
15
16impl Default for ProgressionCurve {
17 fn default() -> Self {
18 Self {
19 baseline_display: 15,
20 xp_base: 100.0,
21 xp_growth: 1.12,
22 }
23 }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct LevelProgress {
29 pub current: u16,
30 pub next: u16,
31 pub xp: f64,
32 pub xp_floor: f64,
33 pub xp_ceiling: f64,
34 pub progress: f64,
36 pub xp_into_level: f64,
37 pub xp_to_next: f64,
38 pub at_cap: bool,
39}
40
41impl ProgressionCurve {
42 pub fn xp_for_display_level(&self, display: f64) -> f64 {
43 if display <= 1.0 {
44 return 0.0;
45 }
46 self.xp_base * self.xp_growth.powf(display - 1.0)
47 }
48
49 pub fn display_from_xp(&self, xp: f64) -> u16 {
50 let baseline_f = self.baseline_display as f64;
51 let bootstrap = self.xp_for_display_level(baseline_f);
52 if xp < bootstrap * 0.999 {
53 return self.baseline_display;
54 }
55 if xp <= 0.0 {
56 return self.baseline_display;
57 }
58 let ratio = xp / self.xp_base.max(f64::MIN_POSITIVE);
59 if ratio <= 1.0 {
60 return self.baseline_display;
61 }
62 let level = 1.0 + ratio.ln() / self.xp_growth.ln();
63 level.floor().max(baseline_f) as u16
64 }
65
66 pub fn skill_internal_from_xp(&self, xp: f64) -> u16 {
67 if xp <= 0.0 {
68 return 0;
69 }
70 let ratio = xp / self.xp_base.max(f64::MIN_POSITIVE);
71 if ratio <= 1.0 {
72 return 0;
73 }
74 let display = 1.0 + ratio.ln() / self.xp_growth.ln();
75 ((display.floor() * 10.0) as u16).min(1000)
76 }
77
78 pub fn primary_progress(&self, xp: f64) -> LevelProgress {
79 let current = self.display_from_xp(xp);
80 let next = current.saturating_add(1).min(100);
81 let floor = self.xp_for_display_level(current as f64);
82 let ceiling = self.xp_for_display_level(next as f64);
83 self.level_progress(xp, current, next, floor, ceiling)
84 }
85
86 pub fn skill_progress(&self, xp: f64) -> LevelProgress {
87 let internal = self.skill_internal_from_xp(xp);
88 let current = (internal / 100).min(10);
89 let next = current.saturating_add(1).min(10);
90 let floor = if current == 0 {
91 0.0
92 } else {
93 self.xp_for_display_level((current * 10) as f64)
94 };
95 let ceiling = if current >= 10 {
96 self.xp_for_display_level(100.0)
97 } else {
98 self.xp_for_display_level((next * 10) as f64)
99 };
100 self.level_progress(xp, current, next, floor, ceiling)
101 }
102
103 pub fn band_progress(&self, xp: f64, current_level: u16, next_level: u16) -> LevelProgress {
104 let floor = self.xp_for_display_level(current_level as f64);
105 let ceiling = self.xp_for_display_level(next_level as f64);
106 self.level_progress(xp, current_level, next_level, floor, ceiling)
107 }
108
109 fn level_progress(
110 &self,
111 xp: f64,
112 current: u16,
113 next: u16,
114 floor: f64,
115 ceiling: f64,
116 ) -> LevelProgress {
117 let at_cap = current >= next && current >= 100;
118 let span = (ceiling - floor).max(f64::MIN_POSITIVE);
119 let into = (xp - floor).max(0.0);
120 let progress = if at_cap {
121 1.0
122 } else {
123 (into / span).clamp(0.0, 1.0)
124 };
125 LevelProgress {
126 current,
127 next,
128 xp,
129 xp_floor: floor,
130 xp_ceiling: ceiling,
131 progress,
132 xp_into_level: into,
133 xp_to_next: if at_cap { 0.0 } else { (ceiling - xp).max(0.0) },
134 at_cap,
135 }
136 }
137}
138
139impl LevelProgress {
140 pub fn bar_label(&self, width: usize) -> String {
141 let width = width.max(8);
142 let filled = ((self.progress * width as f64).round() as usize).min(width);
143 let empty = width.saturating_sub(filled);
144 format!(
145 "[{}{}] {:>3.0}%",
146 "#".repeat(filled),
147 ".".repeat(empty),
148 self.progress * 100.0
149 )
150 }
151
152 pub fn detail_label(&self) -> String {
153 if self.at_cap {
154 format!("{:.0} XP (max)", self.xp)
155 } else {
156 format!(
157 "{:.0}/{:.0} XP · {:.0} to {}",
158 self.xp_into_level,
159 (self.xp_ceiling - self.xp_floor).max(0.0),
160 self.xp_to_next,
161 self.next
162 )
163 }
164 }
165}
166
167pub const PRIMARY_STAT_ROWS: [(&str, fn(&ProgressionXp) -> f64); 7] = [
169 ("STR", |xp| xp.strength),
170 ("DEX", |xp| xp.dexterity),
171 ("INT", |xp| xp.intelligence),
172 ("STA", |xp| xp.stamina),
173 ("VIT", |xp| xp.vitality),
174 ("WIS", |xp| xp.wisdom),
175 ("CHA", |xp| xp.charisma),
176];
177
178pub const SKILL_ROWS: [(
180 &str,
181 fn(&ProgressionXp) -> f64,
182 fn(&crate::PlayerSkills) -> u16,
183); 9] = [
184 ("Logging", |xp| xp.logging, |s| s.logging.display_tier()),
185 ("Mining", |xp| xp.mining, |s| s.mining.display_tier()),
186 (
187 "Evocation",
188 |xp| xp.evocation,
189 |s| s.evocation.display_tier(),
190 ),
191 (
192 "Restoration",
193 |xp| xp.restoration,
194 |s| s.restoration.display_tier(),
195 ),
196 ("Swords", |xp| xp.swords, |s| s.swords.display_tier()),
197 ("Archery", |xp| xp.archery, |s| s.archery.display_tier()),
198 ("Crafting", |xp| xp.crafting, |s| s.crafting.display_tier()),
199 ("Alchemy", |xp| xp.alchemy, |s| s.alchemy.display_tier()),
200 (
201 "Cartography",
202 |xp| xp.cartography,
203 |s| s.cartography.display_tier(),
204 ),
205];
206
207pub fn primary_internal(attrs: &PrimaryAttributes, label: &str) -> u16 {
209 match label {
210 "STR" => attrs.strength,
211 "DEX" => attrs.dexterity,
212 "INT" => attrs.intelligence,
213 "STA" => attrs.stamina,
214 "VIT" => attrs.vitality,
215 "WIS" => attrs.wisdom,
216 "CHA" => attrs.charisma,
217 _ => 0,
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn baseline_15_starts_at_floor() {
227 let curve = ProgressionCurve::default();
228 let xp = ProgressionXp::bootstrap_new(15, curve.xp_base, curve.xp_growth);
229 let prog = curve.primary_progress(xp.strength);
230 assert_eq!(prog.current, 15);
231 assert_eq!(prog.next, 16);
232 assert!(prog.progress < 0.01, "fresh 15 should be at start of bar");
233 }
234
235 #[test]
236 fn skill_tier_zero_has_ceiling() {
237 let curve = ProgressionCurve::default();
238 let prog = curve.skill_progress(0.0);
239 assert_eq!(prog.current, 0);
240 assert_eq!(prog.next, 1);
241 assert!(prog.xp_ceiling > 0.0);
242 }
243}