Implement auth, persistence, typed responses, and injectable RNG

Auth: JWT tokens (sb-auth) with argon2 password hashing, login/register
endpoints, Bearer token middleware protecting all routes except auth.
Authorization checks ensure users can only access their own characters.

Persistence: SQLite via rusqlite (sb-db) with write-through on all
mutations. Optional via SB_DB_PATH env var; falls back to in-memory.

Typed responses: ~20 response DTOs in sb-shared replacing ad-hoc JSON.

Combat: Injectable RNG via &mut impl Rng for deterministic tests.

Clean Architecture: Combat types and response DTOs in sb-shared; domain
services in sb-core; auth in sb-auth; persistence in sb-db.
This commit is contained in:
Jan-Peter von Hunnius 2026-08-09 16:51:52 +02:00
parent 7a75a523f0
commit 6410e0a86d
39 changed files with 3450 additions and 48 deletions

View file

@ -10,6 +10,7 @@ tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rand = "0.9"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
sb-core = { path = "../sb-core" }

View file

@ -1,12 +1,11 @@
#![allow(dead_code)]
use axum::routing::get;
use axum::Router;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
pub mod routes;
pub mod ws;
pub mod middleware;
#[tokio::main]
async fn main() {
@ -17,9 +16,9 @@ async fn main() {
))
.init();
let app = Router::new()
.route("/health", get(health))
.merge(routes::build());
let state = routes::seed_users();
let app = routes::build(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
.await
@ -28,7 +27,3 @@ async fn main() {
tracing::info!("listening on {:?}", listener.local_addr());
axum::serve(listener, app).await.expect("server error");
}
async fn health() -> &'static str {
"ok"
}

View file

@ -0,0 +1,53 @@
use axum::{
extract::Request,
http::{header, StatusCode},
middleware::Next,
response::Response,
};
use sb_auth::jwt::verify_token;
#[derive(Clone)]
pub struct AuthUser {
pub user_id: u32,
pub name: String,
pub role: String,
}
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);
if token.is_empty() {
return Err(StatusCode::UNAUTHORIZED);
}
let claims = verify_token(token).map_err(|_| StatusCode::UNAUTHORIZED)?;
request.extensions_mut().insert(AuthUser {
user_id: claims.sub,
name: claims.name,
role: claims.role,
});
Ok(next.run(request).await)
}
impl std::fmt::Debug for AuthUser {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthUser")
.field("user_id", &self.user_id)
.field("name", &self.name)
.field("role", &self.role)
.finish()
}
}

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ edition = "2024"
[dependencies]
jsonwebtoken = "9"
argon2 = "0.5"
getrandom = "0.3"
serde = { version = "1", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
sb-shared = { path = "../sb-shared" }

View file

@ -0,0 +1,21 @@
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let mut salt_bytes = [0u8; 16];
getrandom::fill(&mut salt_bytes).expect("failed to generate salt");
let salt = SaltString::encode_b64(&salt_bytes).expect("valid salt");
let argon2 = Argon2::default();
let hash = argon2.hash_password(password.as_bytes(), &salt)?;
Ok(hash.to_string())
}
pub fn verify_password(password: &str, hash: &str) -> bool {
let parsed = match PasswordHash::new(hash) {
Ok(h) => h,
Err(_) => return false,
};
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
}

View file

@ -0,0 +1,47 @@
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use chrono::{Utc, Duration};
fn jwt_secret() -> Vec<u8> {
std::env::var("JWT_SECRET")
.map(|s| s.into_bytes())
.unwrap_or_else(|_| b"skara_brae_dev_secret_change_in_prod".to_vec())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
pub sub: u32,
pub name: String,
pub role: String,
pub exp: usize,
}
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")
.timestamp() as usize;
let claims = Claims {
sub: user_id,
name: name.to_string(),
role: role.to_string(),
exp,
};
jsonwebtoken::encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(&jwt_secret()),
)
}
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)
}

View file

@ -1,3 +1,2 @@
pub mod jwt;
pub mod hash;
pub mod session;
pub mod jwt;

View file

@ -1 +0,0 @@

View file

@ -1 +0,0 @@

View file

@ -0,0 +1,415 @@
use sb_shared::{Character, CharacterStats, Class, Race};
use crate::spells;
pub struct TrainingGround {
pub stat: &'static str,
pub name: &'static str,
pub description: &'static str,
}
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." },
];
pub struct RaceInfo {
pub race: Race,
pub name: &'static str,
pub str_mod: i64,
pub int_mod: i64,
pub dex_mod: i64,
pub con_mod: i64,
pub luck_mod: i64,
}
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 },
];
pub struct ClassInfo {
pub class: Class,
pub name: &'static str,
pub primary_stat: &'static str,
pub secondary_stat: &'static str,
pub base_hp: i32,
pub hp_per_con: f64,
}
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 },
];
pub fn stat_bonus(stat: i64) -> i64 {
(stat - 20) / 2
}
fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 {
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");
CharacterStats {
strength: roll_stat(rng, info.str_mod),
intelligence: roll_stat(rng, info.int_mod),
dexterity: roll_stat(rng, info.dex_mod),
constitution: roll_stat(rng, info.con_mod),
luck: roll_stat(rng, info.luck_mod),
}
}
pub fn calc_max_hp(class: &Class, stats: &CharacterStats) -> i32 {
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");
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");
100 + info.base_hp / 2
}
pub fn energy_regen_rate() -> i64 {
180000
}
pub fn nerve_regen_rate() -> i64 {
300000
}
pub fn hp_regen_rate() -> i64 {
300000
}
pub fn apply_regen(char: &mut Character, now_ms: i64) {
let elapsed = now_ms - char.last_regen;
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;
if energy_ticks > 0 {
char.energy = (char.energy + energy_ticks).min(char.max_energy);
}
if nerve_ticks > 0 {
char.nerve = (char.nerve + nerve_ticks).min(char.max_nerve);
}
if hp_ticks > 0 && char.hp > 0 && char.hp < char.max_hp {
let regen_per_tick = (char.max_hp as f64 * 0.05).ceil() as i32;
char.hp = (char.hp + regen_per_tick * hp_ticks).min(char.max_hp);
}
char.last_regen = now_ms;
}
pub fn training_cost(stat: i64) -> i32 {
let scaled = (stat as f64 / 10.0).floor() as i32;
(5.0_f64 * 1.3_f64.powi(scaled)) as i32
}
pub fn create_character(user_id: u32, name: String, race: Race, class: Class) -> Character {
let mut rng = rand::rng();
let class_str = class.as_str().to_string();
let stats = roll_stats(&mut rng, &race);
let max_hp = calc_max_hp(&class, &stats);
let max_energy = calc_max_energy(&class);
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()
.filter(|s| s.spell_level <= 1)
.map(|s| s.id.clone())
.collect()
} else {
Vec::new()
};
Character {
id: 0,
user_id,
name,
race,
class,
level: 1,
xp: 0,
stats,
hp: max_hp,
max_hp,
energy: max_energy,
max_energy,
nerve: max_nerve,
max_nerve,
gold: 50,
last_regen: 0,
equipment: [
("head".into(), None),
("body".into(), None),
("off_hand".into(), None),
("main_hand".into(), None),
("hands".into(), None),
("feet".into(), None),
("back".into(), None),
("neck".into(), None),
("ring".into(), None),
("trinket".into(), None),
("instrument".into(), None),
].into_iter().collect(),
inventory: Vec::new(),
active_song: None,
known_spells,
active_spell: None,
spell_points: spells::max_spell_points(&class_str, 1),
max_spell_points: spells::max_spell_points(&class_str, 1),
}
}
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; }
let n = (level - 1) as f64;
XP_FACTOR * (100.0 * n * n) as i64
}
pub fn level_up_cost(level: i32) -> i32 {
500 * level
}
pub fn level_up(char: &mut Character) -> Result<i32, &'static str> {
if char.level >= MAX_LEVEL {
return Err("already at max level");
}
let next_level = char.level + 1;
if char.xp < xp_for_level(next_level) {
return Err("not enough XP");
}
let cost = level_up_cost(next_level);
if char.gold < cost {
return Err("not enough gold");
}
char.gold -= cost;
char.level = next_level;
let class_str = char.class.as_str().to_string();
char.max_hp = calc_max_hp(&char.class, &char.stats);
char.hp = char.max_hp;
char.max_energy = calc_max_energy(&char.class);
char.energy = char.max_energy;
char.max_nerve = calc_max_nerve(&char.class);
char.nerve = char.max_nerve;
char.max_spell_points = spells::max_spell_points(&class_str, char.level);
char.spell_points = char.max_spell_points;
Ok(char.level)
}
pub fn find_race_info(race: &Race) -> &'static RaceInfo {
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")
}
#[cfg(test)]
mod tests {
use super::*;
use sb_shared::{Character, CharacterStats, Class, Race};
use std::collections::HashMap;
fn base_char() -> Character {
Character {
id: 1, user_id: 1, name: "Test".into(),
race: Race::Human, class: Class::Warrior,
level: 1, xp: 0,
stats: CharacterStats { strength: 20, intelligence: 20, dexterity: 20, constitution: 20, luck: 20 },
hp: 80, max_hp: 80, energy: 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,
}
}
#[test]
fn stat_bonus_at_20_is_zero() {
assert_eq!(stat_bonus(20), 0);
}
#[test]
fn stat_bonus_below_20_is_negative() {
assert_eq!(stat_bonus(10), -5);
assert_eq!(stat_bonus(1), -9);
}
#[test]
fn stat_bonus_above_20_is_positive() {
assert_eq!(stat_bonus(30), 5);
assert_eq!(stat_bonus(100), 40);
}
#[test]
fn stat_bonus_handles_large_values() {
assert_eq!(stat_bonus(1_000_000_000), 499_999_990);
}
#[test]
fn xp_for_level_1_is_zero() {
assert_eq!(xp_for_level(1), 0);
}
#[test]
fn xp_for_level_2_is_1800() {
assert_eq!(xp_for_level(2), 1800);
}
#[test]
fn xp_for_level_scales_subexponentially() {
assert!(xp_for_level(3) > xp_for_level(2));
assert!(xp_for_level(5) > xp_for_level(4));
assert!(xp_for_level(10) > xp_for_level(5));
assert!(xp_for_level(50) > xp_for_level(25));
}
#[test]
fn xp_for_level_does_not_overflow() {
assert!(xp_for_level(50) > 0);
assert!(xp_for_level(50) < i64::MAX / 2);
}
#[test]
fn level_up_fails_at_max_level() {
let mut c = base_char();
c.level = MAX_LEVEL;
c.xp = i64::MAX;
c.gold = i32::MAX;
assert_eq!(level_up(&mut c), Err("already at max level"));
}
#[test]
fn level_up_cost_scales() {
assert_eq!(level_up_cost(2), 1000);
assert_eq!(level_up_cost(3), 1500);
assert_eq!(level_up_cost(10), 5000);
}
#[test]
fn level_up_succeeds_with_enough_xp_and_gold() {
let mut c = base_char();
c.xp = 1800;
c.gold = 1000;
let result = level_up(&mut c);
assert_eq!(result, Ok(2));
assert_eq!(c.level, 2);
assert_eq!(c.gold, 0);
assert_eq!(c.hp, c.max_hp);
assert_eq!(c.energy, c.max_energy);
assert_eq!(c.nerve, c.max_nerve);
}
#[test]
fn level_up_fails_without_enough_xp() {
let mut c = base_char();
c.xp = 100;
c.gold = 10000;
assert_eq!(level_up(&mut c), Err("not enough XP"));
assert_eq!(c.level, 1);
assert_eq!(c.gold, 10000);
}
#[test]
fn level_up_fails_without_enough_gold() {
let mut c = base_char();
c.xp = 1800;
c.gold = 0;
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 hp_high = calc_max_hp(&Class::Warrior, &stats_high);
let hp_low = calc_max_hp(&Class::Warrior, &stats_low);
assert!(hp_high > hp_low);
}
#[test]
fn calc_max_energy_and_nerve_vary_by_class() {
let warrior_energy = calc_max_energy(&Class::Warrior);
let mage_energy = calc_max_energy(&Class::Mage);
assert!(warrior_energy > mage_energy);
}
#[test]
fn training_cost_scales_with_stat() {
assert!(training_cost(20) < training_cost(30));
assert!(training_cost(30) < training_cost(40));
}
#[test]
fn apply_regen_restores_resources() {
let mut c = base_char();
c.hp = 40;
c.energy = 10;
c.nerve = 50;
c.last_regen = 0;
apply_regen(&mut c, 1_800_000);
assert!(c.energy > 10);
assert!(c.nerve > 50);
assert!(c.hp > 40);
}
#[test]
fn apply_regen_no_change_if_no_time_passed() {
let mut c = base_char();
c.hp = 40;
c.last_regen = 1_800_000;
let before = c.hp;
apply_regen(&mut c, 1_800_000);
assert_eq!(c.hp, before);
}
#[test]
fn roll_stats_returns_all_five() {
use rand:: SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let stats = roll_stats(&mut rng, &Race::Human);
assert!(stats.strength >= 6 && stats.strength <= 16);
assert!(stats.intelligence >= 6 && stats.intelligence <= 16);
assert!(stats.dexterity >= 6 && stats.dexterity <= 16);
assert!(stats.constitution >= 6 && stats.constitution <= 16);
assert!(stats.luck >= 6 && stats.luck <= 16);
}
#[test]
fn race_modifiers_apply() {
use rand:: SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let human = roll_stats(&mut rng, &Race::Human);
let mut rng2 = rand::rngs::StdRng::seed_from_u64(42);
let dwarf = roll_stats(&mut rng2, &Race::Dwarf);
assert!(dwarf.constitution > human.constitution);
assert!(dwarf.strength > human.strength);
assert!(dwarf.intelligence < human.intelligence);
}
}

