Compare commits

..

No commits in common. "ac776522f98e92784f657f8360a236b6439f080d" and "899fde8fc3d9f82d9a561c070e1166256b92052c" have entirely different histories.

25 changed files with 598 additions and 2087 deletions

View file

@ -3,9 +3,9 @@
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
pub mod middleware;
pub mod routes;
pub mod ws;
pub mod middleware;
#[tokio::main]
async fn main() {

View file

@ -1,6 +1,6 @@
use axum::{
extract::Request,
http::{StatusCode, header},
http::{header, StatusCode},
middleware::Next,
response::Response,
};
@ -13,14 +13,19 @@ pub struct AuthUser {
pub role: String,
}
pub async fn auth_middleware(mut request: Request, next: Next) -> Result<Response, StatusCode> {
pub async fn auth_middleware(
mut request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let auth_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let token = auth_header.strip_prefix("Bearer ").unwrap_or(auth_header);
let token = auth_header
.strip_prefix("Bearer ")
.unwrap_or(auth_header);
if token.is_empty() {
return Err(StatusCode::UNAUTHORIZED);

View file

@ -1 +0,0 @@

File diff suppressed because it is too large Load diff

View file

@ -1,239 +0,0 @@
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Debug, Serialize)]
pub struct AdventureRunResponse {
pub combat_log: serde_json::Value,
pub enemy: String,
pub loot: Vec<String>,
pub zone: String,
}
#[derive(Debug, Serialize)]
pub struct HealResponse {
pub gold: i32,
pub healed: bool,
pub hp: i32,
pub max_hp: i32,
}
#[derive(Debug, Serialize)]
pub struct SpellEntryResponse {
pub code: String,
pub combat_damage_max: i32,
pub combat_damage_min: i32,
pub combat_effect: String,
pub description: String,
pub heal_max: i32,
pub heal_min: i32,
pub id: String,
pub known: bool,
pub name: String,
pub range: String,
pub spell_level: i32,
pub spell_points: i32,
}
#[derive(Debug, Serialize)]
pub struct SpellbookResponse {
pub caster_class: String,
pub learn_costs: BTreeMap<String, i32>,
pub max_spell_points: i32,
pub spell_level: i32,
pub spell_points: i32,
pub spells: Vec<SpellEntryResponse>,
}
#[derive(Debug, Serialize)]
pub struct ActivateSpellResponse {
pub active_spell: Option<String>,
pub spell_points: i32,
}
#[derive(Debug, Serialize)]
pub struct LearnSpellsResponse {
pub gold: i32,
pub learned_count: usize,
pub max_spell_points: i32,
pub spell_points: i32,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum LearnSpellResponse {
Activate(ActivateSpellResponse),
Learn(LearnSpellsResponse),
}
impl From<sb_core::adventure::AdventureOutcome> for AdventureRunResponse {
fn from(outcome: sb_core::adventure::AdventureOutcome) -> Self {
AdventureRunResponse {
// to_value mirrors exactly what json! does to &log today: struct →
// Value::Object with BTreeMap ordering — nested keys sorted, byte-identical wire.
combat_log: serde_json::to_value(&outcome.combat_log)
.expect("CombatLog graph contains no map types; to_value is infallible"),
enemy: outcome.enemy_name,
loot: outcome.loot_item_names,
zone: outcome.zone_id,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use sb_shared::{CombatLog, CombatRound, CritEntry, CritResult};
fn representative_log() -> CombatLog {
CombatLog {
rounds: vec![
CombatRound {
attacker: "Test".into(),
defender: "Giant Rat".into(),
raw_roll: 67,
ob: 15,
db: 0,
result: 82,
damage: 12,
crit: None,
defender_hp: 0,
},
CombatRound {
attacker: "Giant Rat".into(),
defender: "Test".into(),
raw_roll: 99,
ob: 3,
db: 5,
result: 97,
damage: 18,
crit: Some(CritResult {
severity: "A".into(),
crit: CritEntry {
name: "critical".into(),
damage_multiplier: 2.0,
stun_rounds: 1,
bleed_per_round: 2,
bleed_rounds: 3,
effects: vec!["stun".into()],
},
}),
defender_hp: 62,
},
],
winner: "Test".into(),
loser: "Giant Rat".into(),
xp_gained: 5,
gold_gained: 2,
}
}
fn representative_outcome() -> sb_core::adventure::AdventureOutcome {
sb_core::adventure::AdventureOutcome {
zone_id: "skara_brae_streets".into(),
enemy_name: "Giant Rat".into(),
combat_log: representative_log(),
loot_item_names: vec!["Iron Sword".into()],
}
}
const GOLDEN_W1: &str = r#"{"combat_log":{"gold_gained":2,"loser":"Giant Rat","rounds":[{"attacker":"Test","crit":null,"damage":12,"db":0,"defender":"Giant Rat","defender_hp":0,"ob":15,"raw_roll":67,"result":82},{"attacker":"Giant Rat","crit":{"crit":{"bleed_per_round":2,"bleed_rounds":3,"damage_multiplier":2.0,"effects":["stun"],"name":"critical","stun_rounds":1},"severity":"A"},"damage":18,"db":5,"defender":"Test","defender_hp":62,"ob":3,"raw_roll":99,"result":97}],"winner":"Test","xp_gained":5},"enemy":"Giant Rat","loot":["Iron Sword"],"zone":"skara_brae_streets"}"#;
const GOLDEN_W2: &str = r#"{"gold":123,"healed":true,"hp":45,"max_hp":80}"#;
const GOLDEN_W3: &str = r#"{"code":"fire_bolt","combat_damage_max":6,"combat_damage_min":2,"combat_effect":"damage","description":"Hurls a mote of fire at the target.","heal_max":0,"heal_min":0,"id":"spell_fire_bolt","known":true,"name":"Fire Bolt","range":"short","spell_level":1,"spell_points":10}"#;
const GOLDEN_W4: &str = r#"{"caster_class":"mage","learn_costs":{"2":1000,"3":2000,"4":4000,"5":7000,"6":10000,"7":20000},"max_spell_points":40,"spell_level":4,"spell_points":12,"spells":[{"code":"fire_bolt","combat_damage_max":6,"combat_damage_min":2,"combat_effect":"damage","description":"Hurls a mote of fire at the target.","heal_max":0,"heal_min":0,"id":"spell_fire_bolt","known":true,"name":"Fire Bolt","range":"short","spell_level":1,"spell_points":10}]}"#;
const GOLDEN_W5: &str = r#"{"active_spell":"mage_armor","spell_points":12}"#;
const GOLDEN_W5_NULL: &str = r#"{"active_spell":null,"spell_points":12}"#;
const GOLDEN_W6: &str =
r#"{"gold":19700,"learned_count":4,"max_spell_points":40,"spell_points":40}"#;
#[test]
fn wire_identity_adventure_response() {
let dto: AdventureRunResponse = representative_outcome().into();
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W1);
}
#[test]
fn wire_identity_heal_response() {
let dto = HealResponse {
gold: 123,
healed: true,
hp: 45,
max_hp: 80,
};
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W2);
}
fn representative_spell_entry() -> SpellEntryResponse {
SpellEntryResponse {
code: "fire_bolt".into(),
combat_damage_max: 6,
combat_damage_min: 2,
combat_effect: "damage".into(),
description: "Hurls a mote of fire at the target.".into(),
heal_max: 0,
heal_min: 0,
id: "spell_fire_bolt".into(),
known: true,
name: "Fire Bolt".into(),
range: "short".into(),
spell_level: 1,
spell_points: 10,
}
}
#[test]
fn wire_identity_spell_entry_response() {
let dto = representative_spell_entry();
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W3);
}
#[test]
fn wire_identity_spellbook_response() {
let dto = SpellbookResponse {
caster_class: "mage".into(),
learn_costs: BTreeMap::from([
("2".into(), 1000),
("3".into(), 2000),
("4".into(), 4000),
("5".into(), 7000),
("6".into(), 10000),
("7".into(), 20000),
]),
max_spell_points: 40,
spell_level: 4,
spell_points: 12,
spells: vec![representative_spell_entry()],
};
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W4);
}
#[test]
fn wire_identity_activate_spell_response() {
let dto = ActivateSpellResponse {
active_spell: Some("mage_armor".into()),
spell_points: 12,
};
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W5);
let dto = ActivateSpellResponse {
active_spell: None,
spell_points: 12,
};
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W5_NULL);
}
#[test]
fn wire_identity_learn_response() {
let dto = LearnSpellResponse::Learn(LearnSpellsResponse {
gold: 19700,
learned_count: 4,
max_spell_points: 40,
spell_points: 40,
});
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W6);
}
}

