Skip to main content

flatland_protocol/
progression.rs

1//! Client-facing progression curve helpers (`plans/27`).
2//!
3//! Mirrors sim tuning defaults so the character sheet can show XP-to-next-level
4//! without pulling in `flatland_sim`.
5
6use crate::{PrimaryAttributes, ProgressionXp};
7
8/// Tunable progression curve (defaults match `flatland_settings::ProgressionSettings`).
9#[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/// Progress within the current display level or skill tier.
27#[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    /// Fraction complete toward `next` (0.0–1.0).
35    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
167/// Named primary attribute row for character sheets.
168pub 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
178/// Named skill row for character sheets.
179pub 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
207/// Internal attribute value (1–1000) for designers debugging pool math.
208pub 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}