View file

@ -1 +0,0 @@

View file

@ -0,0 +1,371 @@
use rand::Rng;
use sb_shared::{AttackType, CombatLog, CombatRound, CritEntry, CritResult};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
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")
});
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CombatStats {
pub name: String,
pub hp: i32,
pub level: i32,
pub strength: i64,
pub dexterity: i64,
pub constitution: i64,
pub luck: i64,
pub ob_base: i32,
pub db_base: i32,
pub attack_type: AttackType,
}
impl CombatStats {
pub fn offensive_bonus(&self) -> i32 {
let str_bonus = super::character::stat_bonus(self.strength) as i32;
str_bonus + self.ob_base
}
pub fn defensive_bonus(&self) -> i32 {
let dex_bonus = super::character::stat_bonus(self.dexterity) as i32;
let con_bonus = (super::character::stat_bonus(self.constitution) as i32) / 2;
dex_bonus + con_bonus + self.db_base
}
}
pub fn parse_attack_type(s: &str) -> AttackType {
match s {
"slash" => AttackType::Slash,
"crush" => AttackType::Crush,
"puncture" => AttackType::Puncture,
_ => AttackType::Crush,
}
}
pub fn crit_damage_cap(attacker_level: i32, defender_level: i32, defender_hp: i32) -> Option<i32> {
let diff = defender_level - attacker_level;
if diff <= 0 {
return None;
}
let cap_pct = match diff {
1..=9 => return None,
10..=19 => 0.60,
20..=49 => 0.40,
50..=99 => 0.15,
_ => 0.05,
};
Some((defender_hp as f64 * cap_pct) as i32)
}
pub fn open_ended_roll(rng: &mut impl Rng) -> i32 {
let roll: i32 = rng.random_range(1..=100);
let mut total = roll;
if roll >= 96 {
total += open_ended_roll(rng);
} else if roll <= 5 {
total -= open_ended_roll(rng);
}
total
}
pub fn resolve_attack(
rng: &mut impl Rng,
attacker: &CombatStats,
defender: &CombatStats,
) -> CombatRound {
let raw_roll = open_ended_roll(rng);
let ob = attacker.offensive_bonus();
let db = defender.defensive_bonus();
let attack_total = raw_roll + ob;
let result = attack_total - db;
let mut damage = 0;
let mut crit = None;
if result > 0 {
damage = result;
if let Some(severity) = crit_severity(result)
&& let Some(crit_entry) = lookup_crit(&attacker.attack_type, &severity)
{
let crit_damage = (damage as f64 * crit_entry.damage_multiplier) as i32;
let capped = if let Some(cap) = crit_damage_cap(attacker.level, defender.level, defender.hp) {
crit_damage.min(cap)
} else {
crit_damage
};
damage = capped;
crit = Some(CritResult {
severity: severity.clone(),
crit: crit_entry,
});
}
}
CombatRound {
attacker: attacker.name.clone(),
defender: defender.name.clone(),
raw_roll,
ob,
db,
result,
damage,
crit,
defender_hp: 0,
}
}
pub fn crit_severity(result: i32) -> Option<String> {
if result >= 151 {
Some("D".into())
} else if result >= 101 {
Some("C".into())
} else if result >= 51 {
Some("B".into())
} else if result >= 21 {
Some("A".into())
} else {
None
}
}
pub fn lookup_crit(attack_type: &AttackType, severity: &str) -> Option<CritEntry> {
let type_key = format!("{:?}", attack_type).to_lowercase();
CRIT_TABLES.get(&type_key)?.get(severity).cloned()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpellEffect {
pub combat_effect: String,
pub damage_min: i32,
pub damage_max: i32,
pub heal_min: i32,
pub heal_max: i32,
}
pub fn resolve_combat(
attacker: CombatStats,
defender: CombatStats,
xp_reward: i32,
gold_min: i32,
gold_max: i32,
rng: &mut impl Rng,
) -> CombatLog {
resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, None, None, rng)
}
pub fn resolve_combat_with_song(
attacker: CombatStats,
defender: CombatStats,
xp_reward: i32,
gold_min: i32,
gold_max: i32,
song_effect: Option<&str>,
rng: &mut impl Rng,
) -> CombatLog {
resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, song_effect, None, rng)
}
#[allow(clippy::too_many_arguments)]
pub fn resolve_combat_full(
attacker: CombatStats,
defender: CombatStats,
xp_reward: i32,
gold_min: i32,
gold_max: i32,
song_effect: Option<&str>,
spell: Option<&SpellEffect>,
rng: &mut impl Rng,
) -> CombatLog {
let mut rng = rng;
let mut a_hp = attacker.hp;
let mut d_hp = defender.hp;
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));
if let Some(sp) = spell
&& sp.combat_effect == "damage"
{
let spell_dmg = rng.random_range(sp.damage_min..=sp.damage_max);
round.damage += spell_dmg;
}
d_hp = (d_hp - round.damage).max(0);
round.defender_hp = d_hp;
rounds.push(round);
if d_hp <= 0 {
let gold = rng.random_range(gold_min..=gold_max);
return CombatLog {
rounds,
winner: attacker.name.clone(),
loser: defender.name.clone(),
xp_gained: xp_reward,
gold_gained: gold,
};
}
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);
if a_hp <= 0 {
return CombatLog {
rounds,
winner: defender.name.clone(),
loser: attacker.name.clone(),
xp_gained: 0,
gold_gained: 0,
};
}
if song_effect == Some("heal") && a_hp > 0 && a_hp < attacker.hp {
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
{
let heal_amt = rng.random_range(sp.heal_min..=sp.heal_max);
a_hp = (a_hp + heal_amt).min(attacker.hp);
}
}
}
fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
let mut s = stats.clone();
match song_effect {
Some("accuracy") => { s.ob_base += 15; }
Some("damage") => { s.ob_base += 20; }
_ => {}
}
s
}
fn apply_song_to_defender(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
let mut s = stats.clone();
match song_effect {
Some("defense") | Some("magic_resist") => { s.db_base += 10; }
Some("weaken") => { s.ob_base = (s.ob_base as f64 * 0.7) as i32; }
_ => {}
}
s
}
fn make_temp_stats(original: &CombatStats, hp: i32) -> CombatStats {
let mut s = original.clone();
s.hp = hp;
s
}
#[cfg(test)]
mod tests {
use super::*;
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,
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,
attack_type: AttackType::Crush,
}
}
#[test]
fn open_ended_roll_stays_in_range() {
use rand::SeedableRng;
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);
}
}
#[test]
fn offensive_bonus_includes_stat_and_base() {
let a = attacker();
let ob = a.offensive_bonus();
assert!(ob > a.ob_base);
}
#[test]
fn defensive_bonus_includes_stat_and_base() {
let d = defender();
let db = d.defensive_bonus();
assert!(db > d.db_base);
}
#[test]
fn crit_damage_cap_equal_level_is_none() {
assert_eq!(crit_damage_cap(5, 5, 100), None);
assert_eq!(crit_damage_cap(1, 1, 100), None);
}
#[test]
fn crit_damage_cap_higher_defender_reduces() {
let cap = crit_damage_cap(1, 15, 100).unwrap();
assert!(cap < 100);
}
#[test]
fn crit_damage_cap_scaling() {
let cap_10 = crit_damage_cap(1, 11, 100).unwrap();
let cap_50 = crit_damage_cap(1, 51, 100).unwrap();
let cap_100 = crit_damage_cap(1, 101, 100).unwrap();
assert!(cap_100 < cap_50);
assert!(cap_50 < cap_10);
}
#[test]
fn resolve_attack_can_hit_or_miss() {
use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let a = attacker();
let d = defender();
for _ in 0..100 {
let round = resolve_attack(&mut rng, &a, &d);
assert_eq!(round.attacker, "Hero");
assert_eq!(round.defender, "Goblin");
assert!(round.defender_hp <= d.hp);
assert!(round.defender_hp >= 0);
}
}
#[test]
fn resolve_combat_produces_winner() {
use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let a = attacker();
let d = defender();
let log = resolve_combat(a.clone(), d.clone(), 10, 5, 10, &mut rng);
assert!(!log.rounds.is_empty());
assert!(log.winner == a.name || log.winner == d.name);
assert!(log.loser != log.winner);
}
#[test]
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("unarmed"), AttackType::Crush));
assert!(matches!(parse_attack_type("unknown"), AttackType::Crush));
}
}

View file

@ -1 +0,0 @@

View file

@ -1 +0,0 @@

View file

@ -0,0 +1,63 @@
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");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnemyDef {
pub id: String,
pub name: String,
pub level: i32,
pub stats: EnemyStats,
pub hp: i32,
pub attack_type: String,
pub damage: i32,
pub defense: i32,
pub xp: i32,
pub gold_min: i32,
pub gold_max: i32,
pub loot: Vec<LootDrop>,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LootDrop {
pub item_id: String,
pub chance: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnemyStats {
pub strength: i64,
pub intelligence: i64,
pub dexterity: i64,
pub constitution: i64,
pub luck: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncounterEntry {
pub enemy_id: String,
pub weight: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneDef {
pub id: String,
pub name: String,
pub min_level: i32,
pub max_level: i32,
pub nerve_cost: i32,
pub description: String,
pub encounters: Vec<EncounterEntry>,
}
pub fn load_enemies() -> Vec<EnemyDef> {
serde_json::from_str(ENEMIES_JSON).expect("failed to parse enemies.json")
}
pub fn load_zones() -> Vec<ZoneDef> {
serde_json::from_str(ZONES_JSON).expect("failed to parse zones.json")
}

View file

@ -1 +0,0 @@

View file

@ -1 +0,0 @@

View file

@ -1,13 +1,34 @@
pub mod mod;
use sb_shared::Item;
use sb_common::PROJECT_NAME;
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");
const WEAPONS: &str = include_str!("../../../../skara-brae-common/data/items/weapons.json");
pub fn load_all_items() -> Vec<Item> {
let mut items = Vec::new();
pub fn game_name() -> &'static str {
PROJECT_NAME
let weapons: 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");
items.extend(armor);
let accessories: Vec<Item> =
serde_json::from_str(ACCESSORIES_JSON).expect("failed to parse accessories.json");
items.extend(accessories);
items
}
pub fn load_weapons() -> Vec<serde_json::Value> {
serde_json::from_str(WEAPONS).expect("failed to parse weapons data")
pub fn item_price(item: &Item) -> i32 {
item.value
}
pub fn item_sell_price(item: &Item) -> i32 {
item.value / 2
}

View file

@ -2,13 +2,8 @@
pub mod character;
pub mod combat;
pub mod adventure;
pub mod economy;
pub mod guild;
pub mod crafting;
pub mod pets;
pub mod blessings;
pub mod workers;
pub mod slayer;
pub mod world;
pub mod rules;
pub mod data;
pub mod items;
pub mod services;
pub mod songs;
pub mod spells;

View file

@ -1 +0,0 @@

View file

@ -1 +0,0 @@

View file

@ -0,0 +1,319 @@
use sb_shared::{Character, Item, AttackType};
use crate::data::{EnemyDef, EncounterEntry};
use rand::Rng;
pub fn pick_enemy(
rng: &mut impl Rng,
encounters: &[EncounterEntry],
enemies: &[EnemyDef],
) -> EnemyDef {
let total_weight: i32 = encounters.iter().map(|e| e.weight).sum();
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)
.expect("encounter references valid enemy")
.clone();
}
}
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 {
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
{
return at.clone();
}
AttackType::Crush
}
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)
&& let Some(item) = items.iter().find(|i| &i.id == item_id)
&& let Some(&dmg) = item.stats.get("damage")
{
total += dmg as i32;
}
}
total
}
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
&& let Some(item) = items.iter().find(|i| &i.id == id)
&& let Some(&def) = item.stats.get("defense")
{
total += def as i32;
}
}
total
}
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();
if let Some(Some(prev)) = char.equipment.insert(slot, Some(item_id.to_string())) {
char.inventory.push(prev);
}
Ok(())
}
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("nothing equipped in slot")?;
char.inventory.push(prev.clone());
Ok(prev)
}
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,
"intelligence" => char.stats.intelligence,
"dexterity" => char.stats.dexterity,
"constitution" => char.stats.constitution,
"luck" => char.stats.luck,
_ => return Err("invalid stat"),
};
let cost = super::character::training_cost(current);
if char.gold < cost {
return Err("not enough gold");
}
char.energy -= 1;
char.gold -= cost;
match stat {
"strength" => char.stats.strength += 1,
"intelligence" => char.stats.intelligence += 1,
"dexterity" => char.stats.dexterity += 1,
"constitution" => {
char.stats.constitution += 1;
char.max_hp = super::character::calc_max_hp(&char.class, &char.stats);
char.hp = char.hp.min(char.max_hp);
}
"luck" => char.stats.luck += 1,
_ => {}
}
let new_val = match stat {
"strength" => char.stats.strength,
"intelligence" => char.stats.intelligence,
"dexterity" => char.stats.dexterity,
"constitution" => char.stats.constitution,
"luck" => char.stats.luck,
_ => return Err("invalid stat"),
};
Ok(new_val)
}
#[cfg(test)]
mod tests {
use super::*;
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,
}
}
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 shield() -> Item {
Item {
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,
}
}
#[test]
fn train_stat_increases_stat() {
let mut c = base_char();
let cost = super::super::character::training_cost(c.stats.strength);
c.gold = cost;
let result = train_stat(&mut c, "strength");
assert_eq!(result, Ok(21));
assert_eq!(c.stats.strength, 21);
assert_eq!(c.energy, 9);
}
#[test]
fn train_stat_fails_without_energy() {
let mut c = base_char();
c.energy = 0;
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("not enough gold"));
}
#[test]
fn train_stat_fails_invalid_stat() {
let mut c = base_char();
assert_eq!(train_stat(&mut c, "charisma"), Err("invalid stat"));
}
#[test]
fn train_constitution_recals_hp() {
let mut c = base_char();
let old_max = c.max_hp;
let cost = super::super::character::training_cost(c.stats.constitution);
c.gold = cost;
train_stat(&mut c, "constitution").unwrap();
assert!(c.max_hp > old_max);
}
#[test]
fn equip_item_moves_from_inventory_to_slot() {
let mut c = base_char();
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())));
}
#[test]
fn equip_item_swaps_previous() {
let mut c = base_char();
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!(c.inventory.contains(&"old_sword".to_string()));
assert!(!c.inventory.contains(&"sword1".to_string()));
}
#[test]
fn equip_item_fails_if_not_in_inventory() {
let mut c = base_char();
assert_eq!(equip_item(&mut c, &sword(), "sword1"), Err("item not in inventory"));
}
#[test]
fn unequip_item_moves_to_inventory() {
let mut c = base_char();
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()));
assert_eq!(c.equipment.get("main_hand").cloned(), Some(None));
}
#[test]
fn unequip_item_fails_empty_slot() {
let mut c = base_char();
assert_eq!(unequip_item(&mut c, "main_hand"), Err("nothing equipped in slot"));
}
#[test]
fn equipped_damage_sums_weapon_slots() {
let items = vec![sword(), shield()];
let eq: HashMap<String, Option<String>> = HashMap::from([
("main_hand".into(), Some("sword1".into())),
("off_hand".into(), Some("shield1".into())),
]);
assert_eq!(equipped_damage(&eq, &items), 15);
}
#[test]
fn total_defense_sums_all_slots() {
let items = vec![sword(), shield()];
let eq: HashMap<String, Option<String>> = HashMap::from([
("main_hand".into(), Some("sword1".into())),
("off_hand".into(), Some("shield1".into())),
]);
assert_eq!(total_defense(&eq, &items), 10);
}
#[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));
}
#[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));
}
#[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 },
];
let enemies = vec![
crate::data::EnemyDef {
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 },
description: String::new(),
loot: vec![],
},
crate::data::EnemyDef {
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 },
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);
assert!(enemy.id == "kobold" || enemy.id == "rat");
}
}
}

View file

@ -1 +0,0 @@

View file

@ -0,0 +1,59 @@
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct SongDef {
pub id: &'static str,
pub name: &'static str,
pub description: &'static str,
pub combat_effect: &'static str,
pub required_level: i32,
}
pub const BARD_SONGS: &[SongDef] = &[
SongDef {
id: "seekers_ballad",
name: "The Seeker's Ballad",
description: "Produces light while exploring and increases the party's chance to hit in combat.",
combat_effect: "accuracy",
required_level: 1,
},
SongDef {
id: "travellers_tune",
name: "The Traveller's Tune",
description: "Makes the party more dexterous and agile, harder to hit.",
combat_effect: "defense",
required_level: 1,
},
SongDef {
id: "badhr_kilnfest",
name: "Badh'r Kilnfest",
description: "An ancient Elven melody that heals wounds during combat and while traveling.",
combat_effect: "heal",
required_level: 1,
},
SongDef {
id: "waylands_watch",
name: "Wayland's Watch",
description: "Soothes foes, reducing the damage they deal in combat.",
combat_effect: "weaken",
required_level: 2,
},
SongDef {
id: "falkentyne_fury",
name: "Falkentyne's Fury",
description: "Drives the party into a berserker rage, increasing melee damage.",
combat_effect: "damage",
required_level: 3,
},
SongDef {
id: "lucklaran",
name: "Lucklaran",
description: "Sets up a partial anti-magic field, increasing protection against spells.",
combat_effect: "magic_resist",
required_level: 4,
},
];
pub fn available_songs(level: i32) -> Vec<&'static SongDef> {
BARD_SONGS.iter().filter(|s| s.required_level <= level).collect()
}

View file

@ -0,0 +1,74 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpellDef {
pub id: String,
pub code: String,
pub name: String,
pub caster_class: String,
pub spell_level: i32,
pub spell_points: i32,
pub range: String,
pub duration: String,
pub description: String,
pub combat_effect: String,
pub combat_damage_min: i32,
pub combat_damage_max: i32,
pub heal_min: i32,
pub heal_max: i32,
}
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")
}
pub fn class_for_spells(class: &str) -> &str {
if class == "mage" { "magician" } else { class }
}
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()
}
pub fn find_spell(id: &str) -> Option<&'static SpellDef> {
ALL_SPELLS.iter().find(|s| s.id == id)
}
pub fn spell_learn_cost(spell_level: i32) -> i32 {
match spell_level {
1 => 0,
2 => 1000,
3 => 2000,
4 => 4000,
5 => 7000,
6 => 10000,
7 => 20000,
_ => 999999,
}
}
pub fn max_spell_points(class: &str, level: i32) -> i32 {
let spell_class = class_for_spells(class);
let spell_level = match spell_class {
"conjurer" | "magician" | "sorcerer" | "wizard" => (level / 3) + 1,
_ => 0,
};
spell_level * 10
}
pub fn is_caster_class(class: &str) -> bool {
matches!(class_for_spells(class), "conjurer" | "magician" | "sorcerer" | "wizard")
}
pub fn caster_classes() -> &'static [&'static str] {
&["conjurer", "magician", "sorcerer", "wizard"]
}
use std::sync::LazyLock;
static ALL_SPELLS: LazyLock<Vec<SpellDef>> = LazyLock::new(load_spells);

View file

@ -1 +0,0 @@

View file

@ -1 +0,0 @@

View file

@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid", "migrate"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sb-shared = { path = "../sb-shared" }

View file

@ -1,4 +1,227 @@
#![allow(dead_code)]
use rusqlite::Connection;
use sb_shared::{Character, CharacterStats, Class, Race, User, Role};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
pub mod models;
pub mod queries;
pub struct Database {
conn: Mutex<Connection>,
}
impl Database {
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?;
let db = Self { conn: Mutex::new(conn) };
db.run_migrations()?;
Ok(db)
}
fn run_migrations(&self) -> Result<(), rusqlite::Error> {
let conn = self.conn.lock().unwrap();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
role TEXT NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS characters (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
race TEXT NOT NULL,
class TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 1,
xp INTEGER NOT NULL DEFAULT 0,
gold INTEGER NOT NULL DEFAULT 0,
hp INTEGER NOT NULL DEFAULT 0,
max_hp INTEGER NOT NULL DEFAULT 0,
energy INTEGER NOT NULL DEFAULT 0,
max_energy INTEGER NOT NULL DEFAULT 0,
nerve INTEGER NOT NULL DEFAULT 0,
max_nerve INTEGER NOT NULL DEFAULT 0,
spell_points INTEGER NOT NULL DEFAULT 0,
max_spell_points INTEGER NOT NULL DEFAULT 0,
str_stat INTEGER NOT NULL DEFAULT 10,
int_stat INTEGER NOT NULL DEFAULT 10,
dex_stat INTEGER NOT NULL DEFAULT 10,
con_stat INTEGER NOT NULL DEFAULT 10,
luck_stat INTEGER NOT NULL DEFAULT 10,
equipment TEXT NOT NULL DEFAULT '{}',
inventory TEXT NOT NULL DEFAULT '[]',
active_song TEXT,
known_spells TEXT NOT NULL DEFAULT '[]',
active_spell TEXT,
last_regen INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS shop_stock (
item_id TEXT PRIMARY KEY,
quantity INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_characters_user ON characters(user_id);
"
)?;
Ok(())
}
pub fn load_users(&self) -> Result<Vec<User>, rusqlite::Error> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, name, role, password_hash FROM users")?;
let rows = stmt.query_map([], |row| {
let role_str: String = row.get(2)?;
let role = match role_str.as_str() {
"admin" => Role::Admin,
_ => Role::Player,
};
Ok(User {
id: row.get::<_, u32>(0)?,
name: row.get(1)?,
role,
password_hash: row.get(3)?,
})
})?;
let mut users = Vec::new();
for row in rows {
users.push(row?);
}
Ok(users)
}
pub fn save_user(&self, user: &User) -> Result<(), rusqlite::Error> {
let conn = self.conn.lock().unwrap();
let role_str = match &user.role {
Role::Admin => "admin",
Role::Player => "player",
};
conn.execute(
"INSERT OR REPLACE INTO users (id, name, role, password_hash) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![user.id, user.name, role_str, user.password_hash],
)?;
Ok(())
}
pub fn load_characters(&self) -> Result<Vec<Character>, rusqlite::Error> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, user_id, name, race, class, level, xp, gold, hp, max_hp, energy, max_energy,
nerve, max_nerve, spell_points, max_spell_points,
str_stat, int_stat, dex_stat, con_stat, luck_stat,
equipment, inventory, active_song, known_spells, active_spell, last_regen
FROM characters"
)?;
let rows = stmt.query_map([], |row| {
let equipment_json: String = row.get(21)?;
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 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 race_str: String = row.get(3)?;
let class_str: String = row.get(4)?;
let race = Race::from_str(&race_str);
let class = Class::from_str(&class_str);
Ok(Character {
id: row.get::<_, u32>(0)?,
user_id: row.get::<_, u32>(1)?,
name: row.get(2)?,
race,
class,
stats: CharacterStats {
strength: row.get(16)?,
intelligence: row.get(17)?,
dexterity: row.get(18)?,
constitution: row.get(19)?,
luck: row.get(20)?,
},
level: row.get::<_, i32>(5)?,
xp: row.get::<_, i64>(6)?,
gold: row.get::<_, i32>(7)?,
hp: row.get::<_, i32>(8)?,
max_hp: row.get::<_, i32>(9)?,
energy: row.get::<_, i32>(10)?,
max_energy: row.get::<_, i32>(11)?,
nerve: row.get::<_, i32>(12)?,
max_nerve: row.get::<_, i32>(13)?,
spell_points: row.get::<_, i32>(14)?,
max_spell_points: row.get::<_, i32>(15)?,
equipment,
inventory,
active_song: row.get(23)?,
known_spells,
active_spell: row.get(25)?,
last_regen: row.get(26)?,
})
})?;
let mut characters = Vec::new();
for row in rows {
characters.push(row?);
}
Ok(characters)
}
pub fn save_character(&self, char: &Character) -> Result<(), rusqlite::Error> {
let conn = self.conn.lock().unwrap();
let equipment_json = serde_json::to_string(&char.equipment).unwrap_or_default();
let inventory_json = serde_json::to_string(&char.inventory).unwrap_or_default();
let known_spells_json = serde_json::to_string(&char.known_spells).unwrap_or_default();
conn.execute(
"INSERT OR REPLACE INTO characters (id, user_id, name, race, class, level, xp, gold,
hp, max_hp, energy, max_energy, nerve, max_nerve, spell_points, max_spell_points,
str_stat, int_stat, dex_stat, con_stat, luck_stat,
equipment, inventory, active_song, known_spells, active_spell, last_regen)
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.last_regen,
],
)?;
Ok(())
}
pub fn load_shop_stock(&self) -> Result<HashMap<String, u32>, rusqlite::Error> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT item_id, quantity FROM shop_stock")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
})?;
let mut stock = HashMap::new();
for row in rows {
let (id, qty) = row?;
stock.insert(id, qty);
}
Ok(stock)
}
pub fn save_shop_stock(&self, item_id: &str, quantity: u32) -> Result<(), rusqlite::Error> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO shop_stock (item_id, quantity) VALUES (?1, ?2)",
rusqlite::params![item_id, quantity],
)?;
Ok(())
}
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),
)?;
Ok(id + 1)
}
}

View file

