fmt: repo-wide baseline (WI-2, issue #2)
This commit is contained in:
parent
899fde8fc3
commit
337e9bcf2c
19 changed files with 847 additions and 335 deletions
|
|
@ -1,5 +1,5 @@
|
|||
use sb_shared::{Character, CharacterStats, Class, Race};
|
||||
use crate::spells;
|
||||
use sb_shared::{Character, CharacterStats, Class, Race};
|
||||
|
||||
pub struct TrainingGround {
|
||||
pub stat: &'static str,
|
||||
|
|
@ -8,11 +8,31 @@ pub struct TrainingGround {
|
|||
}
|
||||
|
||||
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." },
|
||||
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 {
|
||||
|
|
@ -26,11 +46,51 @@ pub struct RaceInfo {
|
|||
}
|
||||
|
||||
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 },
|
||||
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 {
|
||||
|
|
@ -43,16 +103,86 @@ pub struct ClassInfo {
|
|||
}
|
||||
|
||||
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 },
|
||||
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 {
|
||||
|
|
@ -65,7 +195,10 @@ fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 {
|
|||
}
|
||||
|
||||
pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats {
|
||||
let info = RACES.iter().find(|r| &r.race == race).expect("invalid race");
|
||||
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),
|
||||
|
|
@ -76,17 +209,26 @@ pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats {
|
|||
}
|
||||
|
||||
pub fn calc_max_hp(class: &Class, stats: &CharacterStats) -> i32 {
|
||||
let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class");
|
||||
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");
|
||||
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");
|
||||
let info = CLASSES
|
||||
.iter()
|
||||
.find(|c| &c.class == class)
|
||||
.expect("invalid class");
|
||||
100 + info.base_hp / 2
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +246,9 @@ pub fn hp_regen_rate() -> i64 {
|
|||
|
||||
pub fn apply_regen(char: &mut Character, now_ms: i64) {
|
||||
let elapsed = now_ms - char.last_regen;
|
||||
if elapsed <= 0 { return; }
|
||||
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;
|
||||
|
|
@ -140,7 +284,8 @@ pub fn create_character(
|
|||
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()
|
||||
spells::spells_for_class(&class_str, 1)
|
||||
.iter()
|
||||
.filter(|s| s.spell_level <= 1)
|
||||
.map(|s| s.id.clone())
|
||||
.collect()
|
||||
|
|
@ -177,7 +322,9 @@ pub fn create_character(
|
|||
("ring".into(), None),
|
||||
("trinket".into(), None),
|
||||
("instrument".into(), None),
|
||||
].into_iter().collect(),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
inventory: Vec::new(),
|
||||
active_song: None,
|
||||
known_spells,
|
||||
|
|
@ -191,7 +338,9 @@ 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; }
|
||||
if level <= 1 {
|
||||
return 0;
|
||||
}
|
||||
let n = (level - 1) as f64;
|
||||
XP_FACTOR * (100.0 * n * n) as i64
|
||||
}
|
||||
|
|
@ -227,11 +376,17 @@ pub fn level_up(char: &mut Character) -> Result<i32, &'static str> {
|
|||
}
|
||||
|
||||
pub fn find_race_info(race: &Race) -> &'static RaceInfo {
|
||||
RACES.iter().find(|r| &r.race == race).expect("invalid race")
|
||||
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")
|
||||
CLASSES
|
||||
.iter()
|
||||
.find(|c| &c.class == class)
|
||||
.expect("invalid class")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -242,15 +397,35 @@ mod tests {
|
|||
|
||||
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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -351,8 +526,20 @@ mod tests {
|
|||
|
||||
#[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 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);
|
||||
|
|
@ -396,7 +583,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn roll_stats_returns_all_five() {
|
||||
use rand:: SeedableRng;
|
||||
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);
|
||||
|
|
@ -408,7 +595,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn race_modifiers_apply() {
|
||||
use rand:: SeedableRng;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -7,9 +7,8 @@ use std::sync::LazyLock;
|
|||
const CRIT_TABLES_JSON: &str =
|
||||
include_str!("../../../../skara-brae-common/data/crit-tables/combat.json");
|
||||
|
||||
static CRIT_TABLES: LazyLock<HashMap<String, HashMap<String, CritEntry>>> = LazyLock::new(|| {
|
||||
serde_json::from_str(CRIT_TABLES_JSON).expect("failed to parse crit tables")
|
||||
});
|
||||
static CRIT_TABLES: LazyLock<HashMap<String, HashMap<String, CritEntry>>> =
|
||||
LazyLock::new(|| serde_json::from_str(CRIT_TABLES_JSON).expect("failed to parse crit tables"));
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CombatStats {
|
||||
|
|
@ -93,11 +92,12 @@ pub fn resolve_attack(
|
|||
&& let Some(crit_entry) = lookup_crit(&attacker.attack_type, &severity)
|
||||
{
|
||||
let crit_damage = (damage as f64 * crit_entry.damage_multiplier) as i32;
|
||||
let capped = if let Some(cap) = crit_damage_cap(attacker.level, defender.level, defender.hp) {
|
||||
crit_damage.min(cap)
|
||||
} else {
|
||||
crit_damage
|
||||
};
|
||||
let capped =
|
||||
if let Some(cap) = crit_damage_cap(attacker.level, defender.level, defender.hp) {
|
||||
crit_damage.min(cap)
|
||||
} else {
|
||||
crit_damage
|
||||
};
|
||||
damage = capped;
|
||||
crit = Some(CritResult {
|
||||
severity: severity.clone(),
|
||||
|
|
@ -155,7 +155,9 @@ pub fn resolve_combat(
|
|||
gold_max: i32,
|
||||
rng: &mut impl Rng,
|
||||
) -> CombatLog {
|
||||
resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, None, None, rng)
|
||||
resolve_combat_full(
|
||||
attacker, defender, xp_reward, gold_min, gold_max, None, None, rng,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_combat_with_song(
|
||||
|
|
@ -167,7 +169,16 @@ pub fn resolve_combat_with_song(
|
|||
song_effect: Option<&str>,
|
||||
rng: &mut impl Rng,
|
||||
) -> CombatLog {
|
||||
resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, song_effect, None, rng)
|
||||
resolve_combat_full(
|
||||
attacker,
|
||||
defender,
|
||||
xp_reward,
|
||||
gold_min,
|
||||
gold_max,
|
||||
song_effect,
|
||||
None,
|
||||
rng,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
|
@ -187,7 +198,11 @@ pub fn resolve_combat_full(
|
|||
let mut rounds = Vec::new();
|
||||
|
||||
loop {
|
||||
let mut round = resolve_attack(&mut rng, &apply_song_to_attacker(&attacker, song_effect), &apply_song_to_defender(&defender, song_effect));
|
||||
let mut round = resolve_attack(
|
||||
&mut rng,
|
||||
&apply_song_to_attacker(&attacker, song_effect),
|
||||
&apply_song_to_defender(&defender, song_effect),
|
||||
);
|
||||
|
||||
if let Some(sp) = spell
|
||||
&& sp.combat_effect == "damage"
|
||||
|
|
@ -211,7 +226,11 @@ pub fn resolve_combat_full(
|
|||
};
|
||||
}
|
||||
|
||||
let mut round = resolve_attack(&mut rng, &defender, &apply_song_to_defender(&make_temp_stats(&attacker, a_hp), song_effect));
|
||||
let mut round = resolve_attack(
|
||||
&mut rng,
|
||||
&defender,
|
||||
&apply_song_to_defender(&make_temp_stats(&attacker, a_hp), song_effect),
|
||||
);
|
||||
a_hp = (a_hp - round.damage).max(0);
|
||||
round.defender_hp = a_hp;
|
||||
rounds.push(round);
|
||||
|
|
@ -232,7 +251,9 @@ pub fn resolve_combat_full(
|
|||
}
|
||||
|
||||
if let Some(sp) = spell
|
||||
&& sp.combat_effect == "heal" && a_hp > 0 && a_hp < attacker.hp
|
||||
&& sp.combat_effect == "heal"
|
||||
&& a_hp > 0
|
||||
&& a_hp < attacker.hp
|
||||
{
|
||||
let heal_amt = rng.random_range(sp.heal_min..=sp.heal_max);
|
||||
a_hp = (a_hp + heal_amt).min(attacker.hp);
|
||||
|
|
@ -243,8 +264,12 @@ pub fn resolve_combat_full(
|
|||
fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
|
||||
let mut s = stats.clone();
|
||||
match song_effect {
|
||||
Some("accuracy") => { s.ob_base += 15; }
|
||||
Some("damage") => { s.ob_base += 20; }
|
||||
Some("accuracy") => {
|
||||
s.ob_base += 15;
|
||||
}
|
||||
Some("damage") => {
|
||||
s.ob_base += 20;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
|
|
@ -253,8 +278,12 @@ fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> Com
|
|||
fn apply_song_to_defender(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
|
||||
let mut s = stats.clone();
|
||||
match song_effect {
|
||||
Some("defense") | Some("magic_resist") => { s.db_base += 10; }
|
||||
Some("weaken") => { s.ob_base = (s.ob_base as f64 * 0.7) as i32; }
|
||||
Some("defense") | Some("magic_resist") => {
|
||||
s.db_base += 10;
|
||||
}
|
||||
Some("weaken") => {
|
||||
s.ob_base = (s.ob_base as f64 * 0.7) as i32;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
|
|
@ -272,18 +301,30 @@ mod tests {
|
|||
|
||||
fn attacker() -> CombatStats {
|
||||
CombatStats {
|
||||
name: "Hero".into(), hp: 100, level: 1,
|
||||
strength: 50, dexterity: 40, constitution: 40, luck: 20,
|
||||
ob_base: 20, db_base: 10,
|
||||
name: "Hero".into(),
|
||||
hp: 100,
|
||||
level: 1,
|
||||
strength: 50,
|
||||
dexterity: 40,
|
||||
constitution: 40,
|
||||
luck: 20,
|
||||
ob_base: 20,
|
||||
db_base: 10,
|
||||
attack_type: AttackType::Slash,
|
||||
}
|
||||
}
|
||||
|
||||
fn defender() -> CombatStats {
|
||||
CombatStats {
|
||||
name: "Goblin".into(), hp: 50, level: 1,
|
||||
strength: 30, dexterity: 30, constitution: 30, luck: 10,
|
||||
ob_base: 10, db_base: 5,
|
||||
name: "Goblin".into(),
|
||||
hp: 50,
|
||||
level: 1,
|
||||
strength: 30,
|
||||
dexterity: 30,
|
||||
constitution: 30,
|
||||
luck: 10,
|
||||
ob_base: 10,
|
||||
db_base: 5,
|
||||
attack_type: AttackType::Crush,
|
||||
}
|
||||
}
|
||||
|
|
@ -294,7 +335,11 @@ mod tests {
|
|||
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
|
||||
for _ in 0..1000 {
|
||||
let roll = open_ended_roll(&mut rng);
|
||||
assert!(roll > -500 && roll < 500, "roll out of expected range: {}", roll);
|
||||
assert!(
|
||||
roll > -500 && roll < 500,
|
||||
"roll out of expected range: {}",
|
||||
roll
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -364,7 +409,10 @@ mod tests {
|
|||
fn parse_attack_type_maps_known_types() {
|
||||
assert!(matches!(parse_attack_type("slash"), AttackType::Slash));
|
||||
assert!(matches!(parse_attack_type("crush"), AttackType::Crush));
|
||||
assert!(matches!(parse_attack_type("puncture"), AttackType::Puncture));
|
||||
assert!(matches!(
|
||||
parse_attack_type("puncture"),
|
||||
AttackType::Puncture
|
||||
));
|
||||
assert!(matches!(parse_attack_type("unarmed"), AttackType::Crush));
|
||||
assert!(matches!(parse_attack_type("unknown"), AttackType::Crush));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ENEMIES_JSON: &str =
|
||||
include_str!("../../../../skara-brae-common/data/enemies.json");
|
||||
const ZONES_JSON: &str =
|
||||
include_str!("../../../../skara-brae-common/data/adventures/zones.json");
|
||||
const ENEMIES_JSON: &str = include_str!("../../../../skara-brae-common/data/enemies.json");
|
||||
const ZONES_JSON: &str = include_str!("../../../../skara-brae-common/data/adventures/zones.json");
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnemyDef {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
use sb_shared::Item;
|
||||
|
||||
const WEAPONS_JSON: &str =
|
||||
include_str!("../../../../skara-brae-common/data/items/weapons.json");
|
||||
const ARMOR_JSON: &str =
|
||||
include_str!("../../../../skara-brae-common/data/items/armor.json");
|
||||
const WEAPONS_JSON: &str = include_str!("../../../../skara-brae-common/data/items/weapons.json");
|
||||
const ARMOR_JSON: &str = include_str!("../../../../skara-brae-common/data/items/armor.json");
|
||||
const ACCESSORIES_JSON: &str =
|
||||
include_str!("../../../../skara-brae-common/data/items/accessories.json");
|
||||
|
||||
|
|
@ -14,8 +12,7 @@ pub fn load_all_items() -> Vec<Item> {
|
|||
serde_json::from_str(WEAPONS_JSON).expect("failed to parse weapons.json");
|
||||
items.extend(weapons);
|
||||
|
||||
let armor: Vec<Item> =
|
||||
serde_json::from_str(ARMOR_JSON).expect("failed to parse armor.json");
|
||||
let armor: Vec<Item> = serde_json::from_str(ARMOR_JSON).expect("failed to parse armor.json");
|
||||
items.extend(armor);
|
||||
|
||||
let accessories: Vec<Item> =
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use sb_shared::{Character, Item, AttackType};
|
||||
use crate::data::{EnemyDef, EncounterEntry};
|
||||
use crate::data::{EncounterEntry, EnemyDef};
|
||||
use rand::Rng;
|
||||
use sb_shared::{AttackType, Character, Item};
|
||||
|
||||
pub fn pick_enemy(
|
||||
rng: &mut impl Rng,
|
||||
|
|
@ -13,17 +13,24 @@ pub fn pick_enemy(
|
|||
for enc in encounters {
|
||||
remaining -= enc.weight;
|
||||
if remaining < 0 {
|
||||
return enemies.iter().find(|e| e.id == enc.enemy_id)
|
||||
return enemies
|
||||
.iter()
|
||||
.find(|e| e.id == enc.enemy_id)
|
||||
.expect("encounter references valid enemy")
|
||||
.clone();
|
||||
}
|
||||
}
|
||||
enemies.iter().find(|e| e.id == encounters.last().unwrap().enemy_id)
|
||||
enemies
|
||||
.iter()
|
||||
.find(|e| e.id == encounters.last().unwrap().enemy_id)
|
||||
.expect("encounter references valid enemy")
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn equipped_attack_type(equipment: &std::collections::HashMap<String, Option<String>>, items: &[Item]) -> AttackType {
|
||||
pub fn equipped_attack_type(
|
||||
equipment: &std::collections::HashMap<String, Option<String>>,
|
||||
items: &[Item],
|
||||
) -> AttackType {
|
||||
if let Some(Some(weapon_id)) = equipment.get("main_hand")
|
||||
&& let Some(weapon) = items.iter().find(|i| &i.id == weapon_id)
|
||||
&& let Some(ref at) = weapon.attack_type
|
||||
|
|
@ -33,7 +40,10 @@ pub fn equipped_attack_type(equipment: &std::collections::HashMap<String, Option
|
|||
AttackType::Crush
|
||||
}
|
||||
|
||||
pub fn equipped_damage(equipment: &std::collections::HashMap<String, Option<String>>, items: &[Item]) -> i32 {
|
||||
pub fn equipped_damage(
|
||||
equipment: &std::collections::HashMap<String, Option<String>>,
|
||||
items: &[Item],
|
||||
) -> i32 {
|
||||
let mut total = 0i32;
|
||||
for slot in &["main_hand", "off_hand"] {
|
||||
if let Some(Some(item_id)) = equipment.get(*slot)
|
||||
|
|
@ -46,7 +56,10 @@ pub fn equipped_damage(equipment: &std::collections::HashMap<String, Option<Stri
|
|||
total
|
||||
}
|
||||
|
||||
pub fn total_defense(equipment: &std::collections::HashMap<String, Option<String>>, items: &[Item]) -> i32 {
|
||||
pub fn total_defense(
|
||||
equipment: &std::collections::HashMap<String, Option<String>>,
|
||||
items: &[Item],
|
||||
) -> i32 {
|
||||
let mut total = 0i32;
|
||||
for (_, item_id) in equipment.iter() {
|
||||
if let Some(id) = item_id
|
||||
|
|
@ -60,7 +73,10 @@ pub fn total_defense(equipment: &std::collections::HashMap<String, Option<String
|
|||
}
|
||||
|
||||
pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<(), &'static str> {
|
||||
let inv_pos = char.inventory.iter().position(|id| id == item_id)
|
||||
let inv_pos = char
|
||||
.inventory
|
||||
.iter()
|
||||
.position(|id| id == item_id)
|
||||
.ok_or("item not in inventory")?;
|
||||
|
||||
char.inventory.remove(inv_pos);
|
||||
|
|
@ -73,7 +89,9 @@ pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<()
|
|||
}
|
||||
|
||||
pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, &'static str> {
|
||||
let prev = char.equipment.insert(slot.to_string(), None)
|
||||
let prev = char
|
||||
.equipment
|
||||
.insert(slot.to_string(), None)
|
||||
.flatten()
|
||||
.ok_or("nothing equipped in slot")?;
|
||||
|
||||
|
|
@ -125,42 +143,70 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str>
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sb_shared::{Character, CharacterStats, Class, Race, Item, Quality, ItemType};
|
||||
use sb_shared::{Character, CharacterStats, Class, Item, ItemType, Quality, 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: 10, max_energy: 36,
|
||||
nerve: 110, max_nerve: 110, gold: 1000, 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,
|
||||
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: 10,
|
||||
max_energy: 36,
|
||||
nerve: 110,
|
||||
max_nerve: 110,
|
||||
gold: 1000,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn sword() -> Item {
|
||||
Item {
|
||||
id: "sword1".into(), name: "Iron Sword".into(),
|
||||
r#type: ItemType::Weapon, quality: Quality::Common,
|
||||
id: "sword1".into(),
|
||||
name: "Iron Sword".into(),
|
||||
r#type: ItemType::Weapon,
|
||||
quality: Quality::Common,
|
||||
slot: "main_hand".into(),
|
||||
stats: HashMap::from([("damage".into(), 15i64)]),
|
||||
description: String::new(), attack_type: Some(AttackType::Slash),
|
||||
value: 100, always_in_shop: true,
|
||||
description: String::new(),
|
||||
attack_type: Some(AttackType::Slash),
|
||||
value: 100,
|
||||
always_in_shop: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn shield() -> Item {
|
||||
Item {
|
||||
id: "shield1".into(), name: "Wood Shield".into(),
|
||||
r#type: ItemType::Armor, quality: Quality::Common,
|
||||
id: "shield1".into(),
|
||||
name: "Wood Shield".into(),
|
||||
r#type: ItemType::Armor,
|
||||
quality: Quality::Common,
|
||||
slot: "off_hand".into(),
|
||||
stats: HashMap::from([("defense".into(), 10i64)]),
|
||||
description: String::new(), attack_type: None,
|
||||
value: 50, always_in_shop: true,
|
||||
description: String::new(),
|
||||
attack_type: None,
|
||||
value: 50,
|
||||
always_in_shop: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -211,16 +257,23 @@ mod tests {
|
|||
c.inventory.push("sword1".into());
|
||||
equip_item(&mut c, &sword(), "sword1").unwrap();
|
||||
assert!(!c.inventory.contains(&"sword1".to_string()));
|
||||
assert_eq!(c.equipment.get("main_hand").cloned(), Some(Some("sword1".into())));
|
||||
assert_eq!(
|
||||
c.equipment.get("main_hand").cloned(),
|
||||
Some(Some("sword1".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equip_item_swaps_previous() {
|
||||
let mut c = base_char();
|
||||
c.equipment.insert("main_hand".into(), Some("old_sword".into()));
|
||||
c.equipment
|
||||
.insert("main_hand".into(), Some("old_sword".into()));
|
||||
c.inventory.push("sword1".into());
|
||||
equip_item(&mut c, &sword(), "sword1").unwrap();
|
||||
assert_eq!(c.equipment.get("main_hand").cloned(), Some(Some("sword1".into())));
|
||||
assert_eq!(
|
||||
c.equipment.get("main_hand").cloned(),
|
||||
Some(Some("sword1".into()))
|
||||
);
|
||||
assert!(c.inventory.contains(&"old_sword".to_string()));
|
||||
assert!(!c.inventory.contains(&"sword1".to_string()));
|
||||
}
|
||||
|
|
@ -228,13 +281,17 @@ mod tests {
|
|||
#[test]
|
||||
fn equip_item_fails_if_not_in_inventory() {
|
||||
let mut c = base_char();
|
||||
assert_eq!(equip_item(&mut c, &sword(), "sword1"), Err("item not in inventory"));
|
||||
assert_eq!(
|
||||
equip_item(&mut c, &sword(), "sword1"),
|
||||
Err("item not in inventory")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unequip_item_moves_to_inventory() {
|
||||
let mut c = base_char();
|
||||
c.equipment.insert("main_hand".into(), Some("sword1".into()));
|
||||
c.equipment
|
||||
.insert("main_hand".into(), Some("sword1".into()));
|
||||
let result = unequip_item(&mut c, "main_hand").unwrap();
|
||||
assert_eq!(result, "sword1");
|
||||
assert!(c.inventory.contains(&"sword1".to_string()));
|
||||
|
|
@ -244,7 +301,10 @@ mod tests {
|
|||
#[test]
|
||||
fn unequip_item_fails_empty_slot() {
|
||||
let mut c = base_char();
|
||||
assert_eq!(unequip_item(&mut c, "main_hand"), Err("nothing equipped in slot"));
|
||||
assert_eq!(
|
||||
unequip_item(&mut c, "main_hand"),
|
||||
Err("nothing equipped in slot")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -270,42 +330,77 @@ mod tests {
|
|||
#[test]
|
||||
fn equipped_attack_type_returns_weapon_type() {
|
||||
let items = vec![sword()];
|
||||
let eq: HashMap<String, Option<String>> = HashMap::from([
|
||||
("main_hand".into(), Some("sword1".into())),
|
||||
]);
|
||||
assert!(matches!(equipped_attack_type(&eq, &items), AttackType::Slash));
|
||||
let eq: HashMap<String, Option<String>> =
|
||||
HashMap::from([("main_hand".into(), Some("sword1".into()))]);
|
||||
assert!(matches!(
|
||||
equipped_attack_type(&eq, &items),
|
||||
AttackType::Slash
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equipped_attack_type_defaults_to_crush() {
|
||||
let items: Vec<Item> = vec![];
|
||||
let eq: HashMap<String, Option<String>> = HashMap::new();
|
||||
assert!(matches!(equipped_attack_type(&eq, &items), AttackType::Crush));
|
||||
assert!(matches!(
|
||||
equipped_attack_type(&eq, &items),
|
||||
AttackType::Crush
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_enemy_respects_weights() {
|
||||
use rand::SeedableRng;
|
||||
let encounters = vec![
|
||||
crate::data::EncounterEntry { enemy_id: "kobold".into(), weight: 3 },
|
||||
crate::data::EncounterEntry { enemy_id: "rat".into(), weight: 1 },
|
||||
crate::data::EncounterEntry {
|
||||
enemy_id: "kobold".into(),
|
||||
weight: 3,
|
||||
},
|
||||
crate::data::EncounterEntry {
|
||||
enemy_id: "rat".into(),
|
||||
weight: 1,
|
||||
},
|
||||
];
|
||||
let enemies = vec![
|
||||
crate::data::EnemyDef {
|
||||
id: "kobold".into(), name: "Kobold".into(),
|
||||
hp: 20, level: 1, damage: 5, defense: 2,
|
||||
id: "kobold".into(),
|
||||
name: "Kobold".into(),
|
||||
hp: 20,
|
||||
level: 1,
|
||||
damage: 5,
|
||||
defense: 2,
|
||||
attack_type: "crush".into(),
|
||||
xp: 10, gold_min: 1, gold_max: 5,
|
||||
stats: crate::data::EnemyStats { strength: 15, intelligence: 10, dexterity: 15, constitution: 15, luck: 5 },
|
||||
xp: 10,
|
||||
gold_min: 1,
|
||||
gold_max: 5,
|
||||
stats: crate::data::EnemyStats {
|
||||
strength: 15,
|
||||
intelligence: 10,
|
||||
dexterity: 15,
|
||||
constitution: 15,
|
||||
luck: 5,
|
||||
},
|
||||
description: String::new(),
|
||||
loot: vec![],
|
||||
},
|
||||
crate::data::EnemyDef {
|
||||
id: "rat".into(), name: "Giant Rat".into(),
|
||||
hp: 10, level: 1, damage: 3, defense: 1,
|
||||
id: "rat".into(),
|
||||
name: "Giant Rat".into(),
|
||||
hp: 10,
|
||||
level: 1,
|
||||
damage: 3,
|
||||
defense: 1,
|
||||
attack_type: "puncture".into(),
|
||||
xp: 5, gold_min: 0, gold_max: 2,
|
||||
stats: crate::data::EnemyStats { strength: 5, intelligence: 5, dexterity: 15, constitution: 5, luck: 5 },
|
||||
xp: 5,
|
||||
gold_min: 0,
|
||||
gold_max: 2,
|
||||
stats: crate::data::EnemyStats {
|
||||
strength: 5,
|
||||
intelligence: 5,
|
||||
dexterity: 15,
|
||||
constitution: 5,
|
||||
luck: 5,
|
||||
},
|
||||
description: String::new(),
|
||||
loot: vec![],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -55,5 +55,8 @@ pub const BARD_SONGS: &[SongDef] = &[
|
|||
];
|
||||
|
||||
pub fn available_songs(level: i32) -> Vec<&'static SongDef> {
|
||||
BARD_SONGS.iter().filter(|s| s.required_level <= level).collect()
|
||||
BARD_SONGS
|
||||
.iter()
|
||||
.filter(|s| s.required_level <= level)
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@ pub struct SpellDef {
|
|||
pub heal_max: i32,
|
||||
}
|
||||
|
||||
const SPELLS_JSON: &str =
|
||||
include_str!("../../../../skara-brae-common/data/spells/spells.json");
|
||||
const SPELLS_JSON: &str = include_str!("../../../../skara-brae-common/data/spells/spells.json");
|
||||
|
||||
pub fn load_spells() -> Vec<SpellDef> {
|
||||
serde_json::from_str(SPELLS_JSON).expect("failed to parse spells.json")
|
||||
|
|
@ -31,9 +30,10 @@ pub fn class_for_spells(class: &str) -> &str {
|
|||
|
||||
pub fn spells_for_class(class: &str, spell_level: i32) -> Vec<&'static SpellDef> {
|
||||
let spell_class = class_for_spells(class);
|
||||
ALL_SPELLS.iter().filter(|s| {
|
||||
s.caster_class == spell_class && s.spell_level <= spell_level
|
||||
}).collect()
|
||||
ALL_SPELLS
|
||||
.iter()
|
||||
.filter(|s| s.caster_class == spell_class && s.spell_level <= spell_level)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn find_spell(id: &str) -> Option<&'static SpellDef> {
|
||||
|
|
@ -63,7 +63,10 @@ pub fn max_spell_points(class: &str, level: i32) -> i32 {
|
|||
}
|
||||
|
||||
pub fn is_caster_class(class: &str) -> bool {
|
||||
matches!(class_for_spells(class), "conjurer" | "magician" | "sorcerer" | "wizard")
|
||||
matches!(
|
||||
class_for_spells(class),
|
||||
"conjurer" | "magician" | "sorcerer" | "wizard"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn caster_classes() -> &'static [&'static str] {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue