diff --git a/crates/sb-core/src/character.rs b/crates/sb-core/src/character.rs index e793147..99c1272 100644 --- a/crates/sb-core/src/character.rs +++ b/crates/sb-core/src/character.rs @@ -1,3 +1,4 @@ +use crate::constants; use crate::spells; use sb_shared::{Character, CharacterStats, Class, Race}; @@ -186,11 +187,11 @@ pub const CLASSES: &[ClassInfo] = &[ ]; pub fn stat_bonus(stat: i64) -> i64 { - (stat - 20) / 2 + (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(6..=16); + let base: i64 = rng.random_range(constants::STAT_ROLL_MIN..=constants::STAT_ROLL_MAX); base + modifier } @@ -221,7 +222,7 @@ pub fn calc_max_energy(class: &Class) -> i32 { .iter() .find(|c| &c.class == class) .expect("invalid class"); - 30 + info.base_hp / 3 + constants::BASE_MAX_ENERGY + info.base_hp / constants::MAX_ENERGY_PER_BASE_HP_DIVISOR } pub fn calc_max_nerve(class: &Class) -> i32 { @@ -229,19 +230,19 @@ pub fn calc_max_nerve(class: &Class) -> i32 { .iter() .find(|c| &c.class == class) .expect("invalid class"); - 100 + info.base_hp / 2 + constants::BASE_MAX_NERVE + info.base_hp / constants::MAX_NERVE_PER_BASE_HP_DIVISOR } pub fn energy_regen_rate() -> i64 { - 180000 + constants::ENERGY_REGEN_INTERVAL_MS } pub fn nerve_regen_rate() -> i64 { - 300000 + constants::NERVE_REGEN_INTERVAL_MS } pub fn hp_regen_rate() -> i64 { - 300000 + constants::HP_REGEN_INTERVAL_MS } pub fn apply_regen(char: &mut Character, now_ms: i64) { @@ -259,15 +260,16 @@ pub fn apply_regen(char: &mut Character, now_ms: i64) { 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; + 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 / 10.0).floor() as i32; - (5.0_f64 * 1.3_f64.powi(scaled)) as 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( @@ -299,7 +301,7 @@ pub fn create_character( name, race, class, - level: 1, + level: constants::STARTING_LEVEL, xp: 0, stats, hp: max_hp, @@ -308,7 +310,7 @@ pub fn create_character( max_energy, nerve: max_nerve, max_nerve, - gold: 50, + gold: constants::STARTING_GOLD, last_regen: 0, equipment: [ ("head".into(), None), @@ -342,11 +344,11 @@ pub fn xp_for_level(level: i32) -> i64 { return 0; } let n = (level - 1) as f64; - XP_FACTOR * (100.0 * n * n) as i64 + XP_FACTOR * (constants::XP_CURVE_COEFFICIENT * n * n) as i64 } pub fn level_up_cost(level: i32) -> i32 { - 500 * level + constants::LEVEL_UP_GOLD_PER_LEVEL * level } pub fn level_up(char: &mut Character) -> Result { diff --git a/crates/sb-core/src/combat.rs b/crates/sb-core/src/combat.rs index 495edac..2d2d5f8 100644 --- a/crates/sb-core/src/combat.rs +++ b/crates/sb-core/src/combat.rs @@ -1,3 +1,4 @@ +use crate::constants; use rand::Rng; use sb_shared::{AttackType, CombatLog, CombatRound, CritEntry, CritResult}; use serde::{Deserialize, Serialize}; @@ -32,7 +33,8 @@ impl CombatStats { pub fn defensive_bonus(&self) -> i32 { let dex_bonus = super::character::stat_bonus(self.dexterity) as i32; - let con_bonus = (super::character::stat_bonus(self.constitution) as i32) / 2; + let con_bonus = (super::character::stat_bonus(self.constitution) as i32) + / constants::DEFENSIVE_CON_BONUS_DIVISOR; dex_bonus + con_bonus + self.db_base } } @@ -52,21 +54,27 @@ pub fn crit_damage_cap(attacker_level: i32, defender_level: i32, defender_hp: i3 return None; } let cap_pct = match diff { - 1..=9 => return None, - 10..=19 => 0.60, - 20..=49 => 0.40, - 50..=99 => 0.15, - _ => 0.05, + 1..=constants::CRIT_CAP_DISABLED_MAX_LEVEL_GAP => return None, + constants::CRIT_CAP_TIER1_MIN_GAP..=constants::CRIT_CAP_TIER1_MAX_GAP => { + constants::CRIT_CAP_TIER1_RATIO + } + constants::CRIT_CAP_TIER2_MIN_GAP..=constants::CRIT_CAP_TIER2_MAX_GAP => { + constants::CRIT_CAP_TIER2_RATIO + } + constants::CRIT_CAP_TIER3_MIN_GAP..=constants::CRIT_CAP_TIER3_MAX_GAP => { + constants::CRIT_CAP_TIER3_RATIO + } + _ => constants::CRIT_CAP_TIER4_RATIO, }; Some((defender_hp as f64 * cap_pct) as i32) } pub fn open_ended_roll(rng: &mut impl Rng) -> i32 { - let roll: i32 = rng.random_range(1..=100); + let roll: i32 = rng.random_range(constants::ATTACK_ROLL_MIN..=constants::ATTACK_ROLL_MAX); let mut total = roll; - if roll >= 96 { + if roll >= constants::OPEN_END_HIGH_THRESHOLD { total += open_ended_roll(rng); - } else if roll <= 5 { + } else if roll <= constants::OPEN_END_LOW_THRESHOLD { total -= open_ended_roll(rng); } total @@ -120,13 +128,13 @@ pub fn resolve_attack( } pub fn crit_severity(result: i32) -> Option { - if result >= 151 { + if result >= constants::CRIT_SEVERITY_D_MIN { Some("D".into()) - } else if result >= 101 { + } else if result >= constants::CRIT_SEVERITY_C_MIN { Some("C".into()) - } else if result >= 51 { + } else if result >= constants::CRIT_SEVERITY_B_MIN { Some("B".into()) - } else if result >= 21 { + } else if result >= constants::CRIT_SEVERITY_A_MIN { Some("A".into()) } else { None @@ -246,7 +254,7 @@ pub fn resolve_combat_full( } if song_effect == Some("heal") && a_hp > 0 && a_hp < attacker.hp { - let heal_amt = (attacker.hp as f64 * 0.03).ceil() as i32; + let heal_amt = (attacker.hp as f64 * constants::BARD_SONG_HEAL_FRACTION).ceil() as i32; a_hp = (a_hp + heal_amt).min(attacker.hp); } @@ -265,10 +273,10 @@ fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> Com let mut s = stats.clone(); match song_effect { Some("accuracy") => { - s.ob_base += 15; + s.ob_base += constants::SONG_ACCURACY_OB_BONUS; } Some("damage") => { - s.ob_base += 20; + s.ob_base += constants::SONG_DAMAGE_OB_BONUS; } _ => {} } @@ -279,10 +287,10 @@ fn apply_song_to_defender(stats: &CombatStats, song_effect: Option<&str>) -> Com let mut s = stats.clone(); match song_effect { Some("defense") | Some("magic_resist") => { - s.db_base += 10; + s.db_base += constants::SONG_DEFENSE_DB_BONUS; } Some("weaken") => { - s.ob_base = (s.ob_base as f64 * 0.7) as i32; + s.ob_base = (s.ob_base as f64 * constants::SONG_WEAKEN_OB_FACTOR) as i32; } _ => {} } diff --git a/crates/sb-core/src/constants.rs b/crates/sb-core/src/constants.rs new file mode 100644 index 0000000..4a410e4 --- /dev/null +++ b/crates/sb-core/src/constants.rs @@ -0,0 +1,131 @@ +//! Game-balance constants — the single audit home for tuning scalars. +//! Static data tables (`RACES`, `CLASSES`, `BARD_SONGS`, `TRAINING_GROUNDS`) +//! stay in their modules; only tuning scalars live here. + +pub use crate::character::{MAX_LEVEL, XP_FACTOR}; + +/// d100 attack roll bounds (origin: combat.rs:66). +pub const ATTACK_ROLL_MIN: i32 = 1; +/// d100 attack roll bounds (origin: combat.rs:66). +pub const ATTACK_ROLL_MAX: i32 = 100; + +/// Open-ended roll: re-roll upward at or above this value (origin: combat.rs:68). +pub const OPEN_END_HIGH_THRESHOLD: i32 = 96; +/// Open-ended roll: re-roll downward at or below this value (origin: combat.rs:70). +pub const OPEN_END_LOW_THRESHOLD: i32 = 5; + +/// Crit severity A floor (origin: combat.rs:129). +pub const CRIT_SEVERITY_A_MIN: i32 = 21; +/// Crit severity B floor (origin: combat.rs:127). +pub const CRIT_SEVERITY_B_MIN: i32 = 51; +/// Crit severity C floor (origin: combat.rs:125). +pub const CRIT_SEVERITY_C_MIN: i32 = 101; +/// Crit severity D floor (origin: combat.rs:123). +pub const CRIT_SEVERITY_D_MIN: i32 = 151; + +/// Level gaps up to this value apply no crit cap (origin: combat.rs:56). +pub const CRIT_CAP_DISABLED_MAX_LEVEL_GAP: i32 = 9; +/// Crit cap tier 1 level-gap range (origin: combat.rs:57). +pub const CRIT_CAP_TIER1_MIN_GAP: i32 = 10; +/// Crit cap tier 1 level-gap range (origin: combat.rs:57). +pub const CRIT_CAP_TIER1_MAX_GAP: i32 = 19; +/// Crit cap tier 2 level-gap range (origin: combat.rs:58). +pub const CRIT_CAP_TIER2_MIN_GAP: i32 = 20; +/// Crit cap tier 2 level-gap range (origin: combat.rs:58). +pub const CRIT_CAP_TIER2_MAX_GAP: i32 = 49; +/// Crit cap tier 3 level-gap range (origin: combat.rs:59). +pub const CRIT_CAP_TIER3_MIN_GAP: i32 = 50; +/// Crit cap tier 3 level-gap range (origin: combat.rs:59). +pub const CRIT_CAP_TIER3_MAX_GAP: i32 = 99; +/// Crit cap tier 4 applies from this gap upward (origin: combat.rs:60). +pub const CRIT_CAP_TIER4_MIN_GAP: i32 = 100; + +/// Crit damage cap ratio for level gaps 10–19 (origin: combat.rs:57). +pub const CRIT_CAP_TIER1_RATIO: f64 = 0.60; +/// Crit damage cap ratio for level gaps 20–49 (origin: combat.rs:58). +pub const CRIT_CAP_TIER2_RATIO: f64 = 0.40; +/// Crit damage cap ratio for level gaps 50–99 (origin: combat.rs:59). +pub const CRIT_CAP_TIER3_RATIO: f64 = 0.15; +/// Crit damage cap ratio for level gaps 100 and above (origin: combat.rs:60). +pub const CRIT_CAP_TIER4_RATIO: f64 = 0.05; + +/// Constitution contributes half its stat bonus to defense (origin: combat.rs:36). +pub const DEFENSIVE_CON_BONUS_DIVISOR: i32 = 2; + +/// Accuracy song offensive-bonus bonus (origin: combat.rs:246). +pub const SONG_ACCURACY_OB_BONUS: i32 = 15; +/// Damage song offensive-bonus bonus (origin: combat.rs:247). +pub const SONG_DAMAGE_OB_BONUS: i32 = 20; +/// Defense song defensive-bonus bonus (origin: combat.rs:256). +pub const SONG_DEFENSE_DB_BONUS: i32 = 10; +/// Weaken song reduces the foe's offensive bonus to this fraction (origin: combat.rs:257). +pub const SONG_WEAKEN_OB_FACTOR: f64 = 0.7; + +/// Bard heal song: fraction of max HP restored per round (origin: combat.rs:230). +pub const BARD_SONG_HEAL_FRACTION: f64 = 0.03; + +/// Stat bonus curve: bonus is zero at this stat value (origin: character.rs:59). +pub const STAT_BONUS_NEUTRAL: i64 = 20; +/// Stat bonus curve: bonus step size per 2 stat points (origin: character.rs:59). +pub const STAT_BONUS_STEP: i64 = 2; + +/// Character creation stat roll bounds (origin: character.rs:63). +pub const STAT_ROLL_MIN: i64 = 6; +/// Character creation stat roll bounds (origin: character.rs:63). +pub const STAT_ROLL_MAX: i64 = 16; + +/// Base maximum energy before the class contribution (origin: character.rs:85). +pub const BASE_MAX_ENERGY: i32 = 30; +/// Energy gains one point per this many base HP (origin: character.rs:85). +pub const MAX_ENERGY_PER_BASE_HP_DIVISOR: i32 = 3; + +/// Base maximum nerve before the class contribution (origin: character.rs:90). +pub const BASE_MAX_NERVE: i32 = 100; +/// Nerve gains one point per this many base HP (origin: character.rs:90). +pub const MAX_NERVE_PER_BASE_HP_DIVISOR: i32 = 2; + +/// Energy regen tick interval in milliseconds (origin: character.rs:94). +pub const ENERGY_REGEN_INTERVAL_MS: i64 = 180_000; +/// Nerve regen tick interval in milliseconds (origin: character.rs:98). +pub const NERVE_REGEN_INTERVAL_MS: i64 = 300_000; +/// HP regen tick interval in milliseconds (origin: character.rs:102). +pub const HP_REGEN_INTERVAL_MS: i64 = 300_000; + +/// HP restored per regen tick, as a fraction of max HP (origin: character.rs:118). +pub const HP_REGEN_FRACTION_PER_TICK: f64 = 0.05; + +/// Training cost curve base cost (origin: character.rs:126). +pub const TRAINING_BASE_COST: f64 = 5.0; +/// Training cost curve growth factor (origin: character.rs:126). +pub const TRAINING_GROWTH_FACTOR: f64 = 1.3; +/// Training cost curve stat scaler (origin: character.rs:125). +pub const TRAINING_STAT_SCALER: f64 = 10.0; + +/// Energy spent per training session (origin: services.rs:85, 100). +pub const TRAINING_ENERGY_COST: i32 = 1; + +/// Level assigned at character creation (origin: character.rs:157). +pub const STARTING_LEVEL: i32 = 1; +/// Gold assigned at character creation (origin: character.rs:166). +pub const STARTING_GOLD: i32 = 50; + +/// XP curve coefficient: 18 · 100 · (level−1)² (origin: character.rs:196). +pub const XP_CURVE_COEFFICIENT: f64 = 100.0; + +/// Gold cost per target level for level-up (origin: character.rs:200). +pub const LEVEL_UP_GOLD_PER_LEVEL: i32 = 500; + +/// Caster levels per spell level: (level / 3) + 1 curve (origin: spells.rs:59). +pub const CASTER_LEVELS_PER_SPELL_LEVEL: i32 = 3; +/// Spell points granted per caster spell level (origin: spells.rs:62). +pub const SPELL_POINTS_PER_SPELL_LEVEL: i32 = 10; + +/// Spell learn cost table, indexed by spell level − 1 (origin: spells.rs:44–52). +pub const SPELL_LEARN_COST_BY_LEVEL: [i32; 7] = [0, 1000, 2000, 4000, 7000, 10000, 20000]; +/// Learn cost reported for spell levels outside 1..=7 (origin: spells.rs:52). +pub const SPELL_LEARN_COST_UNKNOWN_LEVEL: i32 = 999_999; + +/// Lowest learn-request spell level — request validation only (origin: routes/mod.rs:1146). +pub const MIN_SPELL_LEVEL: i32 = 1; +/// Highest learn-request spell level — request validation only (origin: routes/mod.rs:1146). +pub const MAX_SPELL_LEVEL: i32 = 7; diff --git a/crates/sb-core/src/lib.rs b/crates/sb-core/src/lib.rs index 813f904..0eb4216 100644 --- a/crates/sb-core/src/lib.rs +++ b/crates/sb-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod character; pub mod combat; +pub mod constants; pub mod data; pub mod items; pub mod services; diff --git a/crates/sb-core/src/services.rs b/crates/sb-core/src/services.rs index c745f80..348a153 100644 --- a/crates/sb-core/src/services.rs +++ b/crates/sb-core/src/services.rs @@ -100,7 +100,7 @@ pub fn unequip_item(char: &mut Character, slot: &str) -> Result Result { - if char.energy < 1 { + if char.energy < crate::constants::TRAINING_ENERGY_COST { return Err("not enough energy"); } let current = match stat { @@ -115,7 +115,7 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result if char.gold < cost { return Err("not enough gold"); } - char.energy -= 1; + char.energy -= crate::constants::TRAINING_ENERGY_COST; char.gold -= cost; match stat { "strength" => char.stats.strength += 1, diff --git a/crates/sb-core/src/spells.rs b/crates/sb-core/src/spells.rs index de3f5c0..843413c 100644 --- a/crates/sb-core/src/spells.rs +++ b/crates/sb-core/src/spells.rs @@ -1,3 +1,4 @@ +use crate::constants; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -41,25 +42,24 @@ pub fn find_spell(id: &str) -> Option<&'static SpellDef> { } pub fn spell_learn_cost(spell_level: i32) -> i32 { - match spell_level { - 1 => 0, - 2 => 1000, - 3 => 2000, - 4 => 4000, - 5 => 7000, - 6 => 10000, - 7 => 20000, - _ => 999999, + if (constants::MIN_SPELL_LEVEL..=constants::MAX_SPELL_LEVEL).contains(&spell_level) { + constants::SPELL_LEARN_COST_BY_LEVEL[(spell_level - 1) as usize] + } else { + constants::SPELL_LEARN_COST_UNKNOWN_LEVEL } } +pub fn caster_spell_level(character_level: i32) -> i32 { + (character_level / constants::CASTER_LEVELS_PER_SPELL_LEVEL) + 1 +} + pub fn max_spell_points(class: &str, level: i32) -> i32 { let spell_class = class_for_spells(class); let spell_level = match spell_class { - "conjurer" | "magician" | "sorcerer" | "wizard" => (level / 3) + 1, + "conjurer" | "magician" | "sorcerer" | "wizard" => caster_spell_level(level), _ => 0, }; - spell_level * 10 + spell_level * constants::SPELL_POINTS_PER_SPELL_LEVEL } pub fn is_caster_class(class: &str) -> bool { @@ -75,3 +75,39 @@ pub fn caster_classes() -> &'static [&'static str] { use std::sync::LazyLock; static ALL_SPELLS: LazyLock> = LazyLock::new(load_spells); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn caster_spell_level_spot_values() { + assert_eq!(caster_spell_level(1), 1); + assert_eq!(caster_spell_level(2), 1); + assert_eq!(caster_spell_level(3), 2); + assert_eq!(caster_spell_level(6), 3); + assert_eq!(caster_spell_level(100), 34); + } + + #[test] + fn spell_learn_cost_guard_spot_checks() { + assert_eq!( + spell_learn_cost(0), + constants::SPELL_LEARN_COST_UNKNOWN_LEVEL + ); + assert_eq!( + spell_learn_cost(-1), + constants::SPELL_LEARN_COST_UNKNOWN_LEVEL + ); + assert_eq!( + spell_learn_cost(8), + constants::SPELL_LEARN_COST_UNKNOWN_LEVEL + ); + for level in constants::MIN_SPELL_LEVEL..=constants::MAX_SPELL_LEVEL { + assert_eq!( + spell_learn_cost(level), + constants::SPELL_LEARN_COST_BY_LEVEL[(level - 1) as usize] + ); + } + } +}