View file

@ -1 +0,0 @@

View file

@ -1 +0,0 @@

View file

@ -1,2 +1,2 @@
pub mod channels;
pub mod handler;
pub mod channels;

View file

@ -1,6 +1,6 @@
use argon2::{
Argon2,
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
@ -17,7 +17,5 @@ pub fn verify_password(password: &str, hash: &str) -> bool {
Ok(h) => h,
Err(_) => return false,
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
}

View file

@ -1,6 +1,6 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use chrono::{Utc, Duration};
fn jwt_secret() -> Vec<u8> {
std::env::var("JWT_SECRET")
@ -16,11 +16,7 @@ pub struct Claims {
pub exp: usize,
}
pub fn create_token(
user_id: u32,
name: &str,
role: &str,
) -> Result<String, jsonwebtoken::errors::Error> {
pub fn create_token(user_id: u32, name: &str, role: &str) -> Result<String, jsonwebtoken::errors::Error> {
let exp = Utc::now()
.checked_add_signed(Duration::hours(24))
.expect("valid timestamp")
@ -43,6 +39,9 @@ pub fn create_token(
pub fn verify_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let mut validation = Validation::default();
validation.leeway = 60;
jsonwebtoken::decode::<Claims>(token, &DecodingKey::from_secret(&jwt_secret()), &validation)
.map(|d| d.claims)
jsonwebtoken::decode::<Claims>(
token,
&DecodingKey::from_secret(&jwt_secret()),
&validation,
).map(|d| d.claims)
}

View file

@ -1,473 +0,0 @@
use crate::character;
use crate::combat::{self, CombatStats};
use crate::data::{EnemyDef, ZoneDef};
use crate::error::DomainError;
use crate::services;
use crate::songs;
use crate::spells;
use rand::Rng;
use sb_shared::{Character, CombatLog, Item};
/// Domain output of one adventure run. No serde derives — the HTTP DTO
/// (sb-api `AdventureRunResponse`) maps from this at the boundary.
pub struct AdventureOutcome {
pub zone_id: String,
pub enemy_name: String,
pub combat_log: CombatLog,
pub loot_item_names: Vec<String>,
}
pub fn run_adventure(
rng: &mut impl Rng,
character: &mut Character,
zone: &ZoneDef,
enemies: &[EnemyDef],
items: &[Item],
now_ms: i64,
) -> Result<AdventureOutcome, DomainError> {
character::apply_regen(character, now_ms);
if character.hp <= 0 {
return Err(DomainError::CharacterIncapacitated);
}
if character.nerve < zone.nerve_cost {
return Err(DomainError::NotEnoughNerve);
}
character.nerve -= zone.nerve_cost;
let enemy_def = services::pick_enemy(rng, &zone.encounters, enemies)?;
let char_attack_type = services::equipped_attack_type(&character.equipment, items);
let char_ob = services::equipped_damage(&character.equipment, items);
let char_db = services::total_defense(&character.equipment, items);
let char_combat = CombatStats {
name: character.name.clone(),
hp: character.hp,
level: character.level,
strength: character.stats.strength,
dexterity: character.stats.dexterity,
constitution: character.stats.constitution,
luck: character.stats.luck,
ob_base: char_ob,
db_base: char_db,
attack_type: char_attack_type,
};
let enemy_combat = CombatStats {
name: enemy_def.name.clone(),
hp: enemy_def.hp,
level: enemy_def.level,
strength: enemy_def.stats.strength,
dexterity: enemy_def.stats.dexterity,
constitution: enemy_def.stats.constitution,
luck: enemy_def.stats.luck,
ob_base: enemy_def.damage,
db_base: enemy_def.defense,
attack_type: combat::parse_attack_type(&enemy_def.attack_type),
};
let spell_effect = character.active_spell.as_deref().and_then(|sid| {
spells::find_spell(sid).map(|s| combat::SpellEffect {
combat_effect: s.combat_effect.clone(),
damage_min: s.combat_damage_min,
damage_max: s.combat_damage_max,
heal_min: s.heal_min,
heal_max: s.heal_max,
})
});
let song_effect = character.active_song.as_deref().and_then(|sid| {
songs::BARD_SONGS
.iter()
.find(|s| s.id == sid)
.map(|s| s.combat_effect)
});
let log = combat::resolve_combat_full(
char_combat,
enemy_combat,
enemy_def.xp,
enemy_def.gold_min,
enemy_def.gold_max,
song_effect,
spell_effect.as_ref(),
rng,
);
let mut dropped_ids: Vec<String> = Vec::new();
if log.winner == character.name {
character.xp += log.xp_gained as i64;
character.gold += log.gold_gained;
if let Some(hp) = log
.rounds
.iter()
.rfind(|r| r.defender == character.name)
.map(|r| r.defender_hp)
{
character.hp = hp;
}
for drop in &enemy_def.loot {
if rng.random_bool(drop.chance) && items.iter().any(|i| i.id == drop.item_id) {
character.inventory.push(drop.item_id.clone());
dropped_ids.push(drop.item_id.clone());
}
}
} else {
character.hp = 0;
}
if let Some(active) = character.active_spell.as_deref()
&& let Some(sp) = spells::find_spell(active)
{
character.spell_points = (character.spell_points - sp.spell_points).max(0);
}
let loot_item_names = dropped_ids
.iter()
.filter_map(|id| items.iter().find(|i| &i.id == id).map(|i| i.name.clone()))
.collect();
Ok(AdventureOutcome {
zone_id: zone.id.clone(),
enemy_name: enemy_def.name.clone(),
combat_log: log,
loot_item_names,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constants;
use crate::data::{EncounterEntry, EnemyStats, LootDrop};
use rand::SeedableRng;
use sb_shared::{AttackType, CharacterStats, Class, 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: 50,
intelligence: 20,
dexterity: 20,
constitution: 20,
luck: 20,
},
hp: 100,
max_hp: 100,
energy: 36,
max_energy: 36,
nerve: 110,
max_nerve: 110,
gold: 100,
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 char_with_sword() -> Character {
let mut c = base_char();
c.equipment
.insert("main_hand".into(), Some("sword1".into()));
c
}
fn sword() -> Item {
Item {
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,
}
}
fn items() -> Vec<Item> {
vec![sword()]
}
fn zone() -> ZoneDef {
zone_with("rat")
}
fn zone_with(enemy_id: &str) -> ZoneDef {
ZoneDef {
id: "streets".into(),
name: "Skara Brae Streets".into(),
min_level: 1,
max_level: 5,
nerve_cost: 5,
description: String::new(),
encounters: vec![EncounterEntry {
enemy_id: enemy_id.into(),
weight: 1,
}],
}
}
fn weak_enemy() -> EnemyDef {
EnemyDef {
id: "rat".into(),
name: "Giant Rat".into(),
hp: 10,
level: 1,
damage: 0,
defense: 0,
attack_type: "puncture".into(),
xp: 5,
gold_min: 1,
gold_max: 2,
stats: EnemyStats {
strength: 5,
intelligence: 5,
dexterity: 5,
constitution: 5,
luck: 5,
},
description: String::new(),
loot: vec![LootDrop {
item_id: "sword1".into(),
chance: 1.0,
}],
}
}
fn overwhelming_enemy() -> EnemyDef {
EnemyDef {
id: "doom".into(),
name: "Doom Bringer".into(),
hp: 500,
level: 10,
damage: 200,
defense: 0,
attack_type: "crush".into(),
xp: 5,
gold_min: 1,
gold_max: 2,
stats: EnemyStats {
strength: 5,
intelligence: 5,
dexterity: 5,
constitution: 5,
luck: 5,
},
description: String::new(),
loot: vec![LootDrop {
item_id: "sword1".into(),
chance: 1.0,
}],
}
}
#[test]
fn run_adventure_win_applies_rewards_and_loot() {
let mut rng = rand::rngs::StdRng::seed_from_u64(7);
let mut c = char_with_sword();
let (xp_before, gold_before, nerve_before) = (c.xp, c.gold, c.nerve);
let z = zone();
let outcome =
run_adventure(&mut rng, &mut c, &z, &[weak_enemy()], &items(), 1_000_000).unwrap();
assert_eq!(outcome.combat_log.winner, c.name);
assert_eq!(c.xp, xp_before + 5);
assert!(c.gold > gold_before && c.gold <= gold_before + 2);
assert_eq!(c.gold, gold_before + outcome.combat_log.gold_gained);
assert_eq!(c.nerve, nerve_before - z.nerve_cost);
assert!(c.inventory.contains(&"sword1".to_string()));
assert_eq!(outcome.loot_item_names, vec!["Iron Sword".to_string()]);
assert_eq!(outcome.enemy_name, "Giant Rat");
assert_eq!(outcome.zone_id, "streets");
assert_eq!(
serde_json::to_string(&outcome.combat_log).unwrap(),
GOLDEN_COMBAT_LOG_JSON
);
}
#[test]
fn run_adventure_defeat_sets_hp_to_zero() {
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let mut c = base_char();
c.stats.strength = 5;
let (xp_before, gold_before) = (c.xp, c.gold);
let outcome = run_adventure(
&mut rng,
&mut c,
&zone_with("doom"),
&[overwhelming_enemy()],
&items(),
1_000_000,
)
.unwrap();
assert_eq!(outcome.combat_log.winner, "Doom Bringer");
assert_eq!(c.hp, 0);
assert_eq!(c.xp, xp_before);
assert_eq!(c.gold, gold_before);
assert!(c.inventory.is_empty());
assert!(outcome.loot_item_names.is_empty());
}
#[test]
fn run_adventure_rejects_incapacitated() {
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let mut c = char_with_sword();
c.hp = 0;
let nerve_before = c.nerve;
let result = run_adventure(
&mut rng,
&mut c,
&zone(),
&[weak_enemy()],
&items(),
1_000_000,
);
assert!(matches!(result, Err(DomainError::CharacterIncapacitated)));
assert_eq!(c.nerve, nerve_before);
}
#[test]
fn run_adventure_rejects_insufficient_nerve() {
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let mut c = char_with_sword();
c.nerve = 4;
c.last_regen = 1_000_000;
let nerve_before = c.nerve;
let result = run_adventure(
&mut rng,
&mut c,
&zone(),
&[weak_enemy()],
&items(),
1_000_000,
);
assert!(matches!(result, Err(DomainError::NotEnoughNerve)));
assert_eq!(c.nerve, nerve_before);
}
#[test]
fn run_adventure_applies_regen_before_guards() {
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let mut c = char_with_sword();
c.hp = 40;
c.max_hp = 80;
c.last_regen = 0;
let now_ms = 10 * constants::HP_REGEN_INTERVAL_MS;
let mut enemy = weak_enemy();
enemy.damage = 0;
// Seed 42 was verified at fixture-recording time to yield zero enemy
// hits (the character kills the damaged-0 enemy in round 1), so the
// hp == 80 assertion holds: design §7 A5 no-hit-seed rationale.
let outcome = run_adventure(&mut rng, &mut c, &zone(), &[enemy], &items(), now_ms).unwrap();
assert_eq!(c.last_regen, now_ms);
assert_eq!(c.hp, 80);
assert_eq!(outcome.combat_log.winner, c.name);
}
#[test]
fn run_adventure_charges_spell_upkeep_on_win_and_loss() {
let spell = spells::find_spell("arfi").expect("arfi fixture spell");
assert!(spell.spell_points > 0);
let mut win_rng = rand::rngs::StdRng::seed_from_u64(7);
let mut winner = char_with_sword();
winner.active_spell = Some("arfi".into());
winner.spell_points = 20;
run_adventure(
&mut win_rng,
&mut winner,
&zone(),
&[weak_enemy()],
&items(),
1_000_000,
)
.unwrap();
assert_eq!(winner.spell_points, (20 - spell.spell_points).max(0));
assert_eq!(winner.active_spell.as_deref(), Some("arfi"));
let mut loss_rng = rand::rngs::StdRng::seed_from_u64(42);
let mut loser = base_char();
loser.stats.strength = 5;
loser.active_spell = Some("arfi".into());
loser.spell_points = 20;
run_adventure(
&mut loss_rng,
&mut loser,
&zone_with("doom"),
&[overwhelming_enemy()],
&items(),
1_000_000,
)
.unwrap();
assert_eq!(loser.spell_points, (20 - spell.spell_points).max(0));
assert_eq!(loser.active_spell.as_deref(), Some("arfi"));
}
#[test]
fn run_adventure_filters_loot_to_catalog_items() {
let mut rng = rand::rngs::StdRng::seed_from_u64(7);
let mut enemy = weak_enemy();
enemy.loot = vec![LootDrop {
item_id: "ghost_item".into(),
chance: 1.0,
}];
let mut c = char_with_sword();
let outcome =
run_adventure(&mut rng, &mut c, &zone(), &[enemy], &items(), 1_000_000).unwrap();
assert!(!c.inventory.contains(&"ghost_item".to_string()));
assert!(outcome.loot_item_names.is_empty());
}
#[test]
fn run_adventure_is_deterministic_under_seeded_rng() {
let run = || {
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let mut c = char_with_sword();
let outcome = run_adventure(
&mut rng,
&mut c,
&zone(),
&[weak_enemy()],
&items(),
1_000_000,
)
.unwrap();
(
format!(
"{:?}",
(
outcome.zone_id,
outcome.enemy_name,
outcome.loot_item_names,
outcome.combat_log
)
),
format!("{:?}", c),
)
};
assert_eq!(run(), run());
}
const GOLDEN_COMBAT_LOG_JSON: &str = r#"{"rounds":[{"attacker":"Test","defender":"Giant Rat","raw_roll":-11,"ob":30,"db":-10,"result":29,"damage":43,"crit":{"severity":"A","crit":{"name":"Minor Slash","damage_multiplier":1.5,"stun_rounds":0,"bleed_per_round":1,"bleed_rounds":1,"effects":[]}},"defender_hp":0}],"winner":"Test","loser":"Giant Rat","xp_gained":5,"gold_gained":1}"#;
}

View file

@ -1,7 +1,5 @@
use crate::constants;
use crate::error::DomainError;
use crate::spells;
use sb_shared::{Character, CharacterStats, Class, Race};
use crate::spells;
pub struct TrainingGround {
pub stat: &'static str,
@ -10,31 +8,11 @@ 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 {
@ -48,51 +26,11 @@ 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 {
@ -105,102 +43,29 @@ 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 {
(stat - constants::STAT_BONUS_NEUTRAL) / constants::STAT_BONUS_STEP
(stat - 20) / 2
}
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);
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");
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),
@ -211,46 +76,35 @@ 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");
constants::BASE_MAX_ENERGY + info.base_hp / constants::MAX_ENERGY_PER_BASE_HP_DIVISOR
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");
constants::BASE_MAX_NERVE + info.base_hp / constants::MAX_NERVE_PER_BASE_HP_DIVISOR
let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class");
100 + info.base_hp / 2
}
pub fn energy_regen_rate() -> i64 {
constants::ENERGY_REGEN_INTERVAL_MS
180000
}
pub fn nerve_regen_rate() -> i64 {
constants::NERVE_REGEN_INTERVAL_MS
300000
}
pub fn hp_regen_rate() -> i64 {
constants::HP_REGEN_INTERVAL_MS
300000
}
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;
@ -261,16 +115,15 @@ 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 * constants::HP_REGEN_FRACTION_PER_TICK).ceil() as i32;
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 / constants::TRAINING_STAT_SCALER).floor() as i32;
(constants::TRAINING_BASE_COST * constants::TRAINING_GROWTH_FACTOR.powi(scaled)) as 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(
@ -287,8 +140,7 @@ 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()
@ -302,7 +154,7 @@ pub fn create_character(
name,
race,
class,
level: constants::STARTING_LEVEL,
level: 1,
xp: 0,
stats,
hp: max_hp,
@ -311,7 +163,7 @@ pub fn create_character(
max_energy,
nerve: max_nerve,
max_nerve,
gold: constants::STARTING_GOLD,
gold: 50,
last_regen: 0,
equipment: [
("head".into(), None),
@ -325,9 +177,7 @@ 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,
@ -341,28 +191,26 @@ 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 * (constants::XP_CURVE_COEFFICIENT * n * n) as i64
XP_FACTOR * (100.0 * n * n) as i64
}
pub fn level_up_cost(level: i32) -> i32 {
constants::LEVEL_UP_GOLD_PER_LEVEL * level
500 * level
}
pub fn level_up(char: &mut Character) -> Result<i32, DomainError> {
pub fn level_up(char: &mut Character) -> Result<i32, &'static str> {
if char.level >= MAX_LEVEL {
return Err(DomainError::AlreadyAtMaxLevel);
return Err("already at max level");
}
let next_level = char.level + 1;
if char.xp < xp_for_level(next_level) {
return Err(DomainError::NotEnoughXp);
return Err("not enough XP");
}
let cost = level_up_cost(next_level);
if char.gold < cost {
return Err(DomainError::NotEnoughGold);
return Err("not enough gold");
}
char.gold -= cost;
char.level = next_level;
@ -379,38 +227,11 @@ pub fn level_up(char: &mut Character) -> Result<i32, DomainError> {
}
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")
}
/// 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,
}
CLASSES.iter().find(|c| &c.class == class).expect("invalid class")
}
#[cfg(test)]
@ -421,35 +242,15 @@ 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,
}
}
@ -505,7 +306,7 @@ mod tests {
c.level = MAX_LEVEL;
c.xp = i64::MAX;
c.gold = i32::MAX;
assert_eq!(level_up(&mut c), Err(DomainError::AlreadyAtMaxLevel));
assert_eq!(level_up(&mut c), Err("already at max level"));
}
#[test]
@ -534,7 +335,7 @@ mod tests {
let mut c = base_char();
c.xp = 100;
c.gold = 10000;
assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughXp));
assert_eq!(level_up(&mut c), Err("not enough XP"));
assert_eq!(c.level, 1);
assert_eq!(c.gold, 10000);
}
@ -544,26 +345,14 @@ mod tests {
let mut c = base_char();
c.xp = 1800;
c.gold = 0;
assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughGold));
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 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);
@ -652,70 +441,4 @@ mod tests {
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);
}
}
}

