Merge WI-2 Architecture Cleanup (PSP1, issue #2) into main

This commit is contained in:
Jan-Peter von Hunnius 2026-10-05 10:01:20 +02:00
commit ac776522f9
25 changed files with 2087 additions and 598 deletions

View file

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

View file

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

View file

@ -0,0 +1 @@

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,239 @@
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

@ -0,0 +1 @@

View file

@ -0,0 +1 @@

View file

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

View file

@ -1,6 +1,6 @@
use argon2::{ use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2, Argon2,
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
}; };
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
@ -17,5 +17,7 @@ pub fn verify_password(password: &str, hash: &str) -> bool {
Ok(h) => h, Ok(h) => h,
Err(_) => return false, 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 jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use chrono::{Utc, Duration};
fn jwt_secret() -> Vec<u8> { fn jwt_secret() -> Vec<u8> {
std::env::var("JWT_SECRET") std::env::var("JWT_SECRET")
@ -16,7 +16,11 @@ pub struct Claims {
pub exp: usize, 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() let exp = Utc::now()
.checked_add_signed(Duration::hours(24)) .checked_add_signed(Duration::hours(24))
.expect("valid timestamp") .expect("valid timestamp")
@ -39,9 +43,6 @@ pub fn create_token(user_id: u32, name: &str, role: &str) -> Result<String, json
pub fn verify_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> { pub fn verify_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let mut validation = Validation::default(); let mut validation = Validation::default();
validation.leeway = 60; validation.leeway = 60;
jsonwebtoken::decode::<Claims>( jsonwebtoken::decode::<Claims>(token, &DecodingKey::from_secret(&jwt_secret()), &validation)
token, .map(|d| d.claims)
&DecodingKey::from_secret(&jwt_secret()),
&validation,
).map(|d| d.claims)
} }

View file

@ -0,0 +1,473 @@
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,5 +1,7 @@
use sb_shared::{Character, CharacterStats, Class, Race}; use crate::constants;
use crate::error::DomainError;
use crate::spells; use crate::spells;
use sb_shared::{Character, CharacterStats, Class, Race};
pub struct TrainingGround { pub struct TrainingGround {
pub stat: &'static str, pub stat: &'static str,
@ -8,11 +10,31 @@ pub struct TrainingGround {
} }
pub const TRAINING_GROUNDS: &[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 {
TrainingGround { stat: "dexterity", name: "The Archery Range", description: "Sharpen reflexes with bow, blade, and balance drills." }, stat: "strength",
TrainingGround { stat: "constitution", name: "The Endurance Pits", description: "Push your body past its limits in grueling trials of resilience." }, name: "The Anvil",
TrainingGround { stat: "intelligence", name: "The Sage's Study", description: "Study ancient tomes and practice arcane disciplines." }, description: "Pound steel and lift stone under the guidance of master smiths.",
TrainingGround { stat: "luck", name: "Fortune's Shrine", description: "Consult oracles and tempt fate in games of chance." }, },
TrainingGround {
stat: "dexterity",
name: "The Archery Range",
description: "Sharpen reflexes with bow, blade, and balance drills.",
},
TrainingGround {
stat: "constitution",
name: "The Endurance Pits",
description: "Push your body past its limits in grueling trials of resilience.",
},
TrainingGround {
stat: "intelligence",
name: "The Sage's Study",
description: "Study ancient tomes and practice arcane disciplines.",
},
TrainingGround {
stat: "luck",
name: "Fortune's Shrine",
description: "Consult oracles and tempt fate in games of chance.",
},
]; ];
pub struct RaceInfo { pub struct RaceInfo {
@ -26,11 +48,51 @@ pub struct RaceInfo {
} }
pub const RACES: &[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 {
RaceInfo { race: Race::Elf, name: "Elf", str_mod: -2, int_mod: 4, dex_mod: 2, con_mod: -2, luck_mod: 0 }, race: Race::Human,
RaceInfo { race: Race::Dwarf, name: "Dwarf", str_mod: 2, int_mod: -2, dex_mod: -2, con_mod: 4, luck_mod: 0 }, name: "Human",
RaceInfo { race: Race::Hobbit, name: "Hobbit", str_mod: -4, int_mod: 0, dex_mod: 2, con_mod: -2, luck_mod: 4 }, str_mod: 0,
RaceInfo { race: Race::HalfElf, name: "Half-Elf", str_mod: 0, int_mod: 2, dex_mod: 2, con_mod: 0, luck_mod: 0 }, int_mod: 0,
dex_mod: 0,
con_mod: 0,
luck_mod: 0,
},
RaceInfo {
race: Race::Elf,
name: "Elf",
str_mod: -2,
int_mod: 4,
dex_mod: 2,
con_mod: -2,
luck_mod: 0,
},
RaceInfo {
race: Race::Dwarf,
name: "Dwarf",
str_mod: 2,
int_mod: -2,
dex_mod: -2,
con_mod: 4,
luck_mod: 0,
},
RaceInfo {
race: Race::Hobbit,
name: "Hobbit",
str_mod: -4,
int_mod: 0,
dex_mod: 2,
con_mod: -2,
luck_mod: 4,
},
RaceInfo {
race: Race::HalfElf,
name: "Half-Elf",
str_mod: 0,
int_mod: 2,
dex_mod: 2,
con_mod: 0,
luck_mod: 0,
},
]; ];
pub struct ClassInfo { pub struct ClassInfo {
@ -43,29 +105,102 @@ pub struct ClassInfo {
} }
pub const CLASSES: &[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 {
ClassInfo { class: Class::Paladin, name: "Paladin", primary_stat: "strength", secondary_stat: "luck", base_hp: 18, hp_per_con: 2.5 }, class: Class::Warrior,
ClassInfo { class: Class::Rogue, name: "Rogue", primary_stat: "dexterity", secondary_stat: "luck", base_hp: 14, hp_per_con: 2.0 }, name: "Warrior",
ClassInfo { class: Class::Bard, name: "Bard", primary_stat: "dexterity", secondary_stat: "intelligence", base_hp: 14, hp_per_con: 2.0 }, primary_stat: "strength",
ClassInfo { class: Class::Hunter, name: "Hunter", primary_stat: "dexterity", secondary_stat: "strength", base_hp: 16, hp_per_con: 2.5 }, secondary_stat: "constitution",
ClassInfo { class: Class::Monk, name: "Monk", primary_stat: "dexterity", secondary_stat: "constitution", base_hp: 15, hp_per_con: 2.5 }, base_hp: 20,
ClassInfo { class: Class::Mage, name: "Mage", primary_stat: "intelligence", secondary_stat: "dexterity", base_hp: 11, hp_per_con: 1.8 }, hp_per_con: 3.0,
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 {
ClassInfo { class: Class::Sorcerer, name: "Sorcerer", primary_stat: "intelligence", secondary_stat: "strength", base_hp: 12, hp_per_con: 2.0 }, 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 { pub fn stat_bonus(stat: i64) -> i64 {
(stat - 20) / 2 (stat - constants::STAT_BONUS_NEUTRAL) / constants::STAT_BONUS_STEP
} }
fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 { fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 {
let base: i64 = rng.random_range(6..=16); let base: i64 = rng.random_range(constants::STAT_ROLL_MIN..=constants::STAT_ROLL_MAX);
base + modifier base + modifier
} }
pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats { 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 { CharacterStats {
strength: roll_stat(rng, info.str_mod), strength: roll_stat(rng, info.str_mod),
intelligence: roll_stat(rng, info.int_mod), intelligence: roll_stat(rng, info.int_mod),
@ -76,35 +211,46 @@ pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats {
} }
pub fn calc_max_hp(class: &Class, stats: &CharacterStats) -> i32 { 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 (info.base_hp as f64 + stats.constitution as f64 * info.hp_per_con) as i32
} }
pub fn calc_max_energy(class: &Class) -> i32 { pub fn calc_max_energy(class: &Class) -> i32 {
let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class"); let info = CLASSES
30 + info.base_hp / 3 .iter()
.find(|c| &c.class == class)
.expect("invalid class");
constants::BASE_MAX_ENERGY + info.base_hp / constants::MAX_ENERGY_PER_BASE_HP_DIVISOR
} }
pub fn calc_max_nerve(class: &Class) -> i32 { pub fn calc_max_nerve(class: &Class) -> i32 {
let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class"); let info = CLASSES
100 + info.base_hp / 2 .iter()
.find(|c| &c.class == class)
.expect("invalid class");
constants::BASE_MAX_NERVE + info.base_hp / constants::MAX_NERVE_PER_BASE_HP_DIVISOR
} }
pub fn energy_regen_rate() -> i64 { pub fn energy_regen_rate() -> i64 {
180000 constants::ENERGY_REGEN_INTERVAL_MS
} }
pub fn nerve_regen_rate() -> i64 { pub fn nerve_regen_rate() -> i64 {
300000 constants::NERVE_REGEN_INTERVAL_MS
} }
pub fn hp_regen_rate() -> i64 { pub fn hp_regen_rate() -> i64 {
300000 constants::HP_REGEN_INTERVAL_MS
} }
pub fn apply_regen(char: &mut Character, now_ms: i64) { pub fn apply_regen(char: &mut Character, now_ms: i64) {
let elapsed = now_ms - char.last_regen; 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 energy_ticks = (elapsed / energy_regen_rate()) as i32;
let nerve_ticks = (elapsed / nerve_regen_rate()) as i32; let nerve_ticks = (elapsed / nerve_regen_rate()) as i32;
let hp_ticks = (elapsed / hp_regen_rate()) as i32; let hp_ticks = (elapsed / hp_regen_rate()) as i32;
@ -115,15 +261,16 @@ pub fn apply_regen(char: &mut Character, now_ms: i64) {
char.nerve = (char.nerve + nerve_ticks).min(char.max_nerve); char.nerve = (char.nerve + nerve_ticks).min(char.max_nerve);
} }
if hp_ticks > 0 && char.hp > 0 && char.hp < char.max_hp { if hp_ticks > 0 && char.hp > 0 && char.hp < char.max_hp {
let regen_per_tick = (char.max_hp as f64 * 0.05).ceil() as i32; let regen_per_tick =
(char.max_hp as f64 * constants::HP_REGEN_FRACTION_PER_TICK).ceil() as i32;
char.hp = (char.hp + regen_per_tick * hp_ticks).min(char.max_hp); char.hp = (char.hp + regen_per_tick * hp_ticks).min(char.max_hp);
} }
char.last_regen = now_ms; char.last_regen = now_ms;
} }
pub fn training_cost(stat: i64) -> i32 { pub fn training_cost(stat: i64) -> i32 {
let scaled = (stat as f64 / 10.0).floor() as i32; let scaled = (stat as f64 / constants::TRAINING_STAT_SCALER).floor() as i32;
(5.0_f64 * 1.3_f64.powi(scaled)) as i32 (constants::TRAINING_BASE_COST * constants::TRAINING_GROWTH_FACTOR.powi(scaled)) as i32
} }
pub fn create_character( pub fn create_character(
@ -140,7 +287,8 @@ pub fn create_character(
let max_nerve = calc_max_nerve(&class); let max_nerve = calc_max_nerve(&class);
let known_spells: Vec<String> = if spells::is_caster_class(&class_str) { 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) .filter(|s| s.spell_level <= 1)
.map(|s| s.id.clone()) .map(|s| s.id.clone())
.collect() .collect()
@ -154,7 +302,7 @@ pub fn create_character(
name, name,
race, race,
class, class,
level: 1, level: constants::STARTING_LEVEL,
xp: 0, xp: 0,
stats, stats,
hp: max_hp, hp: max_hp,
@ -163,7 +311,7 @@ pub fn create_character(
max_energy, max_energy,
nerve: max_nerve, nerve: max_nerve,
max_nerve, max_nerve,
gold: 50, gold: constants::STARTING_GOLD,
last_regen: 0, last_regen: 0,
equipment: [ equipment: [
("head".into(), None), ("head".into(), None),
@ -177,7 +325,9 @@ pub fn create_character(
("ring".into(), None), ("ring".into(), None),
("trinket".into(), None), ("trinket".into(), None),
("instrument".into(), None), ("instrument".into(), None),
].into_iter().collect(), ]
.into_iter()
.collect(),
inventory: Vec::new(), inventory: Vec::new(),
active_song: None, active_song: None,
known_spells, known_spells,
@ -191,26 +341,28 @@ pub const MAX_LEVEL: i32 = 100;
pub const XP_FACTOR: i64 = 18; pub const XP_FACTOR: i64 = 18;
pub fn xp_for_level(level: i32) -> i64 { pub fn xp_for_level(level: i32) -> i64 {
if level <= 1 { return 0; } if level <= 1 {
return 0;
}
let n = (level - 1) as f64; let n = (level - 1) as f64;
XP_FACTOR * (100.0 * n * n) as i64 XP_FACTOR * (constants::XP_CURVE_COEFFICIENT * n * n) as i64
} }
pub fn level_up_cost(level: i32) -> i32 { pub fn level_up_cost(level: i32) -> i32 {
500 * level constants::LEVEL_UP_GOLD_PER_LEVEL * level
} }
pub fn level_up(char: &mut Character) -> Result<i32, &'static str> { pub fn level_up(char: &mut Character) -> Result<i32, DomainError> {
if char.level >= MAX_LEVEL { if char.level >= MAX_LEVEL {
return Err("already at max level"); return Err(DomainError::AlreadyAtMaxLevel);
} }
let next_level = char.level + 1; let next_level = char.level + 1;
if char.xp < xp_for_level(next_level) { if char.xp < xp_for_level(next_level) {
return Err("not enough XP"); return Err(DomainError::NotEnoughXp);
} }
let cost = level_up_cost(next_level); let cost = level_up_cost(next_level);
if char.gold < cost { if char.gold < cost {
return Err("not enough gold"); return Err(DomainError::NotEnoughGold);
} }
char.gold -= cost; char.gold -= cost;
char.level = next_level; char.level = next_level;
@ -227,11 +379,38 @@ pub fn level_up(char: &mut Character) -> Result<i32, &'static str> {
} }
pub fn find_race_info(race: &Race) -> &'static RaceInfo { 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 { 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")
}
/// 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,
}
} }
#[cfg(test)] #[cfg(test)]
@ -242,15 +421,35 @@ mod tests {
fn base_char() -> Character { fn base_char() -> Character {
Character { Character {
id: 1, user_id: 1, name: "Test".into(), id: 1,
race: Race::Human, class: Class::Warrior, user_id: 1,
level: 1, xp: 0, name: "Test".into(),
stats: CharacterStats { strength: 20, intelligence: 20, dexterity: 20, constitution: 20, luck: 20 }, race: Race::Human,
hp: 80, max_hp: 80, energy: 36, max_energy: 36, class: Class::Warrior,
nerve: 110, max_nerve: 110, gold: 0, last_regen: 0, level: 1,
equipment: HashMap::new(), inventory: Vec::new(), xp: 0,
active_song: None, known_spells: Vec::new(), stats: CharacterStats {
active_spell: None, spell_points: 0, max_spell_points: 0, 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,
} }
} }
@ -306,7 +505,7 @@ mod tests {
c.level = MAX_LEVEL; c.level = MAX_LEVEL;
c.xp = i64::MAX; c.xp = i64::MAX;
c.gold = i32::MAX; c.gold = i32::MAX;
assert_eq!(level_up(&mut c), Err("already at max level")); assert_eq!(level_up(&mut c), Err(DomainError::AlreadyAtMaxLevel));
} }
#[test] #[test]
@ -335,7 +534,7 @@ mod tests {
let mut c = base_char(); let mut c = base_char();
c.xp = 100; c.xp = 100;
c.gold = 10000; c.gold = 10000;
assert_eq!(level_up(&mut c), Err("not enough XP")); assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughXp));
assert_eq!(c.level, 1); assert_eq!(c.level, 1);
assert_eq!(c.gold, 10000); assert_eq!(c.gold, 10000);
} }
@ -345,14 +544,26 @@ mod tests {
let mut c = base_char(); let mut c = base_char();
c.xp = 1800; c.xp = 1800;
c.gold = 0; c.gold = 0;
assert_eq!(level_up(&mut c), Err("not enough gold")); assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughGold));
assert_eq!(c.level, 1); assert_eq!(c.level, 1);
} }
#[test] #[test]
fn calc_max_hp_depends_on_class_and_con() { 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_high = CharacterStats {
let stats_low = CharacterStats { strength: 10, intelligence: 10, dexterity: 10, constitution: 10, luck: 10 }; 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_high = calc_max_hp(&Class::Warrior, &stats_high);
let hp_low = calc_max_hp(&Class::Warrior, &stats_low); let hp_low = calc_max_hp(&Class::Warrior, &stats_low);
assert!(hp_high > hp_low); assert!(hp_high > hp_low);
@ -396,7 +607,7 @@ mod tests {
#[test] #[test]
fn roll_stats_returns_all_five() { fn roll_stats_returns_all_five() {
use rand:: SeedableRng; use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(42); let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let stats = roll_stats(&mut rng, &Race::Human); let stats = roll_stats(&mut rng, &Race::Human);
assert!(stats.strength >= 6 && stats.strength <= 16); assert!(stats.strength >= 6 && stats.strength <= 16);
@ -408,7 +619,7 @@ mod tests {
#[test] #[test]
fn race_modifiers_apply() { fn race_modifiers_apply() {
use rand:: SeedableRng; use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(42); let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let human = roll_stats(&mut rng, &Race::Human); let human = roll_stats(&mut rng, &Race::Human);
let mut rng2 = rand::rngs::StdRng::seed_from_u64(42); let mut rng2 = rand::rngs::StdRng::seed_from_u64(42);
@ -441,4 +652,70 @@ mod tests {
assert_eq!(first.energy, second.energy); assert_eq!(first.energy, second.energy);
assert_eq!(first.nerve, second.nerve); 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,3 +1,4 @@
use crate::constants;
use rand::Rng; use rand::Rng;
use sb_shared::{AttackType, CombatLog, CombatRound, CritEntry, CritResult}; use sb_shared::{AttackType, CombatLog, CombatRound, CritEntry, CritResult};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -7,9 +8,8 @@ use std::sync::LazyLock;
const CRIT_TABLES_JSON: &str = const CRIT_TABLES_JSON: &str =
include_str!("../../../../skara-brae-common/data/crit-tables/combat.json"); include_str!("../../../../skara-brae-common/data/crit-tables/combat.json");
static CRIT_TABLES: LazyLock<HashMap<String, HashMap<String, CritEntry>>> = LazyLock::new(|| { static CRIT_TABLES: LazyLock<HashMap<String, HashMap<String, CritEntry>>> =
serde_json::from_str(CRIT_TABLES_JSON).expect("failed to parse crit tables") LazyLock::new(|| serde_json::from_str(CRIT_TABLES_JSON).expect("failed to parse crit tables"));
});
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CombatStats { pub struct CombatStats {
@ -33,7 +33,8 @@ impl CombatStats {
pub fn defensive_bonus(&self) -> i32 { pub fn defensive_bonus(&self) -> i32 {
let dex_bonus = super::character::stat_bonus(self.dexterity) as i32; let dex_bonus = super::character::stat_bonus(self.dexterity) as i32;
let con_bonus = (super::character::stat_bonus(self.constitution) as i32) / 2; let con_bonus = (super::character::stat_bonus(self.constitution) as i32)
/ constants::DEFENSIVE_CON_BONUS_DIVISOR;
dex_bonus + con_bonus + self.db_base dex_bonus + con_bonus + self.db_base
} }
} }
@ -53,21 +54,27 @@ pub fn crit_damage_cap(attacker_level: i32, defender_level: i32, defender_hp: i3
return None; return None;
} }
let cap_pct = match diff { let cap_pct = match diff {
1..=9 => return None, 1..=constants::CRIT_CAP_DISABLED_MAX_LEVEL_GAP => return None,
10..=19 => 0.60, constants::CRIT_CAP_TIER1_MIN_GAP..=constants::CRIT_CAP_TIER1_MAX_GAP => {
20..=49 => 0.40, constants::CRIT_CAP_TIER1_RATIO
50..=99 => 0.15, }
_ => 0.05, constants::CRIT_CAP_TIER2_MIN_GAP..=constants::CRIT_CAP_TIER2_MAX_GAP => {
constants::CRIT_CAP_TIER2_RATIO
}
constants::CRIT_CAP_TIER3_MIN_GAP..=constants::CRIT_CAP_TIER3_MAX_GAP => {
constants::CRIT_CAP_TIER3_RATIO
}
_ => constants::CRIT_CAP_TIER4_RATIO,
}; };
Some((defender_hp as f64 * cap_pct) as i32) Some((defender_hp as f64 * cap_pct) as i32)
} }
pub fn open_ended_roll(rng: &mut impl Rng) -> i32 { pub fn open_ended_roll(rng: &mut impl Rng) -> i32 {
let roll: i32 = rng.random_range(1..=100); let roll: i32 = rng.random_range(constants::ATTACK_ROLL_MIN..=constants::ATTACK_ROLL_MAX);
let mut total = roll; let mut total = roll;
if roll >= 96 { if roll >= constants::OPEN_END_HIGH_THRESHOLD {
total += open_ended_roll(rng); total += open_ended_roll(rng);
} else if roll <= 5 { } else if roll <= constants::OPEN_END_LOW_THRESHOLD {
total -= open_ended_roll(rng); total -= open_ended_roll(rng);
} }
total total
@ -93,11 +100,12 @@ pub fn resolve_attack(
&& let Some(crit_entry) = lookup_crit(&attacker.attack_type, &severity) && let Some(crit_entry) = lookup_crit(&attacker.attack_type, &severity)
{ {
let crit_damage = (damage as f64 * crit_entry.damage_multiplier) as i32; 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 =
crit_damage.min(cap) if let Some(cap) = crit_damage_cap(attacker.level, defender.level, defender.hp) {
} else { crit_damage.min(cap)
crit_damage } else {
}; crit_damage
};
damage = capped; damage = capped;
crit = Some(CritResult { crit = Some(CritResult {
severity: severity.clone(), severity: severity.clone(),
@ -120,13 +128,13 @@ pub fn resolve_attack(
} }
pub fn crit_severity(result: i32) -> Option<String> { pub fn crit_severity(result: i32) -> Option<String> {
if result >= 151 { if result >= constants::CRIT_SEVERITY_D_MIN {
Some("D".into()) Some("D".into())
} else if result >= 101 { } else if result >= constants::CRIT_SEVERITY_C_MIN {
Some("C".into()) Some("C".into())
} else if result >= 51 { } else if result >= constants::CRIT_SEVERITY_B_MIN {
Some("B".into()) Some("B".into())
} else if result >= 21 { } else if result >= constants::CRIT_SEVERITY_A_MIN {
Some("A".into()) Some("A".into())
} else { } else {
None None
@ -155,7 +163,9 @@ pub fn resolve_combat(
gold_max: i32, gold_max: i32,
rng: &mut impl Rng, rng: &mut impl Rng,
) -> CombatLog { ) -> 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( pub fn resolve_combat_with_song(
@ -167,7 +177,16 @@ pub fn resolve_combat_with_song(
song_effect: Option<&str>, song_effect: Option<&str>,
rng: &mut impl Rng, rng: &mut impl Rng,
) -> CombatLog { ) -> 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)] #[allow(clippy::too_many_arguments)]
@ -187,7 +206,11 @@ pub fn resolve_combat_full(
let mut rounds = Vec::new(); let mut rounds = Vec::new();
loop { 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 if let Some(sp) = spell
&& sp.combat_effect == "damage" && sp.combat_effect == "damage"
@ -211,7 +234,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); a_hp = (a_hp - round.damage).max(0);
round.defender_hp = a_hp; round.defender_hp = a_hp;
rounds.push(round); rounds.push(round);
@ -227,12 +254,14 @@ pub fn resolve_combat_full(
} }
if song_effect == Some("heal") && a_hp > 0 && a_hp < attacker.hp { if song_effect == Some("heal") && a_hp > 0 && a_hp < attacker.hp {
let heal_amt = (attacker.hp as f64 * 0.03).ceil() as i32; let heal_amt = (attacker.hp as f64 * constants::BARD_SONG_HEAL_FRACTION).ceil() as i32;
a_hp = (a_hp + heal_amt).min(attacker.hp); a_hp = (a_hp + heal_amt).min(attacker.hp);
} }
if let Some(sp) = spell 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); let heal_amt = rng.random_range(sp.heal_min..=sp.heal_max);
a_hp = (a_hp + heal_amt).min(attacker.hp); a_hp = (a_hp + heal_amt).min(attacker.hp);
@ -243,8 +272,12 @@ pub fn resolve_combat_full(
fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats { fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
let mut s = stats.clone(); let mut s = stats.clone();
match song_effect { match song_effect {
Some("accuracy") => { s.ob_base += 15; } Some("accuracy") => {
Some("damage") => { s.ob_base += 20; } s.ob_base += constants::SONG_ACCURACY_OB_BONUS;
}
Some("damage") => {
s.ob_base += constants::SONG_DAMAGE_OB_BONUS;
}
_ => {} _ => {}
} }
s s
@ -253,8 +286,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 { fn apply_song_to_defender(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
let mut s = stats.clone(); let mut s = stats.clone();
match song_effect { match song_effect {
Some("defense") | Some("magic_resist") => { s.db_base += 10; } Some("defense") | Some("magic_resist") => {
Some("weaken") => { s.ob_base = (s.ob_base as f64 * 0.7) as i32; } 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;
}
_ => {} _ => {}
} }
s s
@ -272,18 +309,30 @@ mod tests {
fn attacker() -> CombatStats { fn attacker() -> CombatStats {
CombatStats { CombatStats {
name: "Hero".into(), hp: 100, level: 1, name: "Hero".into(),
strength: 50, dexterity: 40, constitution: 40, luck: 20, hp: 100,
ob_base: 20, db_base: 10, level: 1,
strength: 50,
dexterity: 40,
constitution: 40,
luck: 20,
ob_base: 20,
db_base: 10,
attack_type: AttackType::Slash, attack_type: AttackType::Slash,
} }
} }
fn defender() -> CombatStats { fn defender() -> CombatStats {
CombatStats { CombatStats {
name: "Goblin".into(), hp: 50, level: 1, name: "Goblin".into(),
strength: 30, dexterity: 30, constitution: 30, luck: 10, hp: 50,
ob_base: 10, db_base: 5, level: 1,
strength: 30,
dexterity: 30,
constitution: 30,
luck: 10,
ob_base: 10,
db_base: 5,
attack_type: AttackType::Crush, attack_type: AttackType::Crush,
} }
} }
@ -294,7 +343,11 @@ mod tests {
let mut rng = rand::rngs::StdRng::seed_from_u64(42); let mut rng = rand::rngs::StdRng::seed_from_u64(42);
for _ in 0..1000 { for _ in 0..1000 {
let roll = open_ended_roll(&mut rng); 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,8 +417,16 @@ mod tests {
fn parse_attack_type_maps_known_types() { fn parse_attack_type_maps_known_types() {
assert!(matches!(parse_attack_type("slash"), AttackType::Slash)); assert!(matches!(parse_attack_type("slash"), AttackType::Slash));
assert!(matches!(parse_attack_type("crush"), AttackType::Crush)); 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("unarmed"), AttackType::Crush));
assert!(matches!(parse_attack_type("unknown"), AttackType::Crush)); assert!(matches!(parse_attack_type("unknown"), AttackType::Crush));
} }
#[test]
fn crit_tables_parse() {
assert!(!CRIT_TABLES.is_empty());
}
} }

View file

@ -0,0 +1,131 @@
//! Game-balance constants — the single audit home for tuning scalars.
//! Static data tables (`RACES`, `CLASSES`, `BARD_SONGS`, `TRAINING_GROUNDS`)
//! stay in their modules; only tuning scalars live here.
pub use crate::character::{MAX_LEVEL, XP_FACTOR};
/// d100 attack roll bounds (origin: combat.rs:66).
pub const ATTACK_ROLL_MIN: i32 = 1;
/// d100 attack roll bounds (origin: combat.rs:66).
pub const ATTACK_ROLL_MAX: i32 = 100;
/// Open-ended roll: re-roll upward at or above this value (origin: combat.rs:68).
pub const OPEN_END_HIGH_THRESHOLD: i32 = 96;
/// Open-ended roll: re-roll downward at or below this value (origin: combat.rs:70).
pub const OPEN_END_LOW_THRESHOLD: i32 = 5;
/// Crit severity A floor (origin: combat.rs:129).
pub const CRIT_SEVERITY_A_MIN: i32 = 21;
/// Crit severity B floor (origin: combat.rs:127).
pub const CRIT_SEVERITY_B_MIN: i32 = 51;
/// Crit severity C floor (origin: combat.rs:125).
pub const CRIT_SEVERITY_C_MIN: i32 = 101;
/// Crit severity D floor (origin: combat.rs:123).
pub const CRIT_SEVERITY_D_MIN: i32 = 151;
/// Level gaps up to this value apply no crit cap (origin: combat.rs:56).
pub const CRIT_CAP_DISABLED_MAX_LEVEL_GAP: i32 = 9;
/// Crit cap tier 1 level-gap range (origin: combat.rs:57).
pub const CRIT_CAP_TIER1_MIN_GAP: i32 = 10;
/// Crit cap tier 1 level-gap range (origin: combat.rs:57).
pub const CRIT_CAP_TIER1_MAX_GAP: i32 = 19;
/// Crit cap tier 2 level-gap range (origin: combat.rs:58).
pub const CRIT_CAP_TIER2_MIN_GAP: i32 = 20;
/// Crit cap tier 2 level-gap range (origin: combat.rs:58).
pub const CRIT_CAP_TIER2_MAX_GAP: i32 = 49;
/// Crit cap tier 3 level-gap range (origin: combat.rs:59).
pub const CRIT_CAP_TIER3_MIN_GAP: i32 = 50;
/// Crit cap tier 3 level-gap range (origin: combat.rs:59).
pub const CRIT_CAP_TIER3_MAX_GAP: i32 = 99;
/// Crit cap tier 4 applies from this gap upward (origin: combat.rs:60).
pub const CRIT_CAP_TIER4_MIN_GAP: i32 = 100;
/// Crit damage cap ratio for level gaps 10–19 (origin: combat.rs:57).
pub const CRIT_CAP_TIER1_RATIO: f64 = 0.60;
/// Crit damage cap ratio for level gaps 20–49 (origin: combat.rs:58).
pub const CRIT_CAP_TIER2_RATIO: f64 = 0.40;
/// Crit damage cap ratio for level gaps 50–99 (origin: combat.rs:59).
pub const CRIT_CAP_TIER3_RATIO: f64 = 0.15;
/// Crit damage cap ratio for level gaps 100 and above (origin: combat.rs:60).
pub const CRIT_CAP_TIER4_RATIO: f64 = 0.05;
/// Constitution contributes half its stat bonus to defense (origin: combat.rs:36).
pub const DEFENSIVE_CON_BONUS_DIVISOR: i32 = 2;
/// Accuracy song offensive-bonus bonus (origin: combat.rs:246).
pub const SONG_ACCURACY_OB_BONUS: i32 = 15;
/// Damage song offensive-bonus bonus (origin: combat.rs:247).
pub const SONG_DAMAGE_OB_BONUS: i32 = 20;
/// Defense song defensive-bonus bonus (origin: combat.rs:256).
pub const SONG_DEFENSE_DB_BONUS: i32 = 10;
/// Weaken song reduces the foe's offensive bonus to this fraction (origin: combat.rs:257).
pub const SONG_WEAKEN_OB_FACTOR: f64 = 0.7;
/// Bard heal song: fraction of max HP restored per round (origin: combat.rs:230).
pub const BARD_SONG_HEAL_FRACTION: f64 = 0.03;
/// Stat bonus curve: bonus is zero at this stat value (origin: character.rs:59).
pub const STAT_BONUS_NEUTRAL: i64 = 20;
/// Stat bonus curve: bonus step size per 2 stat points (origin: character.rs:59).
pub const STAT_BONUS_STEP: i64 = 2;
/// Character creation stat roll bounds (origin: character.rs:63).
pub const STAT_ROLL_MIN: i64 = 6;
/// Character creation stat roll bounds (origin: character.rs:63).
pub const STAT_ROLL_MAX: i64 = 16;
/// Base maximum energy before the class contribution (origin: character.rs:85).
pub const BASE_MAX_ENERGY: i32 = 30;
/// Energy gains one point per this many base HP (origin: character.rs:85).
pub const MAX_ENERGY_PER_BASE_HP_DIVISOR: i32 = 3;
/// Base maximum nerve before the class contribution (origin: character.rs:90).
pub const BASE_MAX_NERVE: i32 = 100;
/// Nerve gains one point per this many base HP (origin: character.rs:90).
pub const MAX_NERVE_PER_BASE_HP_DIVISOR: i32 = 2;
/// Energy regen tick interval in milliseconds (origin: character.rs:94).
pub const ENERGY_REGEN_INTERVAL_MS: i64 = 180_000;
/// Nerve regen tick interval in milliseconds (origin: character.rs:98).
pub const NERVE_REGEN_INTERVAL_MS: i64 = 300_000;
/// HP regen tick interval in milliseconds (origin: character.rs:102).
pub const HP_REGEN_INTERVAL_MS: i64 = 300_000;
/// HP restored per regen tick, as a fraction of max HP (origin: character.rs:118).
pub const HP_REGEN_FRACTION_PER_TICK: f64 = 0.05;
/// Training cost curve base cost (origin: character.rs:126).
pub const TRAINING_BASE_COST: f64 = 5.0;
/// Training cost curve growth factor (origin: character.rs:126).
pub const TRAINING_GROWTH_FACTOR: f64 = 1.3;
/// Training cost curve stat scaler (origin: character.rs:125).
pub const TRAINING_STAT_SCALER: f64 = 10.0;
/// Energy spent per training session (origin: services.rs:85, 100).
pub const TRAINING_ENERGY_COST: i32 = 1;
/// Level assigned at character creation (origin: character.rs:157).
pub const STARTING_LEVEL: i32 = 1;
/// Gold assigned at character creation (origin: character.rs:166).
pub const STARTING_GOLD: i32 = 50;
/// XP curve coefficient: 18 · 100 · (level−1)² (origin: character.rs:196).
pub const XP_CURVE_COEFFICIENT: f64 = 100.0;
/// Gold cost per target level for level-up (origin: character.rs:200).
pub const LEVEL_UP_GOLD_PER_LEVEL: i32 = 500;
/// Caster levels per spell level: (level / 3) + 1 curve (origin: spells.rs:59).
pub const CASTER_LEVELS_PER_SPELL_LEVEL: i32 = 3;
/// Spell points granted per caster spell level (origin: spells.rs:62).
pub const SPELL_POINTS_PER_SPELL_LEVEL: i32 = 10;
/// Spell learn cost table, indexed by spell level − 1 (origin: spells.rs:44–52).
pub const SPELL_LEARN_COST_BY_LEVEL: [i32; 7] = [0, 1000, 2000, 4000, 7000, 10000, 20000];
/// Learn cost reported for spell levels outside 1..=7 (origin: spells.rs:52).
pub const SPELL_LEARN_COST_UNKNOWN_LEVEL: i32 = 999_999;
/// Lowest learn-request spell level — request validation only (origin: routes/mod.rs:1146).
pub const MIN_SPELL_LEVEL: i32 = 1;
/// Highest learn-request spell level — request validation only (origin: routes/mod.rs:1146).
pub const MAX_SPELL_LEVEL: i32 = 7;

View file

@ -1,9 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const ENEMIES_JSON: &str = const ENEMIES_JSON: &str = include_str!("../../../../skara-brae-common/data/enemies.json");
include_str!("../../../../skara-brae-common/data/enemies.json"); const ZONES_JSON: &str = include_str!("../../../../skara-brae-common/data/adventures/zones.json");
const ZONES_JSON: &str =
include_str!("../../../../skara-brae-common/data/adventures/zones.json");
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnemyDef { pub struct EnemyDef {
@ -61,3 +59,18 @@ pub fn load_enemies() -> Vec<EnemyDef> {
pub fn load_zones() -> Vec<ZoneDef> { pub fn load_zones() -> Vec<ZoneDef> {
serde_json::from_str(ZONES_JSON).expect("failed to parse zones.json") 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

@ -0,0 +1,23 @@
#[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,9 +1,7 @@
use sb_shared::Item; use sb_shared::Item;
const WEAPONS_JSON: &str = const WEAPONS_JSON: &str = include_str!("../../../../skara-brae-common/data/items/weapons.json");
include_str!("../../../../skara-brae-common/data/items/weapons.json"); const ARMOR_JSON: &str = include_str!("../../../../skara-brae-common/data/items/armor.json");
const ARMOR_JSON: &str =
include_str!("../../../../skara-brae-common/data/items/armor.json");
const ACCESSORIES_JSON: &str = const ACCESSORIES_JSON: &str =
include_str!("../../../../skara-brae-common/data/items/accessories.json"); 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"); serde_json::from_str(WEAPONS_JSON).expect("failed to parse weapons.json");
items.extend(weapons); items.extend(weapons);
let armor: Vec<Item> = let armor: Vec<Item> = serde_json::from_str(ARMOR_JSON).expect("failed to parse armor.json");
serde_json::from_str(ARMOR_JSON).expect("failed to parse armor.json");
items.extend(armor); items.extend(armor);
let accessories: Vec<Item> = let accessories: Vec<Item> =
@ -32,3 +29,13 @@ pub fn item_price(item: &Item) -> i32 {
pub fn item_sell_price(item: &Item) -> i32 { pub fn item_sell_price(item: &Item) -> i32 {
item.value / 2 item.value / 2
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_items_json_parses() {
assert!(!load_all_items().is_empty());
}
}

View file

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

View file

@ -1,29 +1,43 @@
use sb_shared::{Character, Item, AttackType}; use crate::data::{EncounterEntry, EnemyDef};
use crate::data::{EnemyDef, EncounterEntry}; use crate::error::DomainError;
use rand::Rng; use rand::Rng;
use sb_shared::{AttackType, Character, Item};
pub fn pick_enemy( pub fn pick_enemy(
rng: &mut impl Rng, rng: &mut impl Rng,
encounters: &[EncounterEntry], encounters: &[EncounterEntry],
enemies: &[EnemyDef], enemies: &[EnemyDef],
) -> EnemyDef { ) -> Result<EnemyDef, DomainError> {
let total_weight: i32 = encounters.iter().map(|e| e.weight).sum(); 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 roll: i32 = rng.random_range(0..total_weight);
let mut remaining = roll; let mut remaining = roll;
for enc in encounters { for enc in encounters {
remaining -= enc.weight; remaining -= enc.weight;
if remaining < 0 { if remaining < 0 {
return enemies.iter().find(|e| e.id == enc.enemy_id) return enemies
.expect("encounter references valid enemy") .iter()
.clone(); .find(|e| e.id == enc.enemy_id)
.cloned()
.ok_or(DomainError::UnknownEncounterEnemy);
} }
} }
enemies.iter().find(|e| e.id == encounters.last().unwrap().enemy_id) let last = encounters
.expect("encounter references valid enemy") .last()
.clone() .ok_or(DomainError::UnknownEncounterEnemy)?;
enemies
.iter()
.find(|e| e.id == last.enemy_id)
.cloned()
.ok_or(DomainError::UnknownEncounterEnemy)
} }
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") if let Some(Some(weapon_id)) = equipment.get("main_hand")
&& let Some(weapon) = items.iter().find(|i| &i.id == weapon_id) && let Some(weapon) = items.iter().find(|i| &i.id == weapon_id)
&& let Some(ref at) = weapon.attack_type && let Some(ref at) = weapon.attack_type
@ -33,7 +47,10 @@ pub fn equipped_attack_type(equipment: &std::collections::HashMap<String, Option
AttackType::Crush 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; let mut total = 0i32;
for slot in &["main_hand", "off_hand"] { for slot in &["main_hand", "off_hand"] {
if let Some(Some(item_id)) = equipment.get(*slot) if let Some(Some(item_id)) = equipment.get(*slot)
@ -46,7 +63,10 @@ pub fn equipped_damage(equipment: &std::collections::HashMap<String, Option<Stri
total 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; let mut total = 0i32;
for (_, item_id) in equipment.iter() { for (_, item_id) in equipment.iter() {
if let Some(id) = item_id if let Some(id) = item_id
@ -59,9 +79,12 @@ pub fn total_defense(equipment: &std::collections::HashMap<String, Option<String
total total
} }
pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<(), &'static str> { 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) let inv_pos = char
.ok_or("item not in inventory")?; .inventory
.iter()
.position(|id| id == item_id)
.ok_or(DomainError::ItemNotInInventory)?;
char.inventory.remove(inv_pos); char.inventory.remove(inv_pos);
let slot = item.slot.clone(); let slot = item.slot.clone();
@ -72,18 +95,20 @@ pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<()
Ok(()) Ok(())
} }
pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, &'static str> { pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, DomainError> {
let prev = char.equipment.insert(slot.to_string(), None) let prev = char
.equipment
.insert(slot.to_string(), None)
.flatten() .flatten()
.ok_or("nothing equipped in slot")?; .ok_or(DomainError::NothingEquippedInSlot)?;
char.inventory.push(prev.clone()); char.inventory.push(prev.clone());
Ok(prev) Ok(prev)
} }
pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str> { pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, DomainError> {
if char.energy < 1 { if char.energy < crate::constants::TRAINING_ENERGY_COST {
return Err("not enough energy"); return Err(DomainError::NotEnoughEnergy);
} }
let current = match stat { let current = match stat {
"strength" => char.stats.strength, "strength" => char.stats.strength,
@ -91,13 +116,13 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str>
"dexterity" => char.stats.dexterity, "dexterity" => char.stats.dexterity,
"constitution" => char.stats.constitution, "constitution" => char.stats.constitution,
"luck" => char.stats.luck, "luck" => char.stats.luck,
_ => return Err("invalid stat"), _ => return Err(DomainError::InvalidStat),
}; };
let cost = super::character::training_cost(current); let cost = super::character::training_cost(current);
if char.gold < cost { if char.gold < cost {
return Err("not enough gold"); return Err(DomainError::NotEnoughGold);
} }
char.energy -= 1; char.energy -= crate::constants::TRAINING_ENERGY_COST;
char.gold -= cost; char.gold -= cost;
match stat { match stat {
"strength" => char.stats.strength += 1, "strength" => char.stats.strength += 1,
@ -117,7 +142,7 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str>
"dexterity" => char.stats.dexterity, "dexterity" => char.stats.dexterity,
"constitution" => char.stats.constitution, "constitution" => char.stats.constitution,
"luck" => char.stats.luck, "luck" => char.stats.luck,
_ => return Err("invalid stat"), _ => return Err(DomainError::InvalidStat),
}; };
Ok(new_val) Ok(new_val)
} }
@ -125,42 +150,70 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str>
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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; use std::collections::HashMap;
fn base_char() -> Character { fn base_char() -> Character {
Character { Character {
id: 1, user_id: 1, name: "Test".into(), id: 1,
race: Race::Human, class: Class::Warrior, user_id: 1,
level: 1, xp: 0, name: "Test".into(),
stats: CharacterStats { strength: 20, intelligence: 20, dexterity: 20, constitution: 20, luck: 20 }, race: Race::Human,
hp: 80, max_hp: 80, energy: 10, max_energy: 36, class: Class::Warrior,
nerve: 110, max_nerve: 110, gold: 1000, last_regen: 0, level: 1,
equipment: HashMap::new(), inventory: Vec::new(), xp: 0,
active_song: None, known_spells: Vec::new(), stats: CharacterStats {
active_spell: None, spell_points: 0, max_spell_points: 0, 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 { fn sword() -> Item {
Item { Item {
id: "sword1".into(), name: "Iron Sword".into(), id: "sword1".into(),
r#type: ItemType::Weapon, quality: Quality::Common, name: "Iron Sword".into(),
r#type: ItemType::Weapon,
quality: Quality::Common,
slot: "main_hand".into(), slot: "main_hand".into(),
stats: HashMap::from([("damage".into(), 15i64)]), stats: HashMap::from([("damage".into(), 15i64)]),
description: String::new(), attack_type: Some(AttackType::Slash), description: String::new(),
value: 100, always_in_shop: true, attack_type: Some(AttackType::Slash),
value: 100,
always_in_shop: true,
} }
} }
fn shield() -> Item { fn shield() -> Item {
Item { Item {
id: "shield1".into(), name: "Wood Shield".into(), id: "shield1".into(),
r#type: ItemType::Armor, quality: Quality::Common, name: "Wood Shield".into(),
r#type: ItemType::Armor,
quality: Quality::Common,
slot: "off_hand".into(), slot: "off_hand".into(),
stats: HashMap::from([("defense".into(), 10i64)]), stats: HashMap::from([("defense".into(), 10i64)]),
description: String::new(), attack_type: None, description: String::new(),
value: 50, always_in_shop: true, attack_type: None,
value: 50,
always_in_shop: true,
} }
} }
@ -179,20 +232,29 @@ mod tests {
fn train_stat_fails_without_energy() { fn train_stat_fails_without_energy() {
let mut c = base_char(); let mut c = base_char();
c.energy = 0; c.energy = 0;
assert_eq!(train_stat(&mut c, "strength"), Err("not enough energy")); assert_eq!(
train_stat(&mut c, "strength"),
Err(DomainError::NotEnoughEnergy)
);
} }
#[test] #[test]
fn train_stat_fails_without_gold() { fn train_stat_fails_without_gold() {
let mut c = base_char(); let mut c = base_char();
c.gold = 0; c.gold = 0;
assert_eq!(train_stat(&mut c, "strength"), Err("not enough gold")); assert_eq!(
train_stat(&mut c, "strength"),
Err(DomainError::NotEnoughGold)
);
} }
#[test] #[test]
fn train_stat_fails_invalid_stat() { fn train_stat_fails_invalid_stat() {
let mut c = base_char(); let mut c = base_char();
assert_eq!(train_stat(&mut c, "charisma"), Err("invalid stat")); assert_eq!(
train_stat(&mut c, "charisma"),
Err(DomainError::InvalidStat)
);
} }
#[test] #[test]
@ -211,16 +273,23 @@ mod tests {
c.inventory.push("sword1".into()); c.inventory.push("sword1".into());
equip_item(&mut c, &sword(), "sword1").unwrap(); equip_item(&mut c, &sword(), "sword1").unwrap();
assert!(!c.inventory.contains(&"sword1".to_string())); 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] #[test]
fn equip_item_swaps_previous() { fn equip_item_swaps_previous() {
let mut c = base_char(); 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()); c.inventory.push("sword1".into());
equip_item(&mut c, &sword(), "sword1").unwrap(); 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(&"old_sword".to_string()));
assert!(!c.inventory.contains(&"sword1".to_string())); assert!(!c.inventory.contains(&"sword1".to_string()));
} }
@ -228,13 +297,17 @@ mod tests {
#[test] #[test]
fn equip_item_fails_if_not_in_inventory() { fn equip_item_fails_if_not_in_inventory() {
let mut c = base_char(); 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(DomainError::ItemNotInInventory)
);
} }
#[test] #[test]
fn unequip_item_moves_to_inventory() { fn unequip_item_moves_to_inventory() {
let mut c = base_char(); 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(); let result = unequip_item(&mut c, "main_hand").unwrap();
assert_eq!(result, "sword1"); assert_eq!(result, "sword1");
assert!(c.inventory.contains(&"sword1".to_string())); assert!(c.inventory.contains(&"sword1".to_string()));
@ -244,7 +317,10 @@ mod tests {
#[test] #[test]
fn unequip_item_fails_empty_slot() { fn unequip_item_fails_empty_slot() {
let mut c = base_char(); 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(DomainError::NothingEquippedInSlot)
);
} }
#[test] #[test]
@ -270,49 +346,84 @@ mod tests {
#[test] #[test]
fn equipped_attack_type_returns_weapon_type() { fn equipped_attack_type_returns_weapon_type() {
let items = vec![sword()]; let items = vec![sword()];
let eq: HashMap<String, Option<String>> = HashMap::from([ let eq: HashMap<String, Option<String>> =
("main_hand".into(), Some("sword1".into())), HashMap::from([("main_hand".into(), Some("sword1".into()))]);
]); assert!(matches!(
assert!(matches!(equipped_attack_type(&eq, &items), AttackType::Slash)); equipped_attack_type(&eq, &items),
AttackType::Slash
));
} }
#[test] #[test]
fn equipped_attack_type_defaults_to_crush() { fn equipped_attack_type_defaults_to_crush() {
let items: Vec<Item> = vec![]; let items: Vec<Item> = vec![];
let eq: HashMap<String, Option<String>> = HashMap::new(); 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] #[test]
fn pick_enemy_respects_weights() { fn pick_enemy_respects_weights() {
use rand::SeedableRng; use rand::SeedableRng;
let encounters = vec![ let encounters = vec![
crate::data::EncounterEntry { enemy_id: "kobold".into(), weight: 3 }, crate::data::EncounterEntry {
crate::data::EncounterEntry { enemy_id: "rat".into(), weight: 1 }, enemy_id: "kobold".into(),
weight: 3,
},
crate::data::EncounterEntry {
enemy_id: "rat".into(),
weight: 1,
},
]; ];
let enemies = vec![ let enemies = vec![
crate::data::EnemyDef { crate::data::EnemyDef {
id: "kobold".into(), name: "Kobold".into(), id: "kobold".into(),
hp: 20, level: 1, damage: 5, defense: 2, name: "Kobold".into(),
hp: 20,
level: 1,
damage: 5,
defense: 2,
attack_type: "crush".into(), attack_type: "crush".into(),
xp: 10, gold_min: 1, gold_max: 5, xp: 10,
stats: crate::data::EnemyStats { strength: 15, intelligence: 10, dexterity: 15, constitution: 15, luck: 5 }, gold_min: 1,
gold_max: 5,
stats: crate::data::EnemyStats {
strength: 15,
intelligence: 10,
dexterity: 15,
constitution: 15,
luck: 5,
},
description: String::new(), description: String::new(),
loot: vec![], loot: vec![],
}, },
crate::data::EnemyDef { crate::data::EnemyDef {
id: "rat".into(), name: "Giant Rat".into(), id: "rat".into(),
hp: 10, level: 1, damage: 3, defense: 1, name: "Giant Rat".into(),
hp: 10,
level: 1,
damage: 3,
defense: 1,
attack_type: "puncture".into(), attack_type: "puncture".into(),
xp: 5, gold_min: 0, gold_max: 2, xp: 5,
stats: crate::data::EnemyStats { strength: 5, intelligence: 5, dexterity: 15, constitution: 5, luck: 5 }, gold_min: 0,
gold_max: 2,
stats: crate::data::EnemyStats {
strength: 5,
intelligence: 5,
dexterity: 15,
constitution: 5,
luck: 5,
},
description: String::new(), description: String::new(),
loot: vec![], loot: vec![],
}, },
]; ];
let mut rng = rand::rngs::StdRng::seed_from_u64(42); let mut rng = rand::rngs::StdRng::seed_from_u64(42);
for _ in 0..100 { for _ in 0..100 {
let enemy = pick_enemy(&mut rng, &encounters, &enemies); let enemy = pick_enemy(&mut rng, &encounters, &enemies).unwrap();
assert!(enemy.id == "kobold" || enemy.id == "rat"); assert!(enemy.id == "kobold" || enemy.id == "rat");
} }
} }

View file

@ -55,5 +55,8 @@ pub const BARD_SONGS: &[SongDef] = &[
]; ];
pub fn available_songs(level: i32) -> Vec<&'static 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,3 +1,4 @@
use crate::constants;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -18,8 +19,7 @@ pub struct SpellDef {
pub heal_max: i32, pub heal_max: i32,
} }
const SPELLS_JSON: &str = const SPELLS_JSON: &str = include_str!("../../../../skara-brae-common/data/spells/spells.json");
include_str!("../../../../skara-brae-common/data/spells/spells.json");
pub fn load_spells() -> Vec<SpellDef> { pub fn load_spells() -> Vec<SpellDef> {
serde_json::from_str(SPELLS_JSON).expect("failed to parse spells.json") serde_json::from_str(SPELLS_JSON).expect("failed to parse spells.json")
@ -31,9 +31,10 @@ pub fn class_for_spells(class: &str) -> &str {
pub fn spells_for_class(class: &str, spell_level: i32) -> Vec<&'static SpellDef> { pub fn spells_for_class(class: &str, spell_level: i32) -> Vec<&'static SpellDef> {
let spell_class = class_for_spells(class); let spell_class = class_for_spells(class);
ALL_SPELLS.iter().filter(|s| { ALL_SPELLS
s.caster_class == spell_class && s.spell_level <= spell_level .iter()
}).collect() .filter(|s| s.caster_class == spell_class && s.spell_level <= spell_level)
.collect()
} }
pub fn find_spell(id: &str) -> Option<&'static SpellDef> { pub fn find_spell(id: &str) -> Option<&'static SpellDef> {
@ -41,29 +42,31 @@ pub fn find_spell(id: &str) -> Option<&'static SpellDef> {
} }
pub fn spell_learn_cost(spell_level: i32) -> i32 { pub fn spell_learn_cost(spell_level: i32) -> i32 {
match spell_level { if (constants::MIN_SPELL_LEVEL..=constants::MAX_SPELL_LEVEL).contains(&spell_level) {
1 => 0, constants::SPELL_LEARN_COST_BY_LEVEL[(spell_level - 1) as usize]
2 => 1000, } else {
3 => 2000, constants::SPELL_LEARN_COST_UNKNOWN_LEVEL
4 => 4000,
5 => 7000,
6 => 10000,
7 => 20000,
_ => 999999,
} }
} }
pub fn caster_spell_level(character_level: i32) -> i32 {
(character_level / constants::CASTER_LEVELS_PER_SPELL_LEVEL) + 1
}
pub fn max_spell_points(class: &str, level: i32) -> i32 { pub fn max_spell_points(class: &str, level: i32) -> i32 {
let spell_class = class_for_spells(class); let spell_class = class_for_spells(class);
let spell_level = match spell_class { let spell_level = match spell_class {
"conjurer" | "magician" | "sorcerer" | "wizard" => (level / 3) + 1, "conjurer" | "magician" | "sorcerer" | "wizard" => caster_spell_level(level),
_ => 0, _ => 0,
}; };
spell_level * 10 spell_level * constants::SPELL_POINTS_PER_SPELL_LEVEL
} }
pub fn is_caster_class(class: &str) -> bool { pub fn is_caster_class(class: &str) -> bool {
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] { pub fn caster_classes() -> &'static [&'static str] {
@ -72,3 +75,44 @@ pub fn caster_classes() -> &'static [&'static str] {
use std::sync::LazyLock; use std::sync::LazyLock;
static ALL_SPELLS: LazyLock<Vec<SpellDef>> = LazyLock::new(load_spells); static ALL_SPELLS: LazyLock<Vec<SpellDef>> = LazyLock::new(load_spells);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn caster_spell_level_spot_values() {
assert_eq!(caster_spell_level(1), 1);
assert_eq!(caster_spell_level(2), 1);
assert_eq!(caster_spell_level(3), 2);
assert_eq!(caster_spell_level(6), 3);
assert_eq!(caster_spell_level(100), 34);
}
#[test]
fn spell_learn_cost_guard_spot_checks() {
assert_eq!(
spell_learn_cost(0),
constants::SPELL_LEARN_COST_UNKNOWN_LEVEL
);
assert_eq!(
spell_learn_cost(-1),
constants::SPELL_LEARN_COST_UNKNOWN_LEVEL
);
assert_eq!(
spell_learn_cost(8),
constants::SPELL_LEARN_COST_UNKNOWN_LEVEL
);
for level in constants::MIN_SPELL_LEVEL..=constants::MAX_SPELL_LEVEL {
assert_eq!(
spell_learn_cost(level),
constants::SPELL_LEARN_COST_BY_LEVEL[(level - 1) as usize]
);
}
}
#[test]
fn spells_json_parses() {
assert!(!load_spells().is_empty());
}
}

View file

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

View file

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

View file

@ -27,6 +27,18 @@ pub enum ItemType {
Material, 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum AttackType { pub enum AttackType {
@ -44,3 +56,52 @@ pub enum Quality {
Epic, Epic,
Legendary, 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 serde::{Deserialize, Serialize};
use crate::character::Character; use crate::character::Character;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CritEntry { pub struct CritEntry {