sb-core: constants module (WI-2, issue #2)

This commit is contained in:
Jan-Peter von Hunnius 2026-10-03 11:58:02 +02:00
parent 337e9bcf2c
commit 093012af20
6 changed files with 223 additions and 45 deletions

View file

@ -1,3 +1,4 @@
use crate::constants;
use crate::spells; use crate::spells;
use sb_shared::{Character, CharacterStats, Class, Race}; use sb_shared::{Character, CharacterStats, Class, Race};
@ -186,11 +187,11 @@ pub const CLASSES: &[ClassInfo] = &[
]; ];
pub fn stat_bonus(stat: i64) -> i64 { 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 { 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 base + modifier
} }
@ -221,7 +222,7 @@ pub fn calc_max_energy(class: &Class) -> i32 {
.iter() .iter()
.find(|c| &c.class == class) .find(|c| &c.class == class)
.expect("invalid 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 { pub fn calc_max_nerve(class: &Class) -> i32 {
@ -229,19 +230,19 @@ pub fn calc_max_nerve(class: &Class) -> i32 {
.iter() .iter()
.find(|c| &c.class == class) .find(|c| &c.class == class)
.expect("invalid 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 { pub fn energy_regen_rate() -> i64 {
180000 constants::ENERGY_REGEN_INTERVAL_MS
} }
pub fn nerve_regen_rate() -> i64 { pub fn nerve_regen_rate() -> i64 {
300000 constants::NERVE_REGEN_INTERVAL_MS
} }
pub fn hp_regen_rate() -> i64 { pub fn hp_regen_rate() -> i64 {
300000 constants::HP_REGEN_INTERVAL_MS
} }
pub fn apply_regen(char: &mut Character, now_ms: i64) { 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); char.nerve = (char.nerve + nerve_ticks).min(char.max_nerve);
} }
if hp_ticks > 0 && char.hp > 0 && char.hp < char.max_hp { 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.hp = (char.hp + regen_per_tick * hp_ticks).min(char.max_hp);
} }
char.last_regen = now_ms; char.last_regen = now_ms;
} }
pub fn training_cost(stat: i64) -> i32 { pub fn training_cost(stat: i64) -> i32 {
let scaled = (stat as f64 / 10.0).floor() as i32; let scaled = (stat as f64 / constants::TRAINING_STAT_SCALER).floor() as i32;
(5.0_f64 * 1.3_f64.powi(scaled)) as i32 (constants::TRAINING_BASE_COST * constants::TRAINING_GROWTH_FACTOR.powi(scaled)) as i32
} }
pub fn create_character( pub fn create_character(
@ -299,7 +301,7 @@ pub fn create_character(
name, name,
race, race,
class, class,
level: 1, level: constants::STARTING_LEVEL,
xp: 0, xp: 0,
stats, stats,
hp: max_hp, hp: max_hp,
@ -308,7 +310,7 @@ pub fn create_character(
max_energy, max_energy,
nerve: max_nerve, nerve: max_nerve,
max_nerve, max_nerve,
gold: 50, gold: constants::STARTING_GOLD,
last_regen: 0, last_regen: 0,
equipment: [ equipment: [
("head".into(), None), ("head".into(), None),
@ -342,11 +344,11 @@ pub fn xp_for_level(level: i32) -> i64 {
return 0; return 0;
} }
let n = (level - 1) as f64; 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 { 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<i32, &'static str> { pub fn level_up(char: &mut Character) -> Result<i32, &'static str> {

View file

@ -1,3 +1,4 @@
use crate::constants;
use rand::Rng; use rand::Rng;
use sb_shared::{AttackType, CombatLog, CombatRound, CritEntry, CritResult}; use sb_shared::{AttackType, CombatLog, CombatRound, CritEntry, CritResult};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -32,7 +33,8 @@ impl CombatStats {
pub fn defensive_bonus(&self) -> i32 { pub fn defensive_bonus(&self) -> i32 {
let dex_bonus = super::character::stat_bonus(self.dexterity) as 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 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; return None;
} }
let cap_pct = match diff { let cap_pct = match diff {
1..=9 => return None, 1..=constants::CRIT_CAP_DISABLED_MAX_LEVEL_GAP => return None,
10..=19 => 0.60, constants::CRIT_CAP_TIER1_MIN_GAP..=constants::CRIT_CAP_TIER1_MAX_GAP => {
20..=49 => 0.40, constants::CRIT_CAP_TIER1_RATIO
50..=99 => 0.15, }
_ => 0.05, 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) Some((defender_hp as f64 * cap_pct) as i32)
} }
pub fn open_ended_roll(rng: &mut impl Rng) -> 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; let mut total = roll;
if roll >= 96 { if roll >= constants::OPEN_END_HIGH_THRESHOLD {
total += open_ended_roll(rng); total += open_ended_roll(rng);
} else if roll <= 5 { } else if roll <= constants::OPEN_END_LOW_THRESHOLD {
total -= open_ended_roll(rng); total -= open_ended_roll(rng);
} }
total total
@ -120,13 +128,13 @@ pub fn resolve_attack(
} }
pub fn crit_severity(result: i32) -> Option<String> { pub fn crit_severity(result: i32) -> Option<String> {
if result >= 151 { if result >= constants::CRIT_SEVERITY_D_MIN {
Some("D".into()) Some("D".into())
} else if result >= 101 { } else if result >= constants::CRIT_SEVERITY_C_MIN {
Some("C".into()) Some("C".into())
} else if result >= 51 { } else if result >= constants::CRIT_SEVERITY_B_MIN {
Some("B".into()) Some("B".into())
} else if result >= 21 { } else if result >= constants::CRIT_SEVERITY_A_MIN {
Some("A".into()) Some("A".into())
} else { } else {
None None
@ -246,7 +254,7 @@ pub fn resolve_combat_full(
} }
if song_effect == Some("heal") && a_hp > 0 && a_hp < attacker.hp { 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); 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(); let mut s = stats.clone();
match song_effect { match song_effect {
Some("accuracy") => { Some("accuracy") => {
s.ob_base += 15; s.ob_base += constants::SONG_ACCURACY_OB_BONUS;
} }
Some("damage") => { 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(); let mut s = stats.clone();
match song_effect { match song_effect {
Some("defense") | Some("magic_resist") => { Some("defense") | Some("magic_resist") => {
s.db_base += 10; s.db_base += constants::SONG_DEFENSE_DB_BONUS;
} }
Some("weaken") => { 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;
} }
_ => {} _ => {}
} }

View file

@ -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;

View file

@ -2,6 +2,7 @@
pub mod character; pub mod character;
pub mod combat; pub mod combat;
pub mod constants;
pub mod data; pub mod data;
pub mod items; pub mod items;
pub mod services; pub mod services;

View file

@ -100,7 +100,7 @@ pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, &'static
} }
pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str> { pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str> {
if char.energy < 1 { if char.energy < crate::constants::TRAINING_ENERGY_COST {
return Err("not enough energy"); return Err("not enough energy");
} }
let current = match stat { let current = match stat {
@ -115,7 +115,7 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str>
if char.gold < cost { if char.gold < cost {
return Err("not enough gold"); return Err("not enough gold");
} }
char.energy -= 1; char.energy -= crate::constants::TRAINING_ENERGY_COST;
char.gold -= cost; char.gold -= cost;
match stat { match stat {
"strength" => char.stats.strength += 1, "strength" => char.stats.strength += 1,

View file

@ -1,3 +1,4 @@
use crate::constants;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[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 { pub fn spell_learn_cost(spell_level: i32) -> i32 {
match spell_level { if (constants::MIN_SPELL_LEVEL..=constants::MAX_SPELL_LEVEL).contains(&spell_level) {
1 => 0, constants::SPELL_LEARN_COST_BY_LEVEL[(spell_level - 1) as usize]
2 => 1000, } else {
3 => 2000, constants::SPELL_LEARN_COST_UNKNOWN_LEVEL
4 => 4000,
5 => 7000,
6 => 10000,
7 => 20000,
_ => 999999,
} }
} }
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 { pub fn max_spell_points(class: &str, level: i32) -> i32 {
let spell_class = class_for_spells(class); let spell_class = class_for_spells(class);
let spell_level = match spell_class { let spell_level = match spell_class {
"conjurer" | "magician" | "sorcerer" | "wizard" => (level / 3) + 1, "conjurer" | "magician" | "sorcerer" | "wizard" => caster_spell_level(level),
_ => 0, _ => 0,
}; };
spell_level * 10 spell_level * constants::SPELL_POINTS_PER_SPELL_LEVEL
} }
pub fn is_caster_class(class: &str) -> bool { pub fn is_caster_class(class: &str) -> bool {
@ -75,3 +75,39 @@ pub fn caster_classes() -> &'static [&'static str] {
use std::sync::LazyLock; use std::sync::LazyLock;
static ALL_SPELLS: LazyLock<Vec<SpellDef>> = LazyLock::new(load_spells); static ALL_SPELLS: LazyLock<Vec<SpellDef>> = 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]
);
}
}
}