View file

@ -1,4 +1,3 @@
use crate::constants;
use rand::Rng;
use sb_shared::{AttackType, CombatLog, CombatRound, CritEntry, CritResult};
use serde::{Deserialize, Serialize};
@ -8,8 +7,9 @@ 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 {
@ -33,8 +33,7 @@ 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)
/ constants::DEFENSIVE_CON_BONUS_DIVISOR;
let con_bonus = (super::character::stat_bonus(self.constitution) as i32) / 2;
dex_bonus + con_bonus + self.db_base
}
}
@ -54,27 +53,21 @@ pub fn crit_damage_cap(attacker_level: i32, defender_level: i32, defender_hp: i3
return None;
}
let cap_pct = match diff {
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,
1..=9 => return None,
10..=19 => 0.60,
20..=49 => 0.40,
50..=99 => 0.15,
_ => 0.05,
};
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(constants::ATTACK_ROLL_MIN..=constants::ATTACK_ROLL_MAX);
let roll: i32 = rng.random_range(1..=100);
let mut total = roll;
if roll >= constants::OPEN_END_HIGH_THRESHOLD {
if roll >= 96 {
total += open_ended_roll(rng);
} else if roll <= constants::OPEN_END_LOW_THRESHOLD {
} else if roll <= 5 {
total -= open_ended_roll(rng);
}
total
@ -100,8 +93,7 @@ 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) {
let capped = if let Some(cap) = crit_damage_cap(attacker.level, defender.level, defender.hp) {
crit_damage.min(cap)
} else {
crit_damage
@ -128,13 +120,13 @@ pub fn resolve_attack(
}
pub fn crit_severity(result: i32) -> Option<String> {
if result >= constants::CRIT_SEVERITY_D_MIN {
if result >= 151 {
Some("D".into())
} else if result >= constants::CRIT_SEVERITY_C_MIN {
} else if result >= 101 {
Some("C".into())
} else if result >= constants::CRIT_SEVERITY_B_MIN {
} else if result >= 51 {
Some("B".into())
} else if result >= constants::CRIT_SEVERITY_A_MIN {
} else if result >= 21 {
Some("A".into())
} else {
None
@ -163,9 +155,7 @@ 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(
@ -177,16 +167,7 @@ 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)]
@ -206,11 +187,7 @@ 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"
@ -234,11 +211,7 @@ 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);
@ -254,14 +227,12 @@ pub fn resolve_combat_full(
}
if song_effect == Some("heal") && a_hp > 0 && a_hp < attacker.hp {
let heal_amt = (attacker.hp as f64 * constants::BARD_SONG_HEAL_FRACTION).ceil() as i32;
let heal_amt = (attacker.hp as f64 * 0.03).ceil() as i32;
a_hp = (a_hp + heal_amt).min(attacker.hp);
}
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);
@ -272,12 +243,8 @@ 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 += constants::SONG_ACCURACY_OB_BONUS;
}
Some("damage") => {
s.ob_base += constants::SONG_DAMAGE_OB_BONUS;
}
Some("accuracy") => { s.ob_base += 15; }
Some("damage") => { s.ob_base += 20; }
_ => {}
}
s
@ -286,12 +253,8 @@ 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 += constants::SONG_DEFENSE_DB_BONUS;
}
Some("weaken") => {
s.ob_base = (s.ob_base as f64 * constants::SONG_WEAKEN_OB_FACTOR) 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
@ -309,30 +272,18 @@ 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,
}
}
@ -343,11 +294,7 @@ 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);
}
}
@ -417,16 +364,8 @@ 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));
}
#[test]
fn crit_tables_parse() {
assert!(!CRIT_TABLES.is_empty());
}
}

View file

@ -1,131 +0,0 @@
//! 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

@ -1,7 +1,9 @@
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 {
@ -59,18 +61,3 @@ pub fn load_enemies() -> Vec<EnemyDef> {
pub fn load_zones() -> Vec<ZoneDef> {
serde_json::from_str(ZONES_JSON).expect("failed to parse zones.json")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enemies_json_parses() {
assert!(!load_enemies().is_empty());
}
#[test]
fn zones_json_parses() {
assert!(!load_zones().is_empty());
}
}

View file

@ -1,23 +0,0 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum DomainError {
#[error("character is incapacitated")]
CharacterIncapacitated,
#[error("not enough nerve")]
NotEnoughNerve,
#[error("not enough energy")]
NotEnoughEnergy,
#[error("not enough gold")]
NotEnoughGold,
#[error("not enough XP")]
NotEnoughXp,
#[error("already at max level")]
AlreadyAtMaxLevel,
#[error("invalid stat name")]
InvalidStat,
#[error("item not in inventory")]
ItemNotInInventory,
#[error("nothing equipped in slot")]
NothingEquippedInSlot,
#[error("encounter references unknown enemy")]
UnknownEncounterEnemy,
}

View file

@ -1,7 +1,9 @@
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");
@ -12,7 +14,8 @@ 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> =
@ -29,13 +32,3 @@ pub fn item_price(item: &Item) -> i32 {
pub fn item_sell_price(item: &Item) -> i32 {
item.value / 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_items_json_parses() {
assert!(!load_all_items().is_empty());
}
}

View file

@ -1,11 +1,8 @@
#![allow(dead_code)]
pub mod adventure;
pub mod character;
pub mod combat;
pub mod constants;
pub mod data;
pub mod error;
pub mod items;
pub mod services;
pub mod songs;

View file

@ -1,43 +1,29 @@
use crate::data::{EncounterEntry, EnemyDef};
use crate::error::DomainError;
use sb_shared::{Character, Item, AttackType};
use crate::data::{EnemyDef, EncounterEntry};
use rand::Rng;
use sb_shared::{AttackType, Character, Item};
pub fn pick_enemy(
rng: &mut impl Rng,
encounters: &[EncounterEntry],
enemies: &[EnemyDef],
) -> Result<EnemyDef, DomainError> {
) -> EnemyDef {
let total_weight: i32 = encounters.iter().map(|e| e.weight).sum();
if encounters.is_empty() || total_weight <= 0 {
return Err(DomainError::UnknownEncounterEnemy);
}
let roll: i32 = rng.random_range(0..total_weight);
let mut remaining = roll;
for enc in encounters {
remaining -= enc.weight;
if remaining < 0 {
return enemies
.iter()
.find(|e| e.id == enc.enemy_id)
.cloned()
.ok_or(DomainError::UnknownEncounterEnemy);
return enemies.iter().find(|e| e.id == enc.enemy_id)
.expect("encounter references valid enemy")
.clone();
}
}
let last = encounters
.last()
.ok_or(DomainError::UnknownEncounterEnemy)?;
enemies
.iter()
.find(|e| e.id == last.enemy_id)
.cloned()
.ok_or(DomainError::UnknownEncounterEnemy)
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
@ -47,10 +33,7 @@ pub fn equipped_attack_type(
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)
@ -63,10 +46,7 @@ pub fn equipped_damage(
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
@ -79,12 +59,9 @@ pub fn total_defense(
total
}
pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<(), DomainError> {
let inv_pos = char
.inventory
.iter()
.position(|id| id == item_id)
.ok_or(DomainError::ItemNotInInventory)?;
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)
.ok_or("item not in inventory")?;
char.inventory.remove(inv_pos);
let slot = item.slot.clone();
@ -95,20 +72,18 @@ pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<()
Ok(())
}
pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, DomainError> {
let prev = char
.equipment
.insert(slot.to_string(), None)
pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, &'static str> {
let prev = char.equipment.insert(slot.to_string(), None)
.flatten()
.ok_or(DomainError::NothingEquippedInSlot)?;
.ok_or("nothing equipped in slot")?;
char.inventory.push(prev.clone());
Ok(prev)
}
pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, DomainError> {
if char.energy < crate::constants::TRAINING_ENERGY_COST {
return Err(DomainError::NotEnoughEnergy);
pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str> {
if char.energy < 1 {
return Err("not enough energy");
}
let current = match stat {
"strength" => char.stats.strength,
@ -116,13 +91,13 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, DomainError>
"dexterity" => char.stats.dexterity,
"constitution" => char.stats.constitution,
"luck" => char.stats.luck,
_ => return Err(DomainError::InvalidStat),
_ => return Err("invalid stat"),
};
let cost = super::character::training_cost(current);
if char.gold < cost {
return Err(DomainError::NotEnoughGold);
return Err("not enough gold");
}
char.energy -= crate::constants::TRAINING_ENERGY_COST;
char.energy -= 1;
char.gold -= cost;
match stat {
"strength" => char.stats.strength += 1,
@ -142,7 +117,7 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, DomainError>
"dexterity" => char.stats.dexterity,
"constitution" => char.stats.constitution,
"luck" => char.stats.luck,
_ => return Err(DomainError::InvalidStat),
_ => return Err("invalid stat"),
};
Ok(new_val)
}
@ -150,70 +125,42 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, DomainError>
#[cfg(test)]
mod tests {
use super::*;
use sb_shared::{Character, CharacterStats, Class, Item, ItemType, Quality, Race};
use sb_shared::{Character, CharacterStats, Class, Race, Item, Quality, ItemType};
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,
}
}
@ -232,29 +179,20 @@ mod tests {
fn train_stat_fails_without_energy() {
let mut c = base_char();
c.energy = 0;
assert_eq!(
train_stat(&mut c, "strength"),
Err(DomainError::NotEnoughEnergy)
);
assert_eq!(train_stat(&mut c, "strength"), Err("not enough energy"));
}
#[test]
fn train_stat_fails_without_gold() {
let mut c = base_char();
c.gold = 0;
assert_eq!(
train_stat(&mut c, "strength"),
Err(DomainError::NotEnoughGold)
);
assert_eq!(train_stat(&mut c, "strength"), Err("not enough gold"));
}
#[test]
fn train_stat_fails_invalid_stat() {
let mut c = base_char();
assert_eq!(
train_stat(&mut c, "charisma"),
Err(DomainError::InvalidStat)
);
assert_eq!(train_stat(&mut c, "charisma"), Err("invalid stat"));
}
#[test]
@ -273,23 +211,16 @@ 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()));
}
@ -297,17 +228,13 @@ 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(DomainError::ItemNotInInventory)
);
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()));
@ -317,10 +244,7 @@ mod tests {
#[test]
fn unequip_item_fails_empty_slot() {
let mut c = base_char();
assert_eq!(
unequip_item(&mut c, "main_hand"),
Err(DomainError::NothingEquippedInSlot)
);
assert_eq!(unequip_item(&mut c, "main_hand"), Err("nothing equipped in slot"));
}
#[test]
@ -346,84 +270,49 @@ 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![],
},
];
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
for _ in 0..100 {
let enemy = pick_enemy(&mut rng, &encounters, &enemies).unwrap();
let enemy = pick_enemy(&mut rng, &encounters, &enemies);
assert!(enemy.id == "kobold" || enemy.id == "rat");
}
}

