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

444 lines
15 KiB
Rust

use sb_shared::{Character, CharacterStats, Class, Race};
use crate::spells;
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 - 20) / 2
}
fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 {
let base: i64 = rng.random_range(6..=16);
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");
30 + info.base_hp / 3
}
pub fn calc_max_nerve(class: &Class) -> i32 {
let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class");
100 + info.base_hp / 2
}
pub fn energy_regen_rate() -> i64 {
180000
}
pub fn nerve_regen_rate() -> i64 {
300000
}
pub fn hp_regen_rate() -> i64 {
300000
}
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 * 0.05).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 / 10.0).floor() as i32;
(5.0_f64 * 1.3_f64.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: 1,
xp: 0,
stats,
hp: max_hp,
max_hp,
energy: max_energy,
max_energy,
nerve: max_nerve,
max_nerve,
gold: 50,
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 * (100.0 * n * n) as i64
}
pub fn level_up_cost(level: i32) -> i32 {
500 * level
}
pub fn level_up(char: &mut Character) -> Result<i32, &'static str> {
if char.level >= MAX_LEVEL {
return Err("already at max level");
}
let next_level = char.level + 1;
if char.xp < xp_for_level(next_level) {
return Err("not enough XP");
}
let cost = level_up_cost(next_level);
if char.gold < cost {
return Err("not enough gold");
}
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")
}
#[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("already at max level"));
}
#[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("not enough XP"));
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("not enough gold"));
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);
}
}