skara-brae-server/crates/sb-core/src/character.rs

721 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String> = 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<i32, DomainError> {
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<i64> {
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);
}
}
}