View file

@ -55,8 +55,5 @@ 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()
}

View file

@ -1,4 +1,3 @@
use crate::constants;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -19,7 +18,8 @@ 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,10 +31,9 @@ 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> {
@ -42,31 +41,29 @@ pub fn find_spell(id: &str) -> Option<&'static SpellDef> {
}
pub fn spell_learn_cost(spell_level: i32) -> i32 {
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
match spell_level {
1 => 0,
2 => 1000,
3 => 2000,
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 {
let spell_class = class_for_spells(class);
let spell_level = match spell_class {
"conjurer" | "magician" | "sorcerer" | "wizard" => caster_spell_level(level),
"conjurer" | "magician" | "sorcerer" | "wizard" => (level / 3) + 1,
_ => 0,
};
spell_level * constants::SPELL_POINTS_PER_SPELL_LEVEL
spell_level * 10
}
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] {
@ -75,44 +72,3 @@ pub fn caster_classes() -> &'static [&'static str] {
use std::sync::LazyLock;
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]
);
}
}
#[test]
fn spells_json_parses() {
assert!(!load_spells().is_empty());
}
}

View file

@ -1,5 +1,5 @@
use rusqlite::Connection;
use sb_shared::{Character, CharacterStats, Class, Race, Role, User};
use sb_shared::{Character, CharacterStats, Class, Race, User, Role};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
@ -11,9 +11,7 @@ pub struct Database {
impl Database {
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?;
let db = Self {
conn: Mutex::new(conn),
};
let db = Self { conn: Mutex::new(conn) };
db.run_migrations()?;
Ok(db)
}
@ -61,7 +59,7 @@ impl Database {
quantity INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_characters_user ON characters(user_id);
",
"
)?;
Ok(())
}
@ -116,15 +114,10 @@ impl Database {
let inventory_json: String = row.get(22)?;
let known_spells_json: String = row.get(24)?;
let equipment_raw: HashMap<String, String> =
serde_json::from_str(&equipment_json).unwrap_or_default();
let equipment: HashMap<String, Option<String>> = equipment_raw
.into_iter()
.map(|(k, v)| (k, Some(v)))
.collect();
let equipment_raw: HashMap<String, String> = serde_json::from_str(&equipment_json).unwrap_or_default();
let equipment: HashMap<String, Option<String>> = equipment_raw.into_iter().map(|(k, v)| (k, Some(v))).collect();
let inventory: Vec<String> = serde_json::from_str(&inventory_json).unwrap_or_default();
let known_spells: Vec<String> =
serde_json::from_str(&known_spells_json).unwrap_or_default();
let known_spells: Vec<String> = serde_json::from_str(&known_spells_json).unwrap_or_default();
let race_str: String = row.get(3)?;
let class_str: String = row.get(4)?;
@ -184,32 +177,15 @@ impl Database {
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27)",
rusqlite::params![
char.id,
char.user_id,
char.name,
char.race.as_str(),
char.class.as_str(),
char.level,
char.xp,
char.gold,
char.hp,
char.max_hp,
char.energy,
char.max_energy,
char.nerve,
char.max_nerve,
char.spell_points,
char.max_spell_points,
char.stats.strength,
char.stats.intelligence,
char.stats.dexterity,
char.stats.constitution,
char.stats.luck,
equipment_json,
inventory_json,
char.active_song,
known_spells_json,
char.active_spell,
char.id, char.user_id, char.name,
char.race.as_str(), char.class.as_str(),
char.level, char.xp, char.gold,
char.hp, char.max_hp, char.energy, char.max_energy,
char.nerve, char.max_nerve, char.spell_points, char.max_spell_points,
char.stats.strength, char.stats.intelligence, char.stats.dexterity,
char.stats.constitution, char.stats.luck,
equipment_json, inventory_json,
char.active_song, known_spells_json, char.active_spell,
char.last_regen,
],
)?;
@ -241,9 +217,11 @@ impl Database {
pub fn next_character_id(&self) -> Result<u32, rusqlite::Error> {
let conn = self.conn.lock().unwrap();
let id: u32 = conn.query_row("SELECT COALESCE(MAX(id), 0) FROM characters", [], |row| {
row.get(0)
})?;
let id: u32 = conn.query_row(
"SELECT COALESCE(MAX(id), 0) FROM characters",
[],
|row| row.get(0),
)?;
Ok(id + 1)
}
}

View file

@ -131,12 +131,8 @@ pub struct Character {
pub max_spell_points: i32,
}
fn default_sp() -> i32 {
0
}
fn default_max_sp() -> i32 {
0
}
fn default_sp() -> i32 { 0 }
fn default_max_sp() -> i32 { 0 }
#[derive(Debug, Clone, Deserialize)]
pub struct CreateCharacter {

View file

@ -27,18 +27,6 @@ pub enum ItemType {
Material,
}
impl ItemType {
pub fn as_str(&self) -> &'static str {
match self {
ItemType::Weapon => "weapon",
ItemType::Armor => "armor",
ItemType::Accessory => "accessory",
ItemType::Consumable => "consumable",
ItemType::Material => "material",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AttackType {
@ -56,52 +44,3 @@ pub enum Quality {
Epic,
Legendary,
}
impl Quality {
pub fn as_str(&self) -> &'static str {
match self {
Quality::Common => "common",
Quality::Uncommon => "uncommon",
Quality::Rare => "rare",
Quality::Epic => "epic",
Quality::Legendary => "legendary",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn item_type_as_str_matches_serde_wire() {
for variant in [
ItemType::Weapon,
ItemType::Armor,
ItemType::Accessory,
ItemType::Consumable,
ItemType::Material,
] {
assert_eq!(
serde_json::to_value(&variant).unwrap(),
serde_json::json!(variant.as_str())
);
}
}
#[test]
fn quality_as_str_matches_serde_wire() {
for variant in [
Quality::Common,
Quality::Uncommon,
Quality::Rare,
Quality::Epic,
Quality::Legendary,
] {
assert_eq!(
serde_json::to_value(&variant).unwrap(),
serde_json::json!(variant.as_str())
);
}
}
}

View file

@ -1,5 +1,5 @@
use crate::character::Character;
use serde::{Deserialize, Serialize};
use crate::character::Character;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CritEntry {