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:
parent
7a75a523f0
commit
6410e0a86d
39 changed files with 3450 additions and 48 deletions
|
|
@ -10,6 +10,7 @@ tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
|
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
rand = "0.9"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
sb-core = { path = "../sb-core" }
|
sb-core = { path = "../sb-core" }
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use axum::routing::get;
|
|
||||||
use axum::Router;
|
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
|
||||||
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() {
|
||||||
|
|
@ -17,9 +16,9 @@ async fn main() {
|
||||||
))
|
))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let app = Router::new()
|
let state = routes::seed_users();
|
||||||
.route("/health", get(health))
|
|
||||||
.merge(routes::build());
|
let app = routes::build(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
|
||||||
.await
|
.await
|
||||||
|
|
@ -28,7 +27,3 @@ async fn main() {
|
||||||
tracing::info!("listening on {:?}", listener.local_addr());
|
tracing::info!("listening on {:?}", listener.local_addr());
|
||||||
axum::serve(listener, app).await.expect("server error");
|
axum::serve(listener, app).await.expect("server error");
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health() -> &'static str {
|
|
||||||
"ok"
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -6,6 +6,7 @@ edition = "2024"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
|
getrandom = "0.3"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
sb-shared = { path = "../sb-shared" }
|
sb-shared = { path = "../sb-shared" }
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,2 @@
|
||||||
pub mod jwt;
|
|
||||||
pub mod hash;
|
pub mod hash;
|
||||||
pub mod session;
|
pub mod jwt;
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
415
crates/sb-core/src/character.rs
Normal file
415
crates/sb-core/src/character.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
371
crates/sb-core/src/combat.rs
Normal file
371
crates/sb-core/src/combat.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
63
crates/sb-core/src/data.rs
Normal file
63
crates/sb-core/src/data.rs
Normal 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")
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -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 {
|
let weapons: Vec<Item> =
|
||||||
PROJECT_NAME
|
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> {
|
pub fn item_price(item: &Item) -> i32 {
|
||||||
serde_json::from_str(WEAPONS).expect("failed to parse weapons data")
|
item.value
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn item_sell_price(item: &Item) -> i32 {
|
||||||
|
item.value / 2
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,8 @@
|
||||||
|
|
||||||
pub mod character;
|
pub mod character;
|
||||||
pub mod combat;
|
pub mod combat;
|
||||||
pub mod adventure;
|
pub mod data;
|
||||||
pub mod economy;
|
pub mod items;
|
||||||
pub mod guild;
|
pub mod services;
|
||||||
pub mod crafting;
|
pub mod songs;
|
||||||
pub mod pets;
|
pub mod spells;
|
||||||
pub mod blessings;
|
|
||||||
pub mod workers;
|
|
||||||
pub mod slayer;
|
|
||||||
pub mod world;
|
|
||||||
pub mod rules;
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
319
crates/sb-core/src/services.rs
Normal file
319
crates/sb-core/src/services.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
59
crates/sb-core/src/songs.rs
Normal file
59
crates/sb-core/src/songs.rs
Normal 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()
|
||||||
|
}
|
||||||
74
crates/sb-core/src/spells.rs
Normal file
74
crates/sb-core/src/spells.rs
Normal 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);
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -4,6 +4,7 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid", "migrate"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
sb-shared = { path = "../sb-shared" }
|
sb-shared = { path = "../sb-shared" }
|
||||||
|
|
|
||||||
|
|
@ -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 struct Database {
|
||||||
pub mod queries;
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
142
crates/sb-shared/src/character.rs
Normal file
142
crates/sb-shared/src/character.rs
Normal 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,
|
||||||
|
}
|
||||||
46
crates/sb-shared/src/item.rs
Normal file
46
crates/sb-shared/src/item.rs
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
|
pub mod character;
|
||||||
pub mod errors;
|
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};
|
||||||
|
|
|
||||||
201
crates/sb-shared/src/responses.rs
Normal file
201
crates/sb-shared/src/responses.rs
Normal 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>,
|
||||||
|
}
|
||||||
17
crates/sb-shared/src/user.rs
Normal file
17
crates/sb-shared/src/user.rs
Normal 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,
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue