flatland-protocol 0.2.12

Flatland3 wire protocol types and codecs
Documentation
//! Client-facing progression curve helpers (`plans/27`).
//!
//! Mirrors sim tuning defaults so the character sheet can show XP-to-next-level
//! without pulling in `flatland_sim`.

use crate::{PrimaryAttributes, ProgressionXp};

/// Tunable progression curve (defaults match `flatland_settings::ProgressionSettings`).
#[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,
        }
    }
}

/// Progress within the current display level or skill tier.
#[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,
    /// Fraction complete toward `next` (0.0–1.0).
    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
            )
        }
    }
}

/// Named primary attribute row for character sheets.
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),
];

/// Named skill row for character sheets.
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(),
    ),
];

/// Internal attribute value (1–1000) for designers debugging pool math.
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);
    }
}