use crate::constants; use crate::error::DomainError; use crate::spells; use sb_shared::{Character, CharacterStats, Class, Race}; pub struct TrainingGround { pub stat: &'static str, pub name: &'static str, pub description: &'static str, } pub const TRAINING_GROUNDS: &[TrainingGround] = &[ TrainingGround { stat: "strength", name: "The Anvil", description: "Pound steel and lift stone under the guidance of master smiths.", }, TrainingGround { stat: "dexterity", name: "The Archery Range", description: "Sharpen reflexes with bow, blade, and balance drills.", }, TrainingGround { stat: "constitution", name: "The Endurance Pits", description: "Push your body past its limits in grueling trials of resilience.", }, TrainingGround { stat: "intelligence", name: "The Sage's Study", description: "Study ancient tomes and practice arcane disciplines.", }, TrainingGround { stat: "luck", name: "Fortune's Shrine", description: "Consult oracles and tempt fate in games of chance.", }, ]; pub struct RaceInfo { pub race: Race, pub name: &'static str, pub str_mod: i64, pub int_mod: i64, pub dex_mod: i64, pub con_mod: i64, pub luck_mod: i64, } pub const RACES: &[RaceInfo] = &[ RaceInfo { race: Race::Human, name: "Human", str_mod: 0, int_mod: 0, dex_mod: 0, con_mod: 0, luck_mod: 0, }, RaceInfo { race: Race::Elf, name: "Elf", str_mod: -2, int_mod: 4, dex_mod: 2, con_mod: -2, luck_mod: 0, }, RaceInfo { race: Race::Dwarf, name: "Dwarf", str_mod: 2, int_mod: -2, dex_mod: -2, con_mod: 4, luck_mod: 0, }, RaceInfo { race: Race::Hobbit, name: "Hobbit", str_mod: -4, int_mod: 0, dex_mod: 2, con_mod: -2, luck_mod: 4, }, RaceInfo { race: Race::HalfElf, name: "Half-Elf", str_mod: 0, int_mod: 2, dex_mod: 2, con_mod: 0, luck_mod: 0, }, ]; pub struct ClassInfo { pub class: Class, pub name: &'static str, pub primary_stat: &'static str, pub secondary_stat: &'static str, pub base_hp: i32, pub hp_per_con: f64, } pub const CLASSES: &[ClassInfo] = &[ ClassInfo { class: Class::Warrior, name: "Warrior", primary_stat: "strength", secondary_stat: "constitution", base_hp: 20, hp_per_con: 3.0, }, ClassInfo { class: Class::Paladin, name: "Paladin", primary_stat: "strength", secondary_stat: "luck", base_hp: 18, hp_per_con: 2.5, }, ClassInfo { class: Class::Rogue, name: "Rogue", primary_stat: "dexterity", secondary_stat: "luck", base_hp: 14, hp_per_con: 2.0, }, ClassInfo { class: Class::Bard, name: "Bard", primary_stat: "dexterity", secondary_stat: "intelligence", base_hp: 14, hp_per_con: 2.0, }, ClassInfo { class: Class::Hunter, name: "Hunter", primary_stat: "dexterity", secondary_stat: "strength", base_hp: 16, hp_per_con: 2.5, }, ClassInfo { class: Class::Monk, name: "Monk", primary_stat: "dexterity", secondary_stat: "constitution", base_hp: 15, hp_per_con: 2.5, }, ClassInfo { class: Class::Mage, name: "Mage", primary_stat: "intelligence", secondary_stat: "dexterity", base_hp: 11, hp_per_con: 1.8, }, ClassInfo { class: Class::Wizard, name: "Wizard", primary_stat: "intelligence", secondary_stat: "constitution", base_hp: 10, hp_per_con: 1.5, }, ClassInfo { class: Class::Conjurer, name: "Conjurer", primary_stat: "intelligence", secondary_stat: "luck", base_hp: 10, hp_per_con: 1.5, }, ClassInfo { class: Class::Sorcerer, name: "Sorcerer", primary_stat: "intelligence", secondary_stat: "strength", base_hp: 12, hp_per_con: 2.0, }, ]; pub fn stat_bonus(stat: i64) -> i64 { (stat - constants::STAT_BONUS_NEUTRAL) / constants::STAT_BONUS_STEP } fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 { let base: i64 = rng.random_range(constants::STAT_ROLL_MIN..=constants::STAT_ROLL_MAX); base + modifier } pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats { let info = RACES .iter() .find(|r| &r.race == race) .expect("invalid race"); CharacterStats { strength: roll_stat(rng, info.str_mod), intelligence: roll_stat(rng, info.int_mod), dexterity: roll_stat(rng, info.dex_mod), constitution: roll_stat(rng, info.con_mod), luck: roll_stat(rng, info.luck_mod), } } pub fn calc_max_hp(class: &Class, stats: &CharacterStats) -> i32 { let info = CLASSES .iter() .find(|c| &c.class == class) .expect("invalid class"); (info.base_hp as f64 + stats.constitution as f64 * info.hp_per_con) as i32 } pub fn calc_max_energy(class: &Class) -> i32 { let info = CLASSES .iter() .find(|c| &c.class == class) .expect("invalid class"); constants::BASE_MAX_ENERGY + info.base_hp / constants::MAX_ENERGY_PER_BASE_HP_DIVISOR } pub fn calc_max_nerve(class: &Class) -> i32 { let info = CLASSES .iter() .find(|c| &c.class == class) .expect("invalid class"); constants::BASE_MAX_NERVE + info.base_hp / constants::MAX_NERVE_PER_BASE_HP_DIVISOR } pub fn energy_regen_rate() -> i64 { constants::ENERGY_REGEN_INTERVAL_MS } pub fn nerve_regen_rate() -> i64 { constants::NERVE_REGEN_INTERVAL_MS } pub fn hp_regen_rate() -> i64 { constants::HP_REGEN_INTERVAL_MS } pub fn apply_regen(char: &mut Character, now_ms: i64) { let elapsed = now_ms - char.last_regen; if elapsed <= 0 { return; } let energy_ticks = (elapsed / energy_regen_rate()) as i32; let nerve_ticks = (elapsed / nerve_regen_rate()) as i32; let hp_ticks = (elapsed / hp_regen_rate()) as i32; if energy_ticks > 0 { char.energy = (char.energy + energy_ticks).min(char.max_energy); } if nerve_ticks > 0 { char.nerve = (char.nerve + nerve_ticks).min(char.max_nerve); } if hp_ticks > 0 && char.hp > 0 && char.hp < char.max_hp { let regen_per_tick = (char.max_hp as f64 * constants::HP_REGEN_FRACTION_PER_TICK).ceil() as i32; char.hp = (char.hp + regen_per_tick * hp_ticks).min(char.max_hp); } char.last_regen = now_ms; } pub fn training_cost(stat: i64) -> i32 { let scaled = (stat as f64 / constants::TRAINING_STAT_SCALER).floor() as i32; (constants::TRAINING_BASE_COST * constants::TRAINING_GROWTH_FACTOR.powi(scaled)) as i32 } pub fn create_character( rng: &mut impl rand::Rng, user_id: u32, name: String, race: Race, class: Class, ) -> Character { let class_str = class.as_str().to_string(); let stats = roll_stats(rng, &race); let max_hp = calc_max_hp(&class, &stats); let max_energy = calc_max_energy(&class); let max_nerve = calc_max_nerve(&class); let known_spells: Vec = if spells::is_caster_class(&class_str) { spells::spells_for_class(&class_str, 1) .iter() .filter(|s| s.spell_level <= 1) .map(|s| s.id.clone()) .collect() } else { Vec::new() }; Character { id: 0, user_id, name, race, class, level: constants::STARTING_LEVEL, xp: 0, stats, hp: max_hp, max_hp, energy: max_energy, max_energy, nerve: max_nerve, max_nerve, gold: constants::STARTING_GOLD, last_regen: 0, equipment: [ ("head".into(), None), ("body".into(), None), ("off_hand".into(), None), ("main_hand".into(), None), ("hands".into(), None), ("feet".into(), None), ("back".into(), None), ("neck".into(), None), ("ring".into(), None), ("trinket".into(), None), ("instrument".into(), None), ] .into_iter() .collect(), inventory: Vec::new(), active_song: None, known_spells, active_spell: None, spell_points: spells::max_spell_points(&class_str, 1), max_spell_points: spells::max_spell_points(&class_str, 1), } } pub const MAX_LEVEL: i32 = 100; pub const XP_FACTOR: i64 = 18; pub fn xp_for_level(level: i32) -> i64 { if level <= 1 { return 0; } let n = (level - 1) as f64; XP_FACTOR * (constants::XP_CURVE_COEFFICIENT * n * n) as i64 } pub fn level_up_cost(level: i32) -> i32 { constants::LEVEL_UP_GOLD_PER_LEVEL * level } pub fn level_up(char: &mut Character) -> Result { if char.level >= MAX_LEVEL { return Err(DomainError::AlreadyAtMaxLevel); } let next_level = char.level + 1; if char.xp < xp_for_level(next_level) { return Err(DomainError::NotEnoughXp); } let cost = level_up_cost(next_level); if char.gold < cost { return Err(DomainError::NotEnoughGold); } char.gold -= cost; char.level = next_level; let class_str = char.class.as_str().to_string(); char.max_hp = calc_max_hp(&char.class, &char.stats); char.hp = char.max_hp; char.max_energy = calc_max_energy(&char.class); char.energy = char.max_energy; char.max_nerve = calc_max_nerve(&char.class); char.nerve = char.max_nerve; char.max_spell_points = spells::max_spell_points(&class_str, char.level); char.spell_points = char.max_spell_points; Ok(char.level) } pub fn find_race_info(race: &Race) -> &'static RaceInfo { RACES .iter() .find(|r| &r.race == race) .expect("invalid race") } pub fn find_class_info(class: &Class) -> &'static ClassInfo { CLASSES .iter() .find(|c| &c.class == class) .expect("invalid class") } /// Gym stat display order — pinned to the get_gym handler's fixed array /// (routes/mod.rs:588–594), deliberately differs from TRAINING_GROUNDS order. pub const GYM_STAT_DISPLAY_ORDER: &[&str] = &[ "strength", "intelligence", "dexterity", "constitution", "luck", ]; pub fn stat_value(stats: &CharacterStats, stat: &str) -> Option { match stat { "strength" => Some(stats.strength), "intelligence" => Some(stats.intelligence), "dexterity" => Some(stats.dexterity), "constitution" => Some(stats.constitution), "luck" => Some(stats.luck), _ => None, } } #[cfg(test)] mod tests { use super::*; use sb_shared::{Character, CharacterStats, Class, Race}; use std::collections::HashMap; fn base_char() -> Character { Character { id: 1, user_id: 1, name: "Test".into(), race: Race::Human, class: Class::Warrior, level: 1, xp: 0, stats: CharacterStats { strength: 20, intelligence: 20, dexterity: 20, constitution: 20, luck: 20, }, hp: 80, max_hp: 80, energy: 36, max_energy: 36, nerve: 110, max_nerve: 110, gold: 0, last_regen: 0, equipment: HashMap::new(), inventory: Vec::new(), active_song: None, known_spells: Vec::new(), active_spell: None, spell_points: 0, max_spell_points: 0, } } #[test] fn stat_bonus_at_20_is_zero() { assert_eq!(stat_bonus(20), 0); } #[test] fn stat_bonus_below_20_is_negative() { assert_eq!(stat_bonus(10), -5); assert_eq!(stat_bonus(1), -9); } #[test] fn stat_bonus_above_20_is_positive() { assert_eq!(stat_bonus(30), 5); assert_eq!(stat_bonus(100), 40); } #[test] fn stat_bonus_handles_large_values() { assert_eq!(stat_bonus(1_000_000_000), 499_999_990); } #[test] fn xp_for_level_1_is_zero() { assert_eq!(xp_for_level(1), 0); } #[test] fn xp_for_level_2_is_1800() { assert_eq!(xp_for_level(2), 1800); } #[test] fn xp_for_level_scales_subexponentially() { assert!(xp_for_level(3) > xp_for_level(2)); assert!(xp_for_level(5) > xp_for_level(4)); assert!(xp_for_level(10) > xp_for_level(5)); assert!(xp_for_level(50) > xp_for_level(25)); } #[test] fn xp_for_level_does_not_overflow() { assert!(xp_for_level(50) > 0); assert!(xp_for_level(50) < i64::MAX / 2); } #[test] fn level_up_fails_at_max_level() { let mut c = base_char(); c.level = MAX_LEVEL; c.xp = i64::MAX; c.gold = i32::MAX; assert_eq!(level_up(&mut c), Err(DomainError::AlreadyAtMaxLevel)); } #[test] fn level_up_cost_scales() { assert_eq!(level_up_cost(2), 1000); assert_eq!(level_up_cost(3), 1500); assert_eq!(level_up_cost(10), 5000); } #[test] fn level_up_succeeds_with_enough_xp_and_gold() { let mut c = base_char(); c.xp = 1800; c.gold = 1000; let result = level_up(&mut c); assert_eq!(result, Ok(2)); assert_eq!(c.level, 2); assert_eq!(c.gold, 0); assert_eq!(c.hp, c.max_hp); assert_eq!(c.energy, c.max_energy); assert_eq!(c.nerve, c.max_nerve); } #[test] fn level_up_fails_without_enough_xp() { let mut c = base_char(); c.xp = 100; c.gold = 10000; assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughXp)); assert_eq!(c.level, 1); assert_eq!(c.gold, 10000); } #[test] fn level_up_fails_without_enough_gold() { let mut c = base_char(); c.xp = 1800; c.gold = 0; assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughGold)); assert_eq!(c.level, 1); } #[test] fn calc_max_hp_depends_on_class_and_con() { let stats_high = CharacterStats { strength: 10, intelligence: 10, dexterity: 10, constitution: 30, luck: 10, }; let stats_low = CharacterStats { strength: 10, intelligence: 10, dexterity: 10, constitution: 10, luck: 10, }; let hp_high = calc_max_hp(&Class::Warrior, &stats_high); let hp_low = calc_max_hp(&Class::Warrior, &stats_low); assert!(hp_high > hp_low); } #[test] fn calc_max_energy_and_nerve_vary_by_class() { let warrior_energy = calc_max_energy(&Class::Warrior); let mage_energy = calc_max_energy(&Class::Mage); assert!(warrior_energy > mage_energy); } #[test] fn training_cost_scales_with_stat() { assert!(training_cost(20) < training_cost(30)); assert!(training_cost(30) < training_cost(40)); } #[test] fn apply_regen_restores_resources() { let mut c = base_char(); c.hp = 40; c.energy = 10; c.nerve = 50; c.last_regen = 0; apply_regen(&mut c, 1_800_000); assert!(c.energy > 10); assert!(c.nerve > 50); assert!(c.hp > 40); } #[test] fn apply_regen_no_change_if_no_time_passed() { let mut c = base_char(); c.hp = 40; c.last_regen = 1_800_000; let before = c.hp; apply_regen(&mut c, 1_800_000); assert_eq!(c.hp, before); } #[test] fn roll_stats_returns_all_five() { use rand::SeedableRng; let mut rng = rand::rngs::StdRng::seed_from_u64(42); let stats = roll_stats(&mut rng, &Race::Human); assert!(stats.strength >= 6 && stats.strength <= 16); assert!(stats.intelligence >= 6 && stats.intelligence <= 16); assert!(stats.dexterity >= 6 && stats.dexterity <= 16); assert!(stats.constitution >= 6 && stats.constitution <= 16); assert!(stats.luck >= 6 && stats.luck <= 16); } #[test] fn race_modifiers_apply() { use rand::SeedableRng; let mut rng = rand::rngs::StdRng::seed_from_u64(42); let human = roll_stats(&mut rng, &Race::Human); let mut rng2 = rand::rngs::StdRng::seed_from_u64(42); let dwarf = roll_stats(&mut rng2, &Race::Dwarf); assert!(dwarf.constitution > human.constitution); assert!(dwarf.strength > human.strength); assert!(dwarf.intelligence < human.intelligence); } #[test] fn create_character_is_deterministic_under_seeded_rng() { use rand::SeedableRng; let first = create_character( &mut rand::rngs::StdRng::seed_from_u64(42), 7, "Twin".into(), Race::Human, Class::Warrior, ); let second = create_character( &mut rand::rngs::StdRng::seed_from_u64(42), 7, "Twin".into(), Race::Human, Class::Warrior, ); assert_eq!(format!("{:?}", first.stats), format!("{:?}", second.stats)); assert_eq!(first.hp, second.hp); assert_eq!(first.max_hp, second.max_hp); assert_eq!(first.energy, second.energy); assert_eq!(first.nerve, second.nerve); } #[test] fn gym_display_order_covers_all_training_grounds() { let stats = CharacterStats { strength: 10, intelligence: 11, dexterity: 12, constitution: 13, luck: 14, }; assert_eq!(GYM_STAT_DISPLAY_ORDER.len(), TRAINING_GROUNDS.len()); for name in GYM_STAT_DISPLAY_ORDER { assert!( TRAINING_GROUNDS.iter().any(|g| g.stat == *name), "no training ground for stat {}", name ); assert!( stat_value(&stats, name).is_some(), "stat_value cannot resolve {}", name ); } for ground in TRAINING_GROUNDS { assert!( GYM_STAT_DISPLAY_ORDER.contains(&ground.stat), "gym display order missing stat {}", ground.stat ); } } #[test] fn race_table_covers_all_variants() { let all = [ Race::Human, Race::Elf, Race::Dwarf, Race::Hobbit, Race::HalfElf, ]; for race in &all { assert!(RACES.iter().any(|r| &r.race == race)); let _ = find_race_info(race); } } #[test] fn class_table_covers_all_variants() { let all = [ Class::Warrior, Class::Paladin, Class::Rogue, Class::Bard, Class::Hunter, Class::Monk, Class::Mage, Class::Wizard, Class::Conjurer, Class::Sorcerer, ]; for class in &all { assert!(CLASSES.iter().any(|c| &c.class == class)); let _ = find_class_info(class); } } }