@ -0,0 +1,142 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CharacterStats {
pub strength: i64,
pub intelligence: i64,
pub dexterity: i64,
pub constitution: i64,
pub luck: i64,
}
impl CharacterStats {
pub fn total(&self) -> i64 {
self.strength + self.intelligence + self.dexterity + self.constitution + self.luck
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Race {
Human,
Elf,
Dwarf,
Hobbit,
HalfElf,
}
impl Race {
pub fn as_str(&self) -> &'static str {
match self {
Race::Human => "human",
Race::Elf => "elf",
Race::Dwarf => "dwarf",
Race::Hobbit => "hobbit",
Race::HalfElf => "half_elf",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s {
"human" => Race::Human,
"elf" => Race::Elf,
"dwarf" => Race::Dwarf,
"hobbit" => Race::Hobbit,
"half_elf" => Race::HalfElf,
_ => Race::Human,
}
}
}
impl Class {
pub fn as_str(&self) -> &'static str {
match self {
Class::Warrior => "warrior",
Class::Paladin => "paladin",
Class::Rogue => "rogue",
Class::Bard => "bard",
Class::Hunter => "hunter",
Class::Monk => "monk",
Class::Mage => "mage",
Class::Wizard => "wizard",
Class::Conjurer => "conjurer",
Class::Sorcerer => "sorcerer",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s {
"warrior" => Class::Warrior,
"paladin" => Class::Paladin,
"rogue" => Class::Rogue,
"bard" => Class::Bard,
"hunter" => Class::Hunter,
"monk" => Class::Monk,
"mage" => Class::Mage,
"wizard" => Class::Wizard,
"conjurer" => Class::Conjurer,
"sorcerer" => Class::Sorcerer,
_ => Class::Warrior,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Class {
Warrior,
Paladin,
Rogue,
Bard,
Hunter,
Monk,
Mage,
Wizard,
Conjurer,
Sorcerer,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Character {
pub id: u32,
pub user_id: u32,
pub name: String,
pub race: Race,
pub class: Class,
pub level: i32,
pub xp: i64,
pub stats: CharacterStats,
pub hp: i32,
pub max_hp: i32,
pub energy: i32,
pub max_energy: i32,
pub nerve: i32,
pub max_nerve: i32,
pub gold: i32,
pub last_regen: i64,
pub equipment: HashMap<String, Option<String>>,
pub inventory: Vec<String>,
#[serde(default)]
pub active_song: Option<String>,
#[serde(default)]
pub known_spells: Vec<String>,
#[serde(default)]
pub active_spell: Option<String>,
#[serde(default = "default_sp")]
pub spell_points: i32,
#[serde(default = "default_max_sp")]
pub max_spell_points: i32,
}
fn default_sp() -> i32 { 0 }
fn default_max_sp() -> i32 { 0 }
#[derive(Debug, Clone, Deserialize)]
pub struct CreateCharacter {
pub name: String,
pub race: Race,
pub class: Class,
}

View file

@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Item {
pub id: String,
pub name: String,
pub r#type: ItemType,
pub quality: Quality,
pub slot: String,
pub stats: HashMap<String, i64>,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub attack_type: Option<AttackType>,
pub value: i32,
#[serde(default)]
pub always_in_shop: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ItemType {
Weapon,
Armor,
Accessory,
Consumable,
Material,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AttackType {
Slash,
Crush,
Puncture,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum Quality {
Common,
Uncommon,
Rare,
Epic,
Legendary,
}

View file

@ -1,3 +1,10 @@
pub mod character;
pub mod errors;
pub mod item;
pub mod responses;
pub mod user;
pub use errors::AppError;
pub use character::{Character, CharacterStats, Class, CreateCharacter, Race};
pub use item::{AttackType, Item, ItemType, Quality};
pub use responses::*;
pub use user::{Role, User};

View file

@ -0,0 +1,201 @@
use serde::{Deserialize, Serialize};
use crate::character::Character;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CritEntry {
pub name: String,
pub damage_multiplier: f64,
pub stun_rounds: u8,
pub bleed_per_round: i32,
pub bleed_rounds: u8,
pub effects: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CritResult {
pub severity: String,
pub crit: CritEntry,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CombatRound {
pub attacker: String,
pub defender: String,
pub raw_roll: i32,
pub ob: i32,
pub db: i32,
pub result: i32,
pub damage: i32,
pub crit: Option<CritResult>,
pub defender_hp: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CombatLog {
pub rounds: Vec<CombatRound>,
pub winner: String,
pub loser: String,
pub xp_gained: i32,
pub gold_gained: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiInfoResponse {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RacesResponse {
pub races: Vec<RaceInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RaceInfo {
pub race: String,
pub name: String,
pub str_mod: i64,
pub int_mod: i64,
pub dex_mod: i64,
pub con_mod: i64,
pub luck_mod: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassesResponse {
pub classes: Vec<ClassInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassInfo {
pub class: String,
pub name: String,
pub primary_stat: String,
pub secondary_stat: String,
pub base_hp: i32,
pub hp_per_con: f64,
pub is_caster: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnemiesResponse {
pub enemies: Vec<EnemySummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnemySummary {
pub id: String,
pub name: String,
pub level: i32,
pub hp: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdventuresResponse {
pub zones: Vec<ZoneSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneSummary {
pub id: String,
pub name: String,
pub description: String,
pub min_level: i32,
pub nerve_cost: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GymResponse {
pub energy: i32,
pub max_energy: i32,
pub gold: i32,
pub stats: Vec<TrainingStatResponse>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingStatResponse {
pub stat: String,
pub name: String,
pub description: String,
pub value: i64,
pub cost: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainStatResponse {
pub success: bool,
pub message: String,
pub character: Option<Character>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShopResponse {
pub gold: i32,
pub inventory: Vec<InventoryItemResponse>,
pub shop_stock: Vec<ShopItemResponse>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryItemResponse {
pub id: String,
pub name: String,
pub item_type: String,
pub quality: String,
pub slot: Option<String>,
pub stats: std::collections::HashMap<String, i64>,
pub description: String,
pub attack_type: Option<String>,
pub value: i32,
pub equipped: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShopItemResponse {
pub id: String,
pub name: String,
pub item_type: String,
pub quality: String,
pub slot: Option<String>,
pub stats: std::collections::HashMap<String, i64>,
pub description: String,
pub attack_type: Option<String>,
pub value: i32,
pub stock: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShopActionResponse {
pub success: bool,
pub message: String,
pub gold: i32,
pub inventory: Vec<InventoryItemResponse>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SongsResponse {
pub active_song: Option<String>,
pub available_songs: Vec<SongSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SongSummary {
pub id: String,
pub name: String,
pub description: String,
pub required_level: i32,
pub combat_effect: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetSongResponse {
pub success: bool,
pub message: String,
pub active_song: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LevelUpResponse {
pub success: bool,
pub message: String,
pub character: Option<Character>,
}

View file

@ -0,0 +1,17 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: u32,
pub name: String,
pub role: Role,
#[serde(skip_serializing)]
pub password_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Admin,
Player,
}