Merge WI-2 Architecture Cleanup (PSP1, issue #2) into main
This commit is contained in:
commit
ac776522f9
25 changed files with 2087 additions and 598 deletions
|
|
@ -3,9 +3,9 @@
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
|
||||||
|
pub mod middleware;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
pub mod middleware;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::Request,
|
extract::Request,
|
||||||
http::{header, StatusCode},
|
http::{StatusCode, header},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
|
|
@ -13,19 +13,14 @@ pub struct AuthUser {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn auth_middleware(
|
pub async fn auth_middleware(mut request: Request, next: Next) -> Result<Response, StatusCode> {
|
||||||
mut request: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Result<Response, StatusCode> {
|
|
||||||
let auth_header = request
|
let auth_header = request
|
||||||
.headers()
|
.headers()
|
||||||
.get(header::AUTHORIZATION)
|
.get(header::AUTHORIZATION)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
let token = auth_header
|
let token = auth_header.strip_prefix("Bearer ").unwrap_or(auth_header);
|
||||||
.strip_prefix("Bearer ")
|
|
||||||
.unwrap_or(auth_header);
|
|
||||||
|
|
||||||
if token.is_empty() {
|
if token.is_empty() {
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load diff
239
crates/sb-api/src/routes/responses.rs
Normal file
239
crates/sb-api/src/routes/responses.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AdventureRunResponse {
|
||||||
|
pub combat_log: serde_json::Value,
|
||||||
|
pub enemy: String,
|
||||||
|
pub loot: Vec<String>,
|
||||||
|
pub zone: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HealResponse {
|
||||||
|
pub gold: i32,
|
||||||
|
pub healed: bool,
|
||||||
|
pub hp: i32,
|
||||||
|
pub max_hp: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SpellEntryResponse {
|
||||||
|
pub code: String,
|
||||||
|
pub combat_damage_max: i32,
|
||||||
|
pub combat_damage_min: i32,
|
||||||
|
pub combat_effect: String,
|
||||||
|
pub description: String,
|
||||||
|
pub heal_max: i32,
|
||||||
|
pub heal_min: i32,
|
||||||
|
pub id: String,
|
||||||
|
pub known: bool,
|
||||||
|
pub name: String,
|
||||||
|
pub range: String,
|
||||||
|
pub spell_level: i32,
|
||||||
|
pub spell_points: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SpellbookResponse {
|
||||||
|
pub caster_class: String,
|
||||||
|
pub learn_costs: BTreeMap<String, i32>,
|
||||||
|
pub max_spell_points: i32,
|
||||||
|
pub spell_level: i32,
|
||||||
|
pub spell_points: i32,
|
||||||
|
pub spells: Vec<SpellEntryResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ActivateSpellResponse {
|
||||||
|
pub active_spell: Option<String>,
|
||||||
|
pub spell_points: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LearnSpellsResponse {
|
||||||
|
pub gold: i32,
|
||||||
|
pub learned_count: usize,
|
||||||
|
pub max_spell_points: i32,
|
||||||
|
pub spell_points: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum LearnSpellResponse {
|
||||||
|
Activate(ActivateSpellResponse),
|
||||||
|
Learn(LearnSpellsResponse),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sb_core::adventure::AdventureOutcome> for AdventureRunResponse {
|
||||||
|
fn from(outcome: sb_core::adventure::AdventureOutcome) -> Self {
|
||||||
|
AdventureRunResponse {
|
||||||
|
// to_value mirrors exactly what json! does to &log today: struct →
|
||||||
|
// Value::Object with BTreeMap ordering — nested keys sorted, byte-identical wire.
|
||||||
|
combat_log: serde_json::to_value(&outcome.combat_log)
|
||||||
|
.expect("CombatLog graph contains no map types; to_value is infallible"),
|
||||||
|
enemy: outcome.enemy_name,
|
||||||
|
loot: outcome.loot_item_names,
|
||||||
|
zone: outcome.zone_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use sb_shared::{CombatLog, CombatRound, CritEntry, CritResult};
|
||||||
|
|
||||||
|
fn representative_log() -> CombatLog {
|
||||||
|
CombatLog {
|
||||||
|
rounds: vec![
|
||||||
|
CombatRound {
|
||||||
|
attacker: "Test".into(),
|
||||||
|
defender: "Giant Rat".into(),
|
||||||
|
raw_roll: 67,
|
||||||
|
ob: 15,
|
||||||
|
db: 0,
|
||||||
|
result: 82,
|
||||||
|
damage: 12,
|
||||||
|
crit: None,
|
||||||
|
defender_hp: 0,
|
||||||
|
},
|
||||||
|
CombatRound {
|
||||||
|
attacker: "Giant Rat".into(),
|
||||||
|
defender: "Test".into(),
|
||||||
|
raw_roll: 99,
|
||||||
|
ob: 3,
|
||||||
|
db: 5,
|
||||||
|
result: 97,
|
||||||
|
damage: 18,
|
||||||
|
crit: Some(CritResult {
|
||||||
|
severity: "A".into(),
|
||||||
|
crit: CritEntry {
|
||||||
|
name: "critical".into(),
|
||||||
|
damage_multiplier: 2.0,
|
||||||
|
stun_rounds: 1,
|
||||||
|
bleed_per_round: 2,
|
||||||
|
bleed_rounds: 3,
|
||||||
|
effects: vec!["stun".into()],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defender_hp: 62,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
winner: "Test".into(),
|
||||||
|
loser: "Giant Rat".into(),
|
||||||
|
xp_gained: 5,
|
||||||
|
gold_gained: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn representative_outcome() -> sb_core::adventure::AdventureOutcome {
|
||||||
|
sb_core::adventure::AdventureOutcome {
|
||||||
|
zone_id: "skara_brae_streets".into(),
|
||||||
|
enemy_name: "Giant Rat".into(),
|
||||||
|
combat_log: representative_log(),
|
||||||
|
loot_item_names: vec!["Iron Sword".into()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GOLDEN_W1: &str = r#"{"combat_log":{"gold_gained":2,"loser":"Giant Rat","rounds":[{"attacker":"Test","crit":null,"damage":12,"db":0,"defender":"Giant Rat","defender_hp":0,"ob":15,"raw_roll":67,"result":82},{"attacker":"Giant Rat","crit":{"crit":{"bleed_per_round":2,"bleed_rounds":3,"damage_multiplier":2.0,"effects":["stun"],"name":"critical","stun_rounds":1},"severity":"A"},"damage":18,"db":5,"defender":"Test","defender_hp":62,"ob":3,"raw_roll":99,"result":97}],"winner":"Test","xp_gained":5},"enemy":"Giant Rat","loot":["Iron Sword"],"zone":"skara_brae_streets"}"#;
|
||||||
|
|
||||||
|
const GOLDEN_W2: &str = r#"{"gold":123,"healed":true,"hp":45,"max_hp":80}"#;
|
||||||
|
|
||||||
|
const GOLDEN_W3: &str = r#"{"code":"fire_bolt","combat_damage_max":6,"combat_damage_min":2,"combat_effect":"damage","description":"Hurls a mote of fire at the target.","heal_max":0,"heal_min":0,"id":"spell_fire_bolt","known":true,"name":"Fire Bolt","range":"short","spell_level":1,"spell_points":10}"#;
|
||||||
|
|
||||||
|
const GOLDEN_W4: &str = r#"{"caster_class":"mage","learn_costs":{"2":1000,"3":2000,"4":4000,"5":7000,"6":10000,"7":20000},"max_spell_points":40,"spell_level":4,"spell_points":12,"spells":[{"code":"fire_bolt","combat_damage_max":6,"combat_damage_min":2,"combat_effect":"damage","description":"Hurls a mote of fire at the target.","heal_max":0,"heal_min":0,"id":"spell_fire_bolt","known":true,"name":"Fire Bolt","range":"short","spell_level":1,"spell_points":10}]}"#;
|
||||||
|
|
||||||
|
const GOLDEN_W5: &str = r#"{"active_spell":"mage_armor","spell_points":12}"#;
|
||||||
|
const GOLDEN_W5_NULL: &str = r#"{"active_spell":null,"spell_points":12}"#;
|
||||||
|
|
||||||
|
const GOLDEN_W6: &str =
|
||||||
|
r#"{"gold":19700,"learned_count":4,"max_spell_points":40,"spell_points":40}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_identity_adventure_response() {
|
||||||
|
let dto: AdventureRunResponse = representative_outcome().into();
|
||||||
|
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_identity_heal_response() {
|
||||||
|
let dto = HealResponse {
|
||||||
|
gold: 123,
|
||||||
|
healed: true,
|
||||||
|
hp: 45,
|
||||||
|
max_hp: 80,
|
||||||
|
};
|
||||||
|
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn representative_spell_entry() -> SpellEntryResponse {
|
||||||
|
SpellEntryResponse {
|
||||||
|
code: "fire_bolt".into(),
|
||||||
|
combat_damage_max: 6,
|
||||||
|
combat_damage_min: 2,
|
||||||
|
combat_effect: "damage".into(),
|
||||||
|
description: "Hurls a mote of fire at the target.".into(),
|
||||||
|
heal_max: 0,
|
||||||
|
heal_min: 0,
|
||||||
|
id: "spell_fire_bolt".into(),
|
||||||
|
known: true,
|
||||||
|
name: "Fire Bolt".into(),
|
||||||
|
range: "short".into(),
|
||||||
|
spell_level: 1,
|
||||||
|
spell_points: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_identity_spell_entry_response() {
|
||||||
|
let dto = representative_spell_entry();
|
||||||
|
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_identity_spellbook_response() {
|
||||||
|
let dto = SpellbookResponse {
|
||||||
|
caster_class: "mage".into(),
|
||||||
|
learn_costs: BTreeMap::from([
|
||||||
|
("2".into(), 1000),
|
||||||
|
("3".into(), 2000),
|
||||||
|
("4".into(), 4000),
|
||||||
|
("5".into(), 7000),
|
||||||
|
("6".into(), 10000),
|
||||||
|
("7".into(), 20000),
|
||||||
|
]),
|
||||||
|
max_spell_points: 40,
|
||||||
|
spell_level: 4,
|
||||||
|
spell_points: 12,
|
||||||
|
spells: vec![representative_spell_entry()],
|
||||||
|
};
|
||||||
|
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_identity_activate_spell_response() {
|
||||||
|
let dto = ActivateSpellResponse {
|
||||||
|
active_spell: Some("mage_armor".into()),
|
||||||
|
spell_points: 12,
|
||||||
|
};
|
||||||
|
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W5);
|
||||||
|
|
||||||
|
let dto = ActivateSpellResponse {
|
||||||
|
active_spell: None,
|
||||||
|
spell_points: 12,
|
||||||
|
};
|
||||||
|
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W5_NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_identity_learn_response() {
|
||||||
|
let dto = LearnSpellResponse::Learn(LearnSpellsResponse {
|
||||||
|
gold: 19700,
|
||||||
|
learned_count: 4,
|
||||||
|
max_spell_points: 40,
|
||||||
|
spell_points: 40,
|
||||||
|
});
|
||||||
|
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
pub mod handler;
|
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
|
pub mod handler;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
|
||||||
Argon2,
|
Argon2,
|
||||||
|
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
|
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
|
||||||
|
|
@ -17,5 +17,7 @@ pub fn verify_password(password: &str, hash: &str) -> bool {
|
||||||
Ok(h) => h,
|
Ok(h) => h,
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
};
|
};
|
||||||
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed)
|
||||||
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use chrono::{Utc, Duration};
|
|
||||||
|
|
||||||
fn jwt_secret() -> Vec<u8> {
|
fn jwt_secret() -> Vec<u8> {
|
||||||
std::env::var("JWT_SECRET")
|
std::env::var("JWT_SECRET")
|
||||||
|
|
@ -16,7 +16,11 @@ pub struct Claims {
|
||||||
pub exp: usize,
|
pub exp: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_token(user_id: u32, name: &str, role: &str) -> Result<String, jsonwebtoken::errors::Error> {
|
pub fn create_token(
|
||||||
|
user_id: u32,
|
||||||
|
name: &str,
|
||||||
|
role: &str,
|
||||||
|
) -> Result<String, jsonwebtoken::errors::Error> {
|
||||||
let exp = Utc::now()
|
let exp = Utc::now()
|
||||||
.checked_add_signed(Duration::hours(24))
|
.checked_add_signed(Duration::hours(24))
|
||||||
.expect("valid timestamp")
|
.expect("valid timestamp")
|
||||||
|
|
@ -39,9 +43,6 @@ pub fn create_token(user_id: u32, name: &str, role: &str) -> Result<String, json
|
||||||
pub fn verify_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
pub fn verify_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||||
let mut validation = Validation::default();
|
let mut validation = Validation::default();
|
||||||
validation.leeway = 60;
|
validation.leeway = 60;
|
||||||
jsonwebtoken::decode::<Claims>(
|
jsonwebtoken::decode::<Claims>(token, &DecodingKey::from_secret(&jwt_secret()), &validation)
|
||||||
token,
|
.map(|d| d.claims)
|
||||||
&DecodingKey::from_secret(&jwt_secret()),
|
|
||||||
&validation,
|
|
||||||
).map(|d| d.claims)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
473
crates/sb-core/src/adventure.rs
Normal file
473
crates/sb-core/src/adventure.rs
Normal file
|
|
@ -0,0 +1,473 @@
|
||||||
|
use crate::character;
|
||||||
|
use crate::combat::{self, CombatStats};
|
||||||
|
use crate::data::{EnemyDef, ZoneDef};
|
||||||
|
use crate::error::DomainError;
|
||||||
|
use crate::services;
|
||||||
|
use crate::songs;
|
||||||
|
use crate::spells;
|
||||||
|
use rand::Rng;
|
||||||
|
use sb_shared::{Character, CombatLog, Item};
|
||||||
|
|
||||||
|
/// Domain output of one adventure run. No serde derives — the HTTP DTO
|
||||||
|
/// (sb-api `AdventureRunResponse`) maps from this at the boundary.
|
||||||
|
pub struct AdventureOutcome {
|
||||||
|
pub zone_id: String,
|
||||||
|
pub enemy_name: String,
|
||||||
|
pub combat_log: CombatLog,
|
||||||
|
pub loot_item_names: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_adventure(
|
||||||
|
rng: &mut impl Rng,
|
||||||
|
character: &mut Character,
|
||||||
|
zone: &ZoneDef,
|
||||||
|
enemies: &[EnemyDef],
|
||||||
|
items: &[Item],
|
||||||
|
now_ms: i64,
|
||||||
|
) -> Result<AdventureOutcome, DomainError> {
|
||||||
|
character::apply_regen(character, now_ms);
|
||||||
|
|
||||||
|
if character.hp <= 0 {
|
||||||
|
return Err(DomainError::CharacterIncapacitated);
|
||||||
|
}
|
||||||
|
if character.nerve < zone.nerve_cost {
|
||||||
|
return Err(DomainError::NotEnoughNerve);
|
||||||
|
}
|
||||||
|
character.nerve -= zone.nerve_cost;
|
||||||
|
|
||||||
|
let enemy_def = services::pick_enemy(rng, &zone.encounters, enemies)?;
|
||||||
|
|
||||||
|
let char_attack_type = services::equipped_attack_type(&character.equipment, items);
|
||||||
|
let char_ob = services::equipped_damage(&character.equipment, items);
|
||||||
|
let char_db = services::total_defense(&character.equipment, items);
|
||||||
|
|
||||||
|
let char_combat = CombatStats {
|
||||||
|
name: character.name.clone(),
|
||||||
|
hp: character.hp,
|
||||||
|
level: character.level,
|
||||||
|
strength: character.stats.strength,
|
||||||
|
dexterity: character.stats.dexterity,
|
||||||
|
constitution: character.stats.constitution,
|
||||||
|
luck: character.stats.luck,
|
||||||
|
ob_base: char_ob,
|
||||||
|
db_base: char_db,
|
||||||
|
attack_type: char_attack_type,
|
||||||
|
};
|
||||||
|
|
||||||
|
let enemy_combat = CombatStats {
|
||||||
|
name: enemy_def.name.clone(),
|
||||||
|
hp: enemy_def.hp,
|
||||||
|
level: enemy_def.level,
|
||||||
|
strength: enemy_def.stats.strength,
|
||||||
|
dexterity: enemy_def.stats.dexterity,
|
||||||
|
constitution: enemy_def.stats.constitution,
|
||||||
|
luck: enemy_def.stats.luck,
|
||||||
|
ob_base: enemy_def.damage,
|
||||||
|
db_base: enemy_def.defense,
|
||||||
|
attack_type: combat::parse_attack_type(&enemy_def.attack_type),
|
||||||
|
};
|
||||||
|
|
||||||
|
let spell_effect = character.active_spell.as_deref().and_then(|sid| {
|
||||||
|
spells::find_spell(sid).map(|s| combat::SpellEffect {
|
||||||
|
combat_effect: s.combat_effect.clone(),
|
||||||
|
damage_min: s.combat_damage_min,
|
||||||
|
damage_max: s.combat_damage_max,
|
||||||
|
heal_min: s.heal_min,
|
||||||
|
heal_max: s.heal_max,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let song_effect = character.active_song.as_deref().and_then(|sid| {
|
||||||
|
songs::BARD_SONGS
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == sid)
|
||||||
|
.map(|s| s.combat_effect)
|
||||||
|
});
|
||||||
|
|
||||||
|
let log = combat::resolve_combat_full(
|
||||||
|
char_combat,
|
||||||
|
enemy_combat,
|
||||||
|
enemy_def.xp,
|
||||||
|
enemy_def.gold_min,
|
||||||
|
enemy_def.gold_max,
|
||||||
|
song_effect,
|
||||||
|
spell_effect.as_ref(),
|
||||||
|
rng,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut dropped_ids: Vec<String> = Vec::new();
|
||||||
|
if log.winner == character.name {
|
||||||
|
character.xp += log.xp_gained as i64;
|
||||||
|
character.gold += log.gold_gained;
|
||||||
|
if let Some(hp) = log
|
||||||
|
.rounds
|
||||||
|
.iter()
|
||||||
|
.rfind(|r| r.defender == character.name)
|
||||||
|
.map(|r| r.defender_hp)
|
||||||
|
{
|
||||||
|
character.hp = hp;
|
||||||
|
}
|
||||||
|
|
||||||
|
for drop in &enemy_def.loot {
|
||||||
|
if rng.random_bool(drop.chance) && items.iter().any(|i| i.id == drop.item_id) {
|
||||||
|
character.inventory.push(drop.item_id.clone());
|
||||||
|
dropped_ids.push(drop.item_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
character.hp = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(active) = character.active_spell.as_deref()
|
||||||
|
&& let Some(sp) = spells::find_spell(active)
|
||||||
|
{
|
||||||
|
character.spell_points = (character.spell_points - sp.spell_points).max(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let loot_item_names = dropped_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| items.iter().find(|i| &i.id == id).map(|i| i.name.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(AdventureOutcome {
|
||||||
|
zone_id: zone.id.clone(),
|
||||||
|
enemy_name: enemy_def.name.clone(),
|
||||||
|
combat_log: log,
|
||||||
|
loot_item_names,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::constants;
|
||||||
|
use crate::data::{EncounterEntry, EnemyStats, LootDrop};
|
||||||
|
use rand::SeedableRng;
|
||||||
|
use sb_shared::{AttackType, CharacterStats, Class, ItemType, Quality, Race};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn base_char() -> Character {
|
||||||
|
Character {
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
name: "Test".into(),
|
||||||
|
race: Race::Human,
|
||||||
|
class: Class::Warrior,
|
||||||
|
level: 1,
|
||||||
|
xp: 0,
|
||||||
|
stats: CharacterStats {
|
||||||
|
strength: 50,
|
||||||
|
intelligence: 20,
|
||||||
|
dexterity: 20,
|
||||||
|
constitution: 20,
|
||||||
|
luck: 20,
|
||||||
|
},
|
||||||
|
hp: 100,
|
||||||
|
max_hp: 100,
|
||||||
|
energy: 36,
|
||||||
|
max_energy: 36,
|
||||||
|
nerve: 110,
|
||||||
|
max_nerve: 110,
|
||||||
|
gold: 100,
|
||||||
|
last_regen: 0,
|
||||||
|
equipment: HashMap::new(),
|
||||||
|
inventory: Vec::new(),
|
||||||
|
active_song: None,
|
||||||
|
known_spells: Vec::new(),
|
||||||
|
active_spell: None,
|
||||||
|
spell_points: 0,
|
||||||
|
max_spell_points: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn char_with_sword() -> Character {
|
||||||
|
let mut c = base_char();
|
||||||
|
c.equipment
|
||||||
|
.insert("main_hand".into(), Some("sword1".into()));
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sword() -> Item {
|
||||||
|
Item {
|
||||||
|
id: "sword1".into(),
|
||||||
|
name: "Iron Sword".into(),
|
||||||
|
r#type: ItemType::Weapon,
|
||||||
|
quality: Quality::Common,
|
||||||
|
slot: "main_hand".into(),
|
||||||
|
stats: HashMap::from([("damage".into(), 15i64)]),
|
||||||
|
description: String::new(),
|
||||||
|
attack_type: Some(AttackType::Slash),
|
||||||
|
value: 100,
|
||||||
|
always_in_shop: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn items() -> Vec<Item> {
|
||||||
|
vec![sword()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zone() -> ZoneDef {
|
||||||
|
zone_with("rat")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zone_with(enemy_id: &str) -> ZoneDef {
|
||||||
|
ZoneDef {
|
||||||
|
id: "streets".into(),
|
||||||
|
name: "Skara Brae Streets".into(),
|
||||||
|
min_level: 1,
|
||||||
|
max_level: 5,
|
||||||
|
nerve_cost: 5,
|
||||||
|
description: String::new(),
|
||||||
|
encounters: vec![EncounterEntry {
|
||||||
|
enemy_id: enemy_id.into(),
|
||||||
|
weight: 1,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn weak_enemy() -> EnemyDef {
|
||||||
|
EnemyDef {
|
||||||
|
id: "rat".into(),
|
||||||
|
name: "Giant Rat".into(),
|
||||||
|
hp: 10,
|
||||||
|
level: 1,
|
||||||
|
damage: 0,
|
||||||
|
defense: 0,
|
||||||
|
attack_type: "puncture".into(),
|
||||||
|
xp: 5,
|
||||||
|
gold_min: 1,
|
||||||
|
gold_max: 2,
|
||||||
|
stats: EnemyStats {
|
||||||
|
strength: 5,
|
||||||
|
intelligence: 5,
|
||||||
|
dexterity: 5,
|
||||||
|
constitution: 5,
|
||||||
|
luck: 5,
|
||||||
|
},
|
||||||
|
description: String::new(),
|
||||||
|
loot: vec![LootDrop {
|
||||||
|
item_id: "sword1".into(),
|
||||||
|
chance: 1.0,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overwhelming_enemy() -> EnemyDef {
|
||||||
|
EnemyDef {
|
||||||
|
id: "doom".into(),
|
||||||
|
name: "Doom Bringer".into(),
|
||||||
|
hp: 500,
|
||||||
|
level: 10,
|
||||||
|
damage: 200,
|
||||||
|
defense: 0,
|
||||||
|
attack_type: "crush".into(),
|
||||||
|
xp: 5,
|
||||||
|
gold_min: 1,
|
||||||
|
gold_max: 2,
|
||||||
|
stats: EnemyStats {
|
||||||
|
strength: 5,
|
||||||
|
intelligence: 5,
|
||||||
|
dexterity: 5,
|
||||||
|
constitution: 5,
|
||||||
|
luck: 5,
|
||||||
|
},
|
||||||
|
description: String::new(),
|
||||||
|
loot: vec![LootDrop {
|
||||||
|
item_id: "sword1".into(),
|
||||||
|
chance: 1.0,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_adventure_win_applies_rewards_and_loot() {
|
||||||
|
let mut rng = rand::rngs::StdRng::seed_from_u64(7);
|
||||||
|
let mut c = char_with_sword();
|
||||||
|
let (xp_before, gold_before, nerve_before) = (c.xp, c.gold, c.nerve);
|
||||||
|
let z = zone();
|
||||||
|
let outcome =
|
||||||
|
run_adventure(&mut rng, &mut c, &z, &[weak_enemy()], &items(), 1_000_000).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(outcome.combat_log.winner, c.name);
|
||||||
|
assert_eq!(c.xp, xp_before + 5);
|
||||||
|
assert!(c.gold > gold_before && c.gold <= gold_before + 2);
|
||||||
|
assert_eq!(c.gold, gold_before + outcome.combat_log.gold_gained);
|
||||||
|
assert_eq!(c.nerve, nerve_before - z.nerve_cost);
|
||||||
|
assert!(c.inventory.contains(&"sword1".to_string()));
|
||||||
|
assert_eq!(outcome.loot_item_names, vec!["Iron Sword".to_string()]);
|
||||||
|
assert_eq!(outcome.enemy_name, "Giant Rat");
|
||||||
|
assert_eq!(outcome.zone_id, "streets");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&outcome.combat_log).unwrap(),
|
||||||
|
GOLDEN_COMBAT_LOG_JSON
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_adventure_defeat_sets_hp_to_zero() {
|
||||||
|
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
|
||||||
|
let mut c = base_char();
|
||||||
|
c.stats.strength = 5;
|
||||||
|
let (xp_before, gold_before) = (c.xp, c.gold);
|
||||||
|
let outcome = run_adventure(
|
||||||
|
&mut rng,
|
||||||
|
&mut c,
|
||||||
|
&zone_with("doom"),
|
||||||
|
&[overwhelming_enemy()],
|
||||||
|
&items(),
|
||||||
|
1_000_000,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(outcome.combat_log.winner, "Doom Bringer");
|
||||||
|
assert_eq!(c.hp, 0);
|
||||||
|
assert_eq!(c.xp, xp_before);
|
||||||
|
assert_eq!(c.gold, gold_before);
|
||||||
|
assert!(c.inventory.is_empty());
|
||||||
|
assert!(outcome.loot_item_names.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_adventure_rejects_incapacitated() {
|
||||||
|
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
|
||||||
|
let mut c = char_with_sword();
|
||||||
|
c.hp = 0;
|
||||||
|
let nerve_before = c.nerve;
|
||||||
|
let result = run_adventure(
|
||||||
|
&mut rng,
|
||||||
|
&mut c,
|
||||||
|
&zone(),
|
||||||
|
&[weak_enemy()],
|
||||||
|
&items(),
|
||||||
|
1_000_000,
|
||||||
|
);
|
||||||
|
assert!(matches!(result, Err(DomainError::CharacterIncapacitated)));
|
||||||
|
assert_eq!(c.nerve, nerve_before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_adventure_rejects_insufficient_nerve() {
|
||||||
|
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
|
||||||
|
let mut c = char_with_sword();
|
||||||
|
c.nerve = 4;
|
||||||
|
c.last_regen = 1_000_000;
|
||||||
|
let nerve_before = c.nerve;
|
||||||
|
let result = run_adventure(
|
||||||
|
&mut rng,
|
||||||
|
&mut c,
|
||||||
|
&zone(),
|
||||||
|
&[weak_enemy()],
|
||||||
|
&items(),
|
||||||
|
1_000_000,
|
||||||
|
);
|
||||||
|
assert!(matches!(result, Err(DomainError::NotEnoughNerve)));
|
||||||
|
assert_eq!(c.nerve, nerve_before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_adventure_applies_regen_before_guards() {
|
||||||
|
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
|
||||||
|
let mut c = char_with_sword();
|
||||||
|
c.hp = 40;
|
||||||
|
c.max_hp = 80;
|
||||||
|
c.last_regen = 0;
|
||||||
|
let now_ms = 10 * constants::HP_REGEN_INTERVAL_MS;
|
||||||
|
let mut enemy = weak_enemy();
|
||||||
|
enemy.damage = 0;
|
||||||
|
// Seed 42 was verified at fixture-recording time to yield zero enemy
|
||||||
|
// hits (the character kills the damaged-0 enemy in round 1), so the
|
||||||
|
// hp == 80 assertion holds: design §7 A5 no-hit-seed rationale.
|
||||||
|
let outcome = run_adventure(&mut rng, &mut c, &zone(), &[enemy], &items(), now_ms).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(c.last_regen, now_ms);
|
||||||
|
assert_eq!(c.hp, 80);
|
||||||
|
assert_eq!(outcome.combat_log.winner, c.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_adventure_charges_spell_upkeep_on_win_and_loss() {
|
||||||
|
let spell = spells::find_spell("arfi").expect("arfi fixture spell");
|
||||||
|
assert!(spell.spell_points > 0);
|
||||||
|
|
||||||
|
let mut win_rng = rand::rngs::StdRng::seed_from_u64(7);
|
||||||
|
let mut winner = char_with_sword();
|
||||||
|
winner.active_spell = Some("arfi".into());
|
||||||
|
winner.spell_points = 20;
|
||||||
|
run_adventure(
|
||||||
|
&mut win_rng,
|
||||||
|
&mut winner,
|
||||||
|
&zone(),
|
||||||
|
&[weak_enemy()],
|
||||||
|
&items(),
|
||||||
|
1_000_000,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(winner.spell_points, (20 - spell.spell_points).max(0));
|
||||||
|
assert_eq!(winner.active_spell.as_deref(), Some("arfi"));
|
||||||
|
|
||||||
|
let mut loss_rng = rand::rngs::StdRng::seed_from_u64(42);
|
||||||
|
let mut loser = base_char();
|
||||||
|
loser.stats.strength = 5;
|
||||||
|
loser.active_spell = Some("arfi".into());
|
||||||
|
loser.spell_points = 20;
|
||||||
|
run_adventure(
|
||||||
|
&mut loss_rng,
|
||||||
|
&mut loser,
|
||||||
|
&zone_with("doom"),
|
||||||
|
&[overwhelming_enemy()],
|
||||||
|
&items(),
|
||||||
|
1_000_000,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loser.spell_points, (20 - spell.spell_points).max(0));
|
||||||
|
assert_eq!(loser.active_spell.as_deref(), Some("arfi"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_adventure_filters_loot_to_catalog_items() {
|
||||||
|
let mut rng = rand::rngs::StdRng::seed_from_u64(7);
|
||||||
|
let mut enemy = weak_enemy();
|
||||||
|
enemy.loot = vec![LootDrop {
|
||||||
|
item_id: "ghost_item".into(),
|
||||||
|
chance: 1.0,
|
||||||
|
}];
|
||||||
|
let mut c = char_with_sword();
|
||||||
|
let outcome =
|
||||||
|
run_adventure(&mut rng, &mut c, &zone(), &[enemy], &items(), 1_000_000).unwrap();
|
||||||
|
|
||||||
|
assert!(!c.inventory.contains(&"ghost_item".to_string()));
|
||||||
|
assert!(outcome.loot_item_names.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_adventure_is_deterministic_under_seeded_rng() {
|
||||||
|
let run = || {
|
||||||
|
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
|
||||||
|
let mut c = char_with_sword();
|
||||||
|
let outcome = run_adventure(
|
||||||
|
&mut rng,
|
||||||
|
&mut c,
|
||||||
|
&zone(),
|
||||||
|
&[weak_enemy()],
|
||||||
|
&items(),
|
||||||
|
1_000_000,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(
|
||||||
|
format!(
|
||||||
|
"{:?}",
|
||||||
|
(
|
||||||
|
outcome.zone_id,
|
||||||
|
outcome.enemy_name,
|
||||||
|
outcome.loot_item_names,
|
||||||
|
outcome.combat_log
|
||||||
|
)
|
||||||
|
),
|
||||||
|
format!("{:?}", c),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert_eq!(run(), run());
|
||||||
|
}
|
||||||
|
|
||||||
|
const GOLDEN_COMBAT_LOG_JSON: &str = r#"{"rounds":[{"attacker":"Test","defender":"Giant Rat","raw_roll":-11,"ob":30,"db":-10,"result":29,"damage":43,"crit":{"severity":"A","crit":{"name":"Minor Slash","damage_multiplier":1.5,"stun_rounds":0,"bleed_per_round":1,"bleed_rounds":1,"effects":[]}},"defender_hp":0}],"winner":"Test","loser":"Giant Rat","xp_gained":5,"gold_gained":1}"#;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
use sb_shared::{Character, CharacterStats, Class, Race};
|
use crate::constants;
|
||||||
|
use crate::error::DomainError;
|
||||||
use crate::spells;
|
use crate::spells;
|
||||||
|
use sb_shared::{Character, CharacterStats, Class, Race};
|
||||||
|
|
||||||
pub struct TrainingGround {
|
pub struct TrainingGround {
|
||||||
pub stat: &'static str,
|
pub stat: &'static str,
|
||||||
|
|
@ -8,11 +10,31 @@ pub struct TrainingGround {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const TRAINING_GROUNDS: &[TrainingGround] = &[
|
pub const TRAINING_GROUNDS: &[TrainingGround] = &[
|
||||||
TrainingGround { stat: "strength", name: "The Anvil", description: "Pound steel and lift stone under the guidance of master smiths." },
|
TrainingGround {
|
||||||
TrainingGround { stat: "dexterity", name: "The Archery Range", description: "Sharpen reflexes with bow, blade, and balance drills." },
|
stat: "strength",
|
||||||
TrainingGround { stat: "constitution", name: "The Endurance Pits", description: "Push your body past its limits in grueling trials of resilience." },
|
name: "The Anvil",
|
||||||
TrainingGround { stat: "intelligence", name: "The Sage's Study", description: "Study ancient tomes and practice arcane disciplines." },
|
description: "Pound steel and lift stone under the guidance of master smiths.",
|
||||||
TrainingGround { stat: "luck", name: "Fortune's Shrine", description: "Consult oracles and tempt fate in games of chance." },
|
},
|
||||||
|
TrainingGround {
|
||||||
|
stat: "dexterity",
|
||||||
|
name: "The Archery Range",
|
||||||
|
description: "Sharpen reflexes with bow, blade, and balance drills.",
|
||||||
|
},
|
||||||
|
TrainingGround {
|
||||||
|
stat: "constitution",
|
||||||
|
name: "The Endurance Pits",
|
||||||
|
description: "Push your body past its limits in grueling trials of resilience.",
|
||||||
|
},
|
||||||
|
TrainingGround {
|
||||||
|
stat: "intelligence",
|
||||||
|
name: "The Sage's Study",
|
||||||
|
description: "Study ancient tomes and practice arcane disciplines.",
|
||||||
|
},
|
||||||
|
TrainingGround {
|
||||||
|
stat: "luck",
|
||||||
|
name: "Fortune's Shrine",
|
||||||
|
description: "Consult oracles and tempt fate in games of chance.",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub struct RaceInfo {
|
pub struct RaceInfo {
|
||||||
|
|
@ -26,11 +48,51 @@ pub struct RaceInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const RACES: &[RaceInfo] = &[
|
pub const RACES: &[RaceInfo] = &[
|
||||||
RaceInfo { race: Race::Human, name: "Human", str_mod: 0, int_mod: 0, dex_mod: 0, con_mod: 0, luck_mod: 0 },
|
RaceInfo {
|
||||||
RaceInfo { race: Race::Elf, name: "Elf", str_mod: -2, int_mod: 4, dex_mod: 2, con_mod: -2, luck_mod: 0 },
|
race: Race::Human,
|
||||||
RaceInfo { race: Race::Dwarf, name: "Dwarf", str_mod: 2, int_mod: -2, dex_mod: -2, con_mod: 4, luck_mod: 0 },
|
name: "Human",
|
||||||
RaceInfo { race: Race::Hobbit, name: "Hobbit", str_mod: -4, int_mod: 0, dex_mod: 2, con_mod: -2, luck_mod: 4 },
|
str_mod: 0,
|
||||||
RaceInfo { race: Race::HalfElf, name: "Half-Elf", str_mod: 0, int_mod: 2, dex_mod: 2, con_mod: 0, luck_mod: 0 },
|
int_mod: 0,
|
||||||
|
dex_mod: 0,
|
||||||
|
con_mod: 0,
|
||||||
|
luck_mod: 0,
|
||||||
|
},
|
||||||
|
RaceInfo {
|
||||||
|
race: Race::Elf,
|
||||||
|
name: "Elf",
|
||||||
|
str_mod: -2,
|
||||||
|
int_mod: 4,
|
||||||
|
dex_mod: 2,
|
||||||
|
con_mod: -2,
|
||||||
|
luck_mod: 0,
|
||||||
|
},
|
||||||
|
RaceInfo {
|
||||||
|
race: Race::Dwarf,
|
||||||
|
name: "Dwarf",
|
||||||
|
str_mod: 2,
|
||||||
|
int_mod: -2,
|
||||||
|
dex_mod: -2,
|
||||||
|
con_mod: 4,
|
||||||
|
luck_mod: 0,
|
||||||
|
},
|
||||||
|
RaceInfo {
|
||||||
|
race: Race::Hobbit,
|
||||||
|
name: "Hobbit",
|
||||||
|
str_mod: -4,
|
||||||
|
int_mod: 0,
|
||||||
|
dex_mod: 2,
|
||||||
|
con_mod: -2,
|
||||||
|
luck_mod: 4,
|
||||||
|
},
|
||||||
|
RaceInfo {
|
||||||
|
race: Race::HalfElf,
|
||||||
|
name: "Half-Elf",
|
||||||
|
str_mod: 0,
|
||||||
|
int_mod: 2,
|
||||||
|
dex_mod: 2,
|
||||||
|
con_mod: 0,
|
||||||
|
luck_mod: 0,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub struct ClassInfo {
|
pub struct ClassInfo {
|
||||||
|
|
@ -43,29 +105,102 @@ pub struct ClassInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const CLASSES: &[ClassInfo] = &[
|
pub const CLASSES: &[ClassInfo] = &[
|
||||||
ClassInfo { class: Class::Warrior, name: "Warrior", primary_stat: "strength", secondary_stat: "constitution", base_hp: 20, hp_per_con: 3.0 },
|
ClassInfo {
|
||||||
ClassInfo { class: Class::Paladin, name: "Paladin", primary_stat: "strength", secondary_stat: "luck", base_hp: 18, hp_per_con: 2.5 },
|
class: Class::Warrior,
|
||||||
ClassInfo { class: Class::Rogue, name: "Rogue", primary_stat: "dexterity", secondary_stat: "luck", base_hp: 14, hp_per_con: 2.0 },
|
name: "Warrior",
|
||||||
ClassInfo { class: Class::Bard, name: "Bard", primary_stat: "dexterity", secondary_stat: "intelligence", base_hp: 14, hp_per_con: 2.0 },
|
primary_stat: "strength",
|
||||||
ClassInfo { class: Class::Hunter, name: "Hunter", primary_stat: "dexterity", secondary_stat: "strength", base_hp: 16, hp_per_con: 2.5 },
|
secondary_stat: "constitution",
|
||||||
ClassInfo { class: Class::Monk, name: "Monk", primary_stat: "dexterity", secondary_stat: "constitution", base_hp: 15, hp_per_con: 2.5 },
|
base_hp: 20,
|
||||||
ClassInfo { class: Class::Mage, name: "Mage", primary_stat: "intelligence", secondary_stat: "dexterity", base_hp: 11, hp_per_con: 1.8 },
|
hp_per_con: 3.0,
|
||||||
ClassInfo { class: Class::Wizard, name: "Wizard", primary_stat: "intelligence", secondary_stat: "constitution", base_hp: 10, hp_per_con: 1.5 },
|
},
|
||||||
ClassInfo { class: Class::Conjurer, name: "Conjurer", primary_stat: "intelligence", secondary_stat: "luck", base_hp: 10, hp_per_con: 1.5 },
|
ClassInfo {
|
||||||
ClassInfo { class: Class::Sorcerer, name: "Sorcerer", primary_stat: "intelligence", secondary_stat: "strength", base_hp: 12, hp_per_con: 2.0 },
|
class: Class::Paladin,
|
||||||
|
name: "Paladin",
|
||||||
|
primary_stat: "strength",
|
||||||
|
secondary_stat: "luck",
|
||||||
|
base_hp: 18,
|
||||||
|
hp_per_con: 2.5,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Rogue,
|
||||||
|
name: "Rogue",
|
||||||
|
primary_stat: "dexterity",
|
||||||
|
secondary_stat: "luck",
|
||||||
|
base_hp: 14,
|
||||||
|
hp_per_con: 2.0,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Bard,
|
||||||
|
name: "Bard",
|
||||||
|
primary_stat: "dexterity",
|
||||||
|
secondary_stat: "intelligence",
|
||||||
|
base_hp: 14,
|
||||||
|
hp_per_con: 2.0,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Hunter,
|
||||||
|
name: "Hunter",
|
||||||
|
primary_stat: "dexterity",
|
||||||
|
secondary_stat: "strength",
|
||||||
|
base_hp: 16,
|
||||||
|
hp_per_con: 2.5,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Monk,
|
||||||
|
name: "Monk",
|
||||||
|
primary_stat: "dexterity",
|
||||||
|
secondary_stat: "constitution",
|
||||||
|
base_hp: 15,
|
||||||
|
hp_per_con: 2.5,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Mage,
|
||||||
|
name: "Mage",
|
||||||
|
primary_stat: "intelligence",
|
||||||
|
secondary_stat: "dexterity",
|
||||||
|
base_hp: 11,
|
||||||
|
hp_per_con: 1.8,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Wizard,
|
||||||
|
name: "Wizard",
|
||||||
|
primary_stat: "intelligence",
|
||||||
|
secondary_stat: "constitution",
|
||||||
|
base_hp: 10,
|
||||||
|
hp_per_con: 1.5,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Conjurer,
|
||||||
|
name: "Conjurer",
|
||||||
|
primary_stat: "intelligence",
|
||||||
|
secondary_stat: "luck",
|
||||||
|
base_hp: 10,
|
||||||
|
hp_per_con: 1.5,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Sorcerer,
|
||||||
|
name: "Sorcerer",
|
||||||
|
primary_stat: "intelligence",
|
||||||
|
secondary_stat: "strength",
|
||||||
|
base_hp: 12,
|
||||||
|
hp_per_con: 2.0,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn stat_bonus(stat: i64) -> i64 {
|
pub fn stat_bonus(stat: i64) -> i64 {
|
||||||
(stat - 20) / 2
|
(stat - constants::STAT_BONUS_NEUTRAL) / constants::STAT_BONUS_STEP
|
||||||
}
|
}
|
||||||
|
|
||||||
fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 {
|
fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 {
|
||||||
let base: i64 = rng.random_range(6..=16);
|
let base: i64 = rng.random_range(constants::STAT_ROLL_MIN..=constants::STAT_ROLL_MAX);
|
||||||
base + modifier
|
base + modifier
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats {
|
pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats {
|
||||||
let info = RACES.iter().find(|r| &r.race == race).expect("invalid race");
|
let info = RACES
|
||||||
|
.iter()
|
||||||
|
.find(|r| &r.race == race)
|
||||||
|
.expect("invalid race");
|
||||||
CharacterStats {
|
CharacterStats {
|
||||||
strength: roll_stat(rng, info.str_mod),
|
strength: roll_stat(rng, info.str_mod),
|
||||||
intelligence: roll_stat(rng, info.int_mod),
|
intelligence: roll_stat(rng, info.int_mod),
|
||||||
|
|
@ -76,35 +211,46 @@ pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn calc_max_hp(class: &Class, stats: &CharacterStats) -> i32 {
|
pub fn calc_max_hp(class: &Class, stats: &CharacterStats) -> i32 {
|
||||||
let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class");
|
let info = CLASSES
|
||||||
|
.iter()
|
||||||
|
.find(|c| &c.class == class)
|
||||||
|
.expect("invalid class");
|
||||||
(info.base_hp as f64 + stats.constitution as f64 * info.hp_per_con) as i32
|
(info.base_hp as f64 + stats.constitution as f64 * info.hp_per_con) as i32
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn calc_max_energy(class: &Class) -> i32 {
|
pub fn calc_max_energy(class: &Class) -> i32 {
|
||||||
let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class");
|
let info = CLASSES
|
||||||
30 + info.base_hp / 3
|
.iter()
|
||||||
|
.find(|c| &c.class == class)
|
||||||
|
.expect("invalid class");
|
||||||
|
constants::BASE_MAX_ENERGY + info.base_hp / constants::MAX_ENERGY_PER_BASE_HP_DIVISOR
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn calc_max_nerve(class: &Class) -> i32 {
|
pub fn calc_max_nerve(class: &Class) -> i32 {
|
||||||
let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class");
|
let info = CLASSES
|
||||||
100 + info.base_hp / 2
|
.iter()
|
||||||
|
.find(|c| &c.class == class)
|
||||||
|
.expect("invalid class");
|
||||||
|
constants::BASE_MAX_NERVE + info.base_hp / constants::MAX_NERVE_PER_BASE_HP_DIVISOR
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn energy_regen_rate() -> i64 {
|
pub fn energy_regen_rate() -> i64 {
|
||||||
180000
|
constants::ENERGY_REGEN_INTERVAL_MS
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn nerve_regen_rate() -> i64 {
|
pub fn nerve_regen_rate() -> i64 {
|
||||||
300000
|
constants::NERVE_REGEN_INTERVAL_MS
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hp_regen_rate() -> i64 {
|
pub fn hp_regen_rate() -> i64 {
|
||||||
300000
|
constants::HP_REGEN_INTERVAL_MS
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_regen(char: &mut Character, now_ms: i64) {
|
pub fn apply_regen(char: &mut Character, now_ms: i64) {
|
||||||
let elapsed = now_ms - char.last_regen;
|
let elapsed = now_ms - char.last_regen;
|
||||||
if elapsed <= 0 { return; }
|
if elapsed <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let energy_ticks = (elapsed / energy_regen_rate()) as i32;
|
let energy_ticks = (elapsed / energy_regen_rate()) as i32;
|
||||||
let nerve_ticks = (elapsed / nerve_regen_rate()) as i32;
|
let nerve_ticks = (elapsed / nerve_regen_rate()) as i32;
|
||||||
let hp_ticks = (elapsed / hp_regen_rate()) as i32;
|
let hp_ticks = (elapsed / hp_regen_rate()) as i32;
|
||||||
|
|
@ -115,15 +261,16 @@ pub fn apply_regen(char: &mut Character, now_ms: i64) {
|
||||||
char.nerve = (char.nerve + nerve_ticks).min(char.max_nerve);
|
char.nerve = (char.nerve + nerve_ticks).min(char.max_nerve);
|
||||||
}
|
}
|
||||||
if hp_ticks > 0 && char.hp > 0 && char.hp < char.max_hp {
|
if hp_ticks > 0 && char.hp > 0 && char.hp < char.max_hp {
|
||||||
let regen_per_tick = (char.max_hp as f64 * 0.05).ceil() as i32;
|
let regen_per_tick =
|
||||||
|
(char.max_hp as f64 * constants::HP_REGEN_FRACTION_PER_TICK).ceil() as i32;
|
||||||
char.hp = (char.hp + regen_per_tick * hp_ticks).min(char.max_hp);
|
char.hp = (char.hp + regen_per_tick * hp_ticks).min(char.max_hp);
|
||||||
}
|
}
|
||||||
char.last_regen = now_ms;
|
char.last_regen = now_ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn training_cost(stat: i64) -> i32 {
|
pub fn training_cost(stat: i64) -> i32 {
|
||||||
let scaled = (stat as f64 / 10.0).floor() as i32;
|
let scaled = (stat as f64 / constants::TRAINING_STAT_SCALER).floor() as i32;
|
||||||
(5.0_f64 * 1.3_f64.powi(scaled)) as i32
|
(constants::TRAINING_BASE_COST * constants::TRAINING_GROWTH_FACTOR.powi(scaled)) as i32
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_character(
|
pub fn create_character(
|
||||||
|
|
@ -140,7 +287,8 @@ pub fn create_character(
|
||||||
let max_nerve = calc_max_nerve(&class);
|
let max_nerve = calc_max_nerve(&class);
|
||||||
|
|
||||||
let known_spells: Vec<String> = if spells::is_caster_class(&class_str) {
|
let known_spells: Vec<String> = if spells::is_caster_class(&class_str) {
|
||||||
spells::spells_for_class(&class_str, 1).iter()
|
spells::spells_for_class(&class_str, 1)
|
||||||
|
.iter()
|
||||||
.filter(|s| s.spell_level <= 1)
|
.filter(|s| s.spell_level <= 1)
|
||||||
.map(|s| s.id.clone())
|
.map(|s| s.id.clone())
|
||||||
.collect()
|
.collect()
|
||||||
|
|
@ -154,7 +302,7 @@ pub fn create_character(
|
||||||
name,
|
name,
|
||||||
race,
|
race,
|
||||||
class,
|
class,
|
||||||
level: 1,
|
level: constants::STARTING_LEVEL,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
stats,
|
stats,
|
||||||
hp: max_hp,
|
hp: max_hp,
|
||||||
|
|
@ -163,7 +311,7 @@ pub fn create_character(
|
||||||
max_energy,
|
max_energy,
|
||||||
nerve: max_nerve,
|
nerve: max_nerve,
|
||||||
max_nerve,
|
max_nerve,
|
||||||
gold: 50,
|
gold: constants::STARTING_GOLD,
|
||||||
last_regen: 0,
|
last_regen: 0,
|
||||||
equipment: [
|
equipment: [
|
||||||
("head".into(), None),
|
("head".into(), None),
|
||||||
|
|
@ -177,7 +325,9 @@ pub fn create_character(
|
||||||
("ring".into(), None),
|
("ring".into(), None),
|
||||||
("trinket".into(), None),
|
("trinket".into(), None),
|
||||||
("instrument".into(), None),
|
("instrument".into(), None),
|
||||||
].into_iter().collect(),
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
inventory: Vec::new(),
|
inventory: Vec::new(),
|
||||||
active_song: None,
|
active_song: None,
|
||||||
known_spells,
|
known_spells,
|
||||||
|
|
@ -191,26 +341,28 @@ pub const MAX_LEVEL: i32 = 100;
|
||||||
pub const XP_FACTOR: i64 = 18;
|
pub const XP_FACTOR: i64 = 18;
|
||||||
|
|
||||||
pub fn xp_for_level(level: i32) -> i64 {
|
pub fn xp_for_level(level: i32) -> i64 {
|
||||||
if level <= 1 { return 0; }
|
if level <= 1 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
let n = (level - 1) as f64;
|
let n = (level - 1) as f64;
|
||||||
XP_FACTOR * (100.0 * n * n) as i64
|
XP_FACTOR * (constants::XP_CURVE_COEFFICIENT * n * n) as i64
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn level_up_cost(level: i32) -> i32 {
|
pub fn level_up_cost(level: i32) -> i32 {
|
||||||
500 * level
|
constants::LEVEL_UP_GOLD_PER_LEVEL * level
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn level_up(char: &mut Character) -> Result<i32, &'static str> {
|
pub fn level_up(char: &mut Character) -> Result<i32, DomainError> {
|
||||||
if char.level >= MAX_LEVEL {
|
if char.level >= MAX_LEVEL {
|
||||||
return Err("already at max level");
|
return Err(DomainError::AlreadyAtMaxLevel);
|
||||||
}
|
}
|
||||||
let next_level = char.level + 1;
|
let next_level = char.level + 1;
|
||||||
if char.xp < xp_for_level(next_level) {
|
if char.xp < xp_for_level(next_level) {
|
||||||
return Err("not enough XP");
|
return Err(DomainError::NotEnoughXp);
|
||||||
}
|
}
|
||||||
let cost = level_up_cost(next_level);
|
let cost = level_up_cost(next_level);
|
||||||
if char.gold < cost {
|
if char.gold < cost {
|
||||||
return Err("not enough gold");
|
return Err(DomainError::NotEnoughGold);
|
||||||
}
|
}
|
||||||
char.gold -= cost;
|
char.gold -= cost;
|
||||||
char.level = next_level;
|
char.level = next_level;
|
||||||
|
|
@ -227,11 +379,38 @@ pub fn level_up(char: &mut Character) -> Result<i32, &'static str> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_race_info(race: &Race) -> &'static RaceInfo {
|
pub fn find_race_info(race: &Race) -> &'static RaceInfo {
|
||||||
RACES.iter().find(|r| &r.race == race).expect("invalid race")
|
RACES
|
||||||
|
.iter()
|
||||||
|
.find(|r| &r.race == race)
|
||||||
|
.expect("invalid race")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_class_info(class: &Class) -> &'static ClassInfo {
|
pub fn find_class_info(class: &Class) -> &'static ClassInfo {
|
||||||
CLASSES.iter().find(|c| &c.class == class).expect("invalid class")
|
CLASSES
|
||||||
|
.iter()
|
||||||
|
.find(|c| &c.class == class)
|
||||||
|
.expect("invalid class")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gym stat display order — pinned to the get_gym handler's fixed array
|
||||||
|
/// (routes/mod.rs:588–594), deliberately differs from TRAINING_GROUNDS order.
|
||||||
|
pub const GYM_STAT_DISPLAY_ORDER: &[&str] = &[
|
||||||
|
"strength",
|
||||||
|
"intelligence",
|
||||||
|
"dexterity",
|
||||||
|
"constitution",
|
||||||
|
"luck",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn stat_value(stats: &CharacterStats, stat: &str) -> Option<i64> {
|
||||||
|
match stat {
|
||||||
|
"strength" => Some(stats.strength),
|
||||||
|
"intelligence" => Some(stats.intelligence),
|
||||||
|
"dexterity" => Some(stats.dexterity),
|
||||||
|
"constitution" => Some(stats.constitution),
|
||||||
|
"luck" => Some(stats.luck),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -242,15 +421,35 @@ mod tests {
|
||||||
|
|
||||||
fn base_char() -> Character {
|
fn base_char() -> Character {
|
||||||
Character {
|
Character {
|
||||||
id: 1, user_id: 1, name: "Test".into(),
|
id: 1,
|
||||||
race: Race::Human, class: Class::Warrior,
|
user_id: 1,
|
||||||
level: 1, xp: 0,
|
name: "Test".into(),
|
||||||
stats: CharacterStats { strength: 20, intelligence: 20, dexterity: 20, constitution: 20, luck: 20 },
|
race: Race::Human,
|
||||||
hp: 80, max_hp: 80, energy: 36, max_energy: 36,
|
class: Class::Warrior,
|
||||||
nerve: 110, max_nerve: 110, gold: 0, last_regen: 0,
|
level: 1,
|
||||||
equipment: HashMap::new(), inventory: Vec::new(),
|
xp: 0,
|
||||||
active_song: None, known_spells: Vec::new(),
|
stats: CharacterStats {
|
||||||
active_spell: None, spell_points: 0, max_spell_points: 0,
|
strength: 20,
|
||||||
|
intelligence: 20,
|
||||||
|
dexterity: 20,
|
||||||
|
constitution: 20,
|
||||||
|
luck: 20,
|
||||||
|
},
|
||||||
|
hp: 80,
|
||||||
|
max_hp: 80,
|
||||||
|
energy: 36,
|
||||||
|
max_energy: 36,
|
||||||
|
nerve: 110,
|
||||||
|
max_nerve: 110,
|
||||||
|
gold: 0,
|
||||||
|
last_regen: 0,
|
||||||
|
equipment: HashMap::new(),
|
||||||
|
inventory: Vec::new(),
|
||||||
|
active_song: None,
|
||||||
|
known_spells: Vec::new(),
|
||||||
|
active_spell: None,
|
||||||
|
spell_points: 0,
|
||||||
|
max_spell_points: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,7 +505,7 @@ mod tests {
|
||||||
c.level = MAX_LEVEL;
|
c.level = MAX_LEVEL;
|
||||||
c.xp = i64::MAX;
|
c.xp = i64::MAX;
|
||||||
c.gold = i32::MAX;
|
c.gold = i32::MAX;
|
||||||
assert_eq!(level_up(&mut c), Err("already at max level"));
|
assert_eq!(level_up(&mut c), Err(DomainError::AlreadyAtMaxLevel));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -335,7 +534,7 @@ mod tests {
|
||||||
let mut c = base_char();
|
let mut c = base_char();
|
||||||
c.xp = 100;
|
c.xp = 100;
|
||||||
c.gold = 10000;
|
c.gold = 10000;
|
||||||
assert_eq!(level_up(&mut c), Err("not enough XP"));
|
assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughXp));
|
||||||
assert_eq!(c.level, 1);
|
assert_eq!(c.level, 1);
|
||||||
assert_eq!(c.gold, 10000);
|
assert_eq!(c.gold, 10000);
|
||||||
}
|
}
|
||||||
|
|
@ -345,14 +544,26 @@ mod tests {
|
||||||
let mut c = base_char();
|
let mut c = base_char();
|
||||||
c.xp = 1800;
|
c.xp = 1800;
|
||||||
c.gold = 0;
|
c.gold = 0;
|
||||||
assert_eq!(level_up(&mut c), Err("not enough gold"));
|
assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughGold));
|
||||||
assert_eq!(c.level, 1);
|
assert_eq!(c.level, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn calc_max_hp_depends_on_class_and_con() {
|
fn calc_max_hp_depends_on_class_and_con() {
|
||||||
let stats_high = CharacterStats { strength: 10, intelligence: 10, dexterity: 10, constitution: 30, luck: 10 };
|
let stats_high = CharacterStats {
|
||||||
let stats_low = CharacterStats { strength: 10, intelligence: 10, dexterity: 10, constitution: 10, luck: 10 };
|
strength: 10,
|
||||||
|
intelligence: 10,
|
||||||
|
dexterity: 10,
|
||||||
|
constitution: 30,
|
||||||
|
luck: 10,
|
||||||
|
};
|
||||||
|
let stats_low = CharacterStats {
|
||||||
|
strength: 10,
|
||||||
|
intelligence: 10,
|
||||||
|
dexterity: 10,
|
||||||
|
constitution: 10,
|
||||||
|
luck: 10,
|
||||||
|
};
|
||||||
let hp_high = calc_max_hp(&Class::Warrior, &stats_high);
|
let hp_high = calc_max_hp(&Class::Warrior, &stats_high);
|
||||||
let hp_low = calc_max_hp(&Class::Warrior, &stats_low);
|
let hp_low = calc_max_hp(&Class::Warrior, &stats_low);
|
||||||
assert!(hp_high > hp_low);
|
assert!(hp_high > hp_low);
|
||||||
|
|
@ -441,4 +652,70 @@ mod tests {
|
||||||
assert_eq!(first.energy, second.energy);
|
assert_eq!(first.energy, second.energy);
|
||||||
assert_eq!(first.nerve, second.nerve);
|
assert_eq!(first.nerve, second.nerve);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gym_display_order_covers_all_training_grounds() {
|
||||||
|
let stats = CharacterStats {
|
||||||
|
strength: 10,
|
||||||
|
intelligence: 11,
|
||||||
|
dexterity: 12,
|
||||||
|
constitution: 13,
|
||||||
|
luck: 14,
|
||||||
|
};
|
||||||
|
assert_eq!(GYM_STAT_DISPLAY_ORDER.len(), TRAINING_GROUNDS.len());
|
||||||
|
for name in GYM_STAT_DISPLAY_ORDER {
|
||||||
|
assert!(
|
||||||
|
TRAINING_GROUNDS.iter().any(|g| g.stat == *name),
|
||||||
|
"no training ground for stat {}",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stat_value(&stats, name).is_some(),
|
||||||
|
"stat_value cannot resolve {}",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for ground in TRAINING_GROUNDS {
|
||||||
|
assert!(
|
||||||
|
GYM_STAT_DISPLAY_ORDER.contains(&ground.stat),
|
||||||
|
"gym display order missing stat {}",
|
||||||
|
ground.stat
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn race_table_covers_all_variants() {
|
||||||
|
let all = [
|
||||||
|
Race::Human,
|
||||||
|
Race::Elf,
|
||||||
|
Race::Dwarf,
|
||||||
|
Race::Hobbit,
|
||||||
|
Race::HalfElf,
|
||||||
|
];
|
||||||
|
for race in &all {
|
||||||
|
assert!(RACES.iter().any(|r| &r.race == race));
|
||||||
|
let _ = find_race_info(race);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn class_table_covers_all_variants() {
|
||||||
|
let all = [
|
||||||
|
Class::Warrior,
|
||||||
|
Class::Paladin,
|
||||||
|
Class::Rogue,
|
||||||
|
Class::Bard,
|
||||||
|
Class::Hunter,
|
||||||
|
Class::Monk,
|
||||||
|
Class::Mage,
|
||||||
|
Class::Wizard,
|
||||||
|
Class::Conjurer,
|
||||||
|
Class::Sorcerer,
|
||||||
|
];
|
||||||
|
for class in &all {
|
||||||
|
assert!(CLASSES.iter().any(|c| &c.class == class));
|
||||||
|
let _ = find_class_info(class);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use crate::constants;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use sb_shared::{AttackType, CombatLog, CombatRound, CritEntry, CritResult};
|
use sb_shared::{AttackType, CombatLog, CombatRound, CritEntry, CritResult};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -7,9 +8,8 @@ use std::sync::LazyLock;
|
||||||
const CRIT_TABLES_JSON: &str =
|
const CRIT_TABLES_JSON: &str =
|
||||||
include_str!("../../../../skara-brae-common/data/crit-tables/combat.json");
|
include_str!("../../../../skara-brae-common/data/crit-tables/combat.json");
|
||||||
|
|
||||||
static CRIT_TABLES: LazyLock<HashMap<String, HashMap<String, CritEntry>>> = LazyLock::new(|| {
|
static CRIT_TABLES: LazyLock<HashMap<String, HashMap<String, CritEntry>>> =
|
||||||
serde_json::from_str(CRIT_TABLES_JSON).expect("failed to parse crit tables")
|
LazyLock::new(|| serde_json::from_str(CRIT_TABLES_JSON).expect("failed to parse crit tables"));
|
||||||
});
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CombatStats {
|
pub struct CombatStats {
|
||||||
|
|
@ -33,7 +33,8 @@ impl CombatStats {
|
||||||
|
|
||||||
pub fn defensive_bonus(&self) -> i32 {
|
pub fn defensive_bonus(&self) -> i32 {
|
||||||
let dex_bonus = super::character::stat_bonus(self.dexterity) as i32;
|
let dex_bonus = super::character::stat_bonus(self.dexterity) as i32;
|
||||||
let con_bonus = (super::character::stat_bonus(self.constitution) as i32) / 2;
|
let con_bonus = (super::character::stat_bonus(self.constitution) as i32)
|
||||||
|
/ constants::DEFENSIVE_CON_BONUS_DIVISOR;
|
||||||
dex_bonus + con_bonus + self.db_base
|
dex_bonus + con_bonus + self.db_base
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,21 +54,27 @@ pub fn crit_damage_cap(attacker_level: i32, defender_level: i32, defender_hp: i3
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let cap_pct = match diff {
|
let cap_pct = match diff {
|
||||||
1..=9 => return None,
|
1..=constants::CRIT_CAP_DISABLED_MAX_LEVEL_GAP => return None,
|
||||||
10..=19 => 0.60,
|
constants::CRIT_CAP_TIER1_MIN_GAP..=constants::CRIT_CAP_TIER1_MAX_GAP => {
|
||||||
20..=49 => 0.40,
|
constants::CRIT_CAP_TIER1_RATIO
|
||||||
50..=99 => 0.15,
|
}
|
||||||
_ => 0.05,
|
constants::CRIT_CAP_TIER2_MIN_GAP..=constants::CRIT_CAP_TIER2_MAX_GAP => {
|
||||||
|
constants::CRIT_CAP_TIER2_RATIO
|
||||||
|
}
|
||||||
|
constants::CRIT_CAP_TIER3_MIN_GAP..=constants::CRIT_CAP_TIER3_MAX_GAP => {
|
||||||
|
constants::CRIT_CAP_TIER3_RATIO
|
||||||
|
}
|
||||||
|
_ => constants::CRIT_CAP_TIER4_RATIO,
|
||||||
};
|
};
|
||||||
Some((defender_hp as f64 * cap_pct) as i32)
|
Some((defender_hp as f64 * cap_pct) as i32)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open_ended_roll(rng: &mut impl Rng) -> i32 {
|
pub fn open_ended_roll(rng: &mut impl Rng) -> i32 {
|
||||||
let roll: i32 = rng.random_range(1..=100);
|
let roll: i32 = rng.random_range(constants::ATTACK_ROLL_MIN..=constants::ATTACK_ROLL_MAX);
|
||||||
let mut total = roll;
|
let mut total = roll;
|
||||||
if roll >= 96 {
|
if roll >= constants::OPEN_END_HIGH_THRESHOLD {
|
||||||
total += open_ended_roll(rng);
|
total += open_ended_roll(rng);
|
||||||
} else if roll <= 5 {
|
} else if roll <= constants::OPEN_END_LOW_THRESHOLD {
|
||||||
total -= open_ended_roll(rng);
|
total -= open_ended_roll(rng);
|
||||||
}
|
}
|
||||||
total
|
total
|
||||||
|
|
@ -93,7 +100,8 @@ pub fn resolve_attack(
|
||||||
&& let Some(crit_entry) = lookup_crit(&attacker.attack_type, &severity)
|
&& let Some(crit_entry) = lookup_crit(&attacker.attack_type, &severity)
|
||||||
{
|
{
|
||||||
let crit_damage = (damage as f64 * crit_entry.damage_multiplier) as i32;
|
let crit_damage = (damage as f64 * crit_entry.damage_multiplier) as i32;
|
||||||
let capped = if let Some(cap) = crit_damage_cap(attacker.level, defender.level, defender.hp) {
|
let capped =
|
||||||
|
if let Some(cap) = crit_damage_cap(attacker.level, defender.level, defender.hp) {
|
||||||
crit_damage.min(cap)
|
crit_damage.min(cap)
|
||||||
} else {
|
} else {
|
||||||
crit_damage
|
crit_damage
|
||||||
|
|
@ -120,13 +128,13 @@ pub fn resolve_attack(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn crit_severity(result: i32) -> Option<String> {
|
pub fn crit_severity(result: i32) -> Option<String> {
|
||||||
if result >= 151 {
|
if result >= constants::CRIT_SEVERITY_D_MIN {
|
||||||
Some("D".into())
|
Some("D".into())
|
||||||
} else if result >= 101 {
|
} else if result >= constants::CRIT_SEVERITY_C_MIN {
|
||||||
Some("C".into())
|
Some("C".into())
|
||||||
} else if result >= 51 {
|
} else if result >= constants::CRIT_SEVERITY_B_MIN {
|
||||||
Some("B".into())
|
Some("B".into())
|
||||||
} else if result >= 21 {
|
} else if result >= constants::CRIT_SEVERITY_A_MIN {
|
||||||
Some("A".into())
|
Some("A".into())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
@ -155,7 +163,9 @@ pub fn resolve_combat(
|
||||||
gold_max: i32,
|
gold_max: i32,
|
||||||
rng: &mut impl Rng,
|
rng: &mut impl Rng,
|
||||||
) -> CombatLog {
|
) -> CombatLog {
|
||||||
resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, None, None, rng)
|
resolve_combat_full(
|
||||||
|
attacker, defender, xp_reward, gold_min, gold_max, None, None, rng,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_combat_with_song(
|
pub fn resolve_combat_with_song(
|
||||||
|
|
@ -167,7 +177,16 @@ pub fn resolve_combat_with_song(
|
||||||
song_effect: Option<&str>,
|
song_effect: Option<&str>,
|
||||||
rng: &mut impl Rng,
|
rng: &mut impl Rng,
|
||||||
) -> CombatLog {
|
) -> CombatLog {
|
||||||
resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, song_effect, None, rng)
|
resolve_combat_full(
|
||||||
|
attacker,
|
||||||
|
defender,
|
||||||
|
xp_reward,
|
||||||
|
gold_min,
|
||||||
|
gold_max,
|
||||||
|
song_effect,
|
||||||
|
None,
|
||||||
|
rng,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
|
@ -187,7 +206,11 @@ pub fn resolve_combat_full(
|
||||||
let mut rounds = Vec::new();
|
let mut rounds = Vec::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let mut round = resolve_attack(&mut rng, &apply_song_to_attacker(&attacker, song_effect), &apply_song_to_defender(&defender, song_effect));
|
let mut round = resolve_attack(
|
||||||
|
&mut rng,
|
||||||
|
&apply_song_to_attacker(&attacker, song_effect),
|
||||||
|
&apply_song_to_defender(&defender, song_effect),
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(sp) = spell
|
if let Some(sp) = spell
|
||||||
&& sp.combat_effect == "damage"
|
&& sp.combat_effect == "damage"
|
||||||
|
|
@ -211,7 +234,11 @@ pub fn resolve_combat_full(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut round = resolve_attack(&mut rng, &defender, &apply_song_to_defender(&make_temp_stats(&attacker, a_hp), song_effect));
|
let mut round = resolve_attack(
|
||||||
|
&mut rng,
|
||||||
|
&defender,
|
||||||
|
&apply_song_to_defender(&make_temp_stats(&attacker, a_hp), song_effect),
|
||||||
|
);
|
||||||
a_hp = (a_hp - round.damage).max(0);
|
a_hp = (a_hp - round.damage).max(0);
|
||||||
round.defender_hp = a_hp;
|
round.defender_hp = a_hp;
|
||||||
rounds.push(round);
|
rounds.push(round);
|
||||||
|
|
@ -227,12 +254,14 @@ pub fn resolve_combat_full(
|
||||||
}
|
}
|
||||||
|
|
||||||
if song_effect == Some("heal") && a_hp > 0 && a_hp < attacker.hp {
|
if song_effect == Some("heal") && a_hp > 0 && a_hp < attacker.hp {
|
||||||
let heal_amt = (attacker.hp as f64 * 0.03).ceil() as i32;
|
let heal_amt = (attacker.hp as f64 * constants::BARD_SONG_HEAL_FRACTION).ceil() as i32;
|
||||||
a_hp = (a_hp + heal_amt).min(attacker.hp);
|
a_hp = (a_hp + heal_amt).min(attacker.hp);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(sp) = spell
|
if let Some(sp) = spell
|
||||||
&& sp.combat_effect == "heal" && a_hp > 0 && a_hp < attacker.hp
|
&& sp.combat_effect == "heal"
|
||||||
|
&& a_hp > 0
|
||||||
|
&& a_hp < attacker.hp
|
||||||
{
|
{
|
||||||
let heal_amt = rng.random_range(sp.heal_min..=sp.heal_max);
|
let heal_amt = rng.random_range(sp.heal_min..=sp.heal_max);
|
||||||
a_hp = (a_hp + heal_amt).min(attacker.hp);
|
a_hp = (a_hp + heal_amt).min(attacker.hp);
|
||||||
|
|
@ -243,8 +272,12 @@ pub fn resolve_combat_full(
|
||||||
fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
|
fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
|
||||||
let mut s = stats.clone();
|
let mut s = stats.clone();
|
||||||
match song_effect {
|
match song_effect {
|
||||||
Some("accuracy") => { s.ob_base += 15; }
|
Some("accuracy") => {
|
||||||
Some("damage") => { s.ob_base += 20; }
|
s.ob_base += constants::SONG_ACCURACY_OB_BONUS;
|
||||||
|
}
|
||||||
|
Some("damage") => {
|
||||||
|
s.ob_base += constants::SONG_DAMAGE_OB_BONUS;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
s
|
s
|
||||||
|
|
@ -253,8 +286,12 @@ fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> Com
|
||||||
fn apply_song_to_defender(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
|
fn apply_song_to_defender(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
|
||||||
let mut s = stats.clone();
|
let mut s = stats.clone();
|
||||||
match song_effect {
|
match song_effect {
|
||||||
Some("defense") | Some("magic_resist") => { s.db_base += 10; }
|
Some("defense") | Some("magic_resist") => {
|
||||||
Some("weaken") => { s.ob_base = (s.ob_base as f64 * 0.7) as i32; }
|
s.db_base += constants::SONG_DEFENSE_DB_BONUS;
|
||||||
|
}
|
||||||
|
Some("weaken") => {
|
||||||
|
s.ob_base = (s.ob_base as f64 * constants::SONG_WEAKEN_OB_FACTOR) as i32;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
s
|
s
|
||||||
|
|
@ -272,18 +309,30 @@ mod tests {
|
||||||
|
|
||||||
fn attacker() -> CombatStats {
|
fn attacker() -> CombatStats {
|
||||||
CombatStats {
|
CombatStats {
|
||||||
name: "Hero".into(), hp: 100, level: 1,
|
name: "Hero".into(),
|
||||||
strength: 50, dexterity: 40, constitution: 40, luck: 20,
|
hp: 100,
|
||||||
ob_base: 20, db_base: 10,
|
level: 1,
|
||||||
|
strength: 50,
|
||||||
|
dexterity: 40,
|
||||||
|
constitution: 40,
|
||||||
|
luck: 20,
|
||||||
|
ob_base: 20,
|
||||||
|
db_base: 10,
|
||||||
attack_type: AttackType::Slash,
|
attack_type: AttackType::Slash,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn defender() -> CombatStats {
|
fn defender() -> CombatStats {
|
||||||
CombatStats {
|
CombatStats {
|
||||||
name: "Goblin".into(), hp: 50, level: 1,
|
name: "Goblin".into(),
|
||||||
strength: 30, dexterity: 30, constitution: 30, luck: 10,
|
hp: 50,
|
||||||
ob_base: 10, db_base: 5,
|
level: 1,
|
||||||
|
strength: 30,
|
||||||
|
dexterity: 30,
|
||||||
|
constitution: 30,
|
||||||
|
luck: 10,
|
||||||
|
ob_base: 10,
|
||||||
|
db_base: 5,
|
||||||
attack_type: AttackType::Crush,
|
attack_type: AttackType::Crush,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -294,7 +343,11 @@ mod tests {
|
||||||
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
|
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
|
||||||
for _ in 0..1000 {
|
for _ in 0..1000 {
|
||||||
let roll = open_ended_roll(&mut rng);
|
let roll = open_ended_roll(&mut rng);
|
||||||
assert!(roll > -500 && roll < 500, "roll out of expected range: {}", roll);
|
assert!(
|
||||||
|
roll > -500 && roll < 500,
|
||||||
|
"roll out of expected range: {}",
|
||||||
|
roll
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -364,8 +417,16 @@ mod tests {
|
||||||
fn parse_attack_type_maps_known_types() {
|
fn parse_attack_type_maps_known_types() {
|
||||||
assert!(matches!(parse_attack_type("slash"), AttackType::Slash));
|
assert!(matches!(parse_attack_type("slash"), AttackType::Slash));
|
||||||
assert!(matches!(parse_attack_type("crush"), AttackType::Crush));
|
assert!(matches!(parse_attack_type("crush"), AttackType::Crush));
|
||||||
assert!(matches!(parse_attack_type("puncture"), AttackType::Puncture));
|
assert!(matches!(
|
||||||
|
parse_attack_type("puncture"),
|
||||||
|
AttackType::Puncture
|
||||||
|
));
|
||||||
assert!(matches!(parse_attack_type("unarmed"), AttackType::Crush));
|
assert!(matches!(parse_attack_type("unarmed"), AttackType::Crush));
|
||||||
assert!(matches!(parse_attack_type("unknown"), AttackType::Crush));
|
assert!(matches!(parse_attack_type("unknown"), AttackType::Crush));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crit_tables_parse() {
|
||||||
|
assert!(!CRIT_TABLES.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
131
crates/sb-core/src/constants.rs
Normal file
131
crates/sb-core/src/constants.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
//! Game-balance constants — the single audit home for tuning scalars.
|
||||||
|
//! Static data tables (`RACES`, `CLASSES`, `BARD_SONGS`, `TRAINING_GROUNDS`)
|
||||||
|
//! stay in their modules; only tuning scalars live here.
|
||||||
|
|
||||||
|
pub use crate::character::{MAX_LEVEL, XP_FACTOR};
|
||||||
|
|
||||||
|
/// d100 attack roll bounds (origin: combat.rs:66).
|
||||||
|
pub const ATTACK_ROLL_MIN: i32 = 1;
|
||||||
|
/// d100 attack roll bounds (origin: combat.rs:66).
|
||||||
|
pub const ATTACK_ROLL_MAX: i32 = 100;
|
||||||
|
|
||||||
|
/// Open-ended roll: re-roll upward at or above this value (origin: combat.rs:68).
|
||||||
|
pub const OPEN_END_HIGH_THRESHOLD: i32 = 96;
|
||||||
|
/// Open-ended roll: re-roll downward at or below this value (origin: combat.rs:70).
|
||||||
|
pub const OPEN_END_LOW_THRESHOLD: i32 = 5;
|
||||||
|
|
||||||
|
/// Crit severity A floor (origin: combat.rs:129).
|
||||||
|
pub const CRIT_SEVERITY_A_MIN: i32 = 21;
|
||||||
|
/// Crit severity B floor (origin: combat.rs:127).
|
||||||
|
pub const CRIT_SEVERITY_B_MIN: i32 = 51;
|
||||||
|
/// Crit severity C floor (origin: combat.rs:125).
|
||||||
|
pub const CRIT_SEVERITY_C_MIN: i32 = 101;
|
||||||
|
/// Crit severity D floor (origin: combat.rs:123).
|
||||||
|
pub const CRIT_SEVERITY_D_MIN: i32 = 151;
|
||||||
|
|
||||||
|
/// Level gaps up to this value apply no crit cap (origin: combat.rs:56).
|
||||||
|
pub const CRIT_CAP_DISABLED_MAX_LEVEL_GAP: i32 = 9;
|
||||||
|
/// Crit cap tier 1 level-gap range (origin: combat.rs:57).
|
||||||
|
pub const CRIT_CAP_TIER1_MIN_GAP: i32 = 10;
|
||||||
|
/// Crit cap tier 1 level-gap range (origin: combat.rs:57).
|
||||||
|
pub const CRIT_CAP_TIER1_MAX_GAP: i32 = 19;
|
||||||
|
/// Crit cap tier 2 level-gap range (origin: combat.rs:58).
|
||||||
|
pub const CRIT_CAP_TIER2_MIN_GAP: i32 = 20;
|
||||||
|
/// Crit cap tier 2 level-gap range (origin: combat.rs:58).
|
||||||
|
pub const CRIT_CAP_TIER2_MAX_GAP: i32 = 49;
|
||||||
|
/// Crit cap tier 3 level-gap range (origin: combat.rs:59).
|
||||||
|
pub const CRIT_CAP_TIER3_MIN_GAP: i32 = 50;
|
||||||
|
/// Crit cap tier 3 level-gap range (origin: combat.rs:59).
|
||||||
|
pub const CRIT_CAP_TIER3_MAX_GAP: i32 = 99;
|
||||||
|
/// Crit cap tier 4 applies from this gap upward (origin: combat.rs:60).
|
||||||
|
pub const CRIT_CAP_TIER4_MIN_GAP: i32 = 100;
|
||||||
|
|
||||||
|
/// Crit damage cap ratio for level gaps 10–19 (origin: combat.rs:57).
|
||||||
|
pub const CRIT_CAP_TIER1_RATIO: f64 = 0.60;
|
||||||
|
/// Crit damage cap ratio for level gaps 20–49 (origin: combat.rs:58).
|
||||||
|
pub const CRIT_CAP_TIER2_RATIO: f64 = 0.40;
|
||||||
|
/// Crit damage cap ratio for level gaps 50–99 (origin: combat.rs:59).
|
||||||
|
pub const CRIT_CAP_TIER3_RATIO: f64 = 0.15;
|
||||||
|
/// Crit damage cap ratio for level gaps 100 and above (origin: combat.rs:60).
|
||||||
|
pub const CRIT_CAP_TIER4_RATIO: f64 = 0.05;
|
||||||
|
|
||||||
|
/// Constitution contributes half its stat bonus to defense (origin: combat.rs:36).
|
||||||
|
pub const DEFENSIVE_CON_BONUS_DIVISOR: i32 = 2;
|
||||||
|
|
||||||
|
/// Accuracy song offensive-bonus bonus (origin: combat.rs:246).
|
||||||
|
pub const SONG_ACCURACY_OB_BONUS: i32 = 15;
|
||||||
|
/// Damage song offensive-bonus bonus (origin: combat.rs:247).
|
||||||
|
pub const SONG_DAMAGE_OB_BONUS: i32 = 20;
|
||||||
|
/// Defense song defensive-bonus bonus (origin: combat.rs:256).
|
||||||
|
pub const SONG_DEFENSE_DB_BONUS: i32 = 10;
|
||||||
|
/// Weaken song reduces the foe's offensive bonus to this fraction (origin: combat.rs:257).
|
||||||
|
pub const SONG_WEAKEN_OB_FACTOR: f64 = 0.7;
|
||||||
|
|
||||||
|
/// Bard heal song: fraction of max HP restored per round (origin: combat.rs:230).
|
||||||
|
pub const BARD_SONG_HEAL_FRACTION: f64 = 0.03;
|
||||||
|
|
||||||
|
/// Stat bonus curve: bonus is zero at this stat value (origin: character.rs:59).
|
||||||
|
pub const STAT_BONUS_NEUTRAL: i64 = 20;
|
||||||
|
/// Stat bonus curve: bonus step size per 2 stat points (origin: character.rs:59).
|
||||||
|
pub const STAT_BONUS_STEP: i64 = 2;
|
||||||
|
|
||||||
|
/// Character creation stat roll bounds (origin: character.rs:63).
|
||||||
|
pub const STAT_ROLL_MIN: i64 = 6;
|
||||||
|
/// Character creation stat roll bounds (origin: character.rs:63).
|
||||||
|
pub const STAT_ROLL_MAX: i64 = 16;
|
||||||
|
|
||||||
|
/// Base maximum energy before the class contribution (origin: character.rs:85).
|
||||||
|
pub const BASE_MAX_ENERGY: i32 = 30;
|
||||||
|
/// Energy gains one point per this many base HP (origin: character.rs:85).
|
||||||
|
pub const MAX_ENERGY_PER_BASE_HP_DIVISOR: i32 = 3;
|
||||||
|
|
||||||
|
/// Base maximum nerve before the class contribution (origin: character.rs:90).
|
||||||
|
pub const BASE_MAX_NERVE: i32 = 100;
|
||||||
|
/// Nerve gains one point per this many base HP (origin: character.rs:90).
|
||||||
|
pub const MAX_NERVE_PER_BASE_HP_DIVISOR: i32 = 2;
|
||||||
|
|
||||||
|
/// Energy regen tick interval in milliseconds (origin: character.rs:94).
|
||||||
|
pub const ENERGY_REGEN_INTERVAL_MS: i64 = 180_000;
|
||||||
|
/// Nerve regen tick interval in milliseconds (origin: character.rs:98).
|
||||||
|
pub const NERVE_REGEN_INTERVAL_MS: i64 = 300_000;
|
||||||
|
/// HP regen tick interval in milliseconds (origin: character.rs:102).
|
||||||
|
pub const HP_REGEN_INTERVAL_MS: i64 = 300_000;
|
||||||
|
|
||||||
|
/// HP restored per regen tick, as a fraction of max HP (origin: character.rs:118).
|
||||||
|
pub const HP_REGEN_FRACTION_PER_TICK: f64 = 0.05;
|
||||||
|
|
||||||
|
/// Training cost curve base cost (origin: character.rs:126).
|
||||||
|
pub const TRAINING_BASE_COST: f64 = 5.0;
|
||||||
|
/// Training cost curve growth factor (origin: character.rs:126).
|
||||||
|
pub const TRAINING_GROWTH_FACTOR: f64 = 1.3;
|
||||||
|
/// Training cost curve stat scaler (origin: character.rs:125).
|
||||||
|
pub const TRAINING_STAT_SCALER: f64 = 10.0;
|
||||||
|
|
||||||
|
/// Energy spent per training session (origin: services.rs:85, 100).
|
||||||
|
pub const TRAINING_ENERGY_COST: i32 = 1;
|
||||||
|
|
||||||
|
/// Level assigned at character creation (origin: character.rs:157).
|
||||||
|
pub const STARTING_LEVEL: i32 = 1;
|
||||||
|
/// Gold assigned at character creation (origin: character.rs:166).
|
||||||
|
pub const STARTING_GOLD: i32 = 50;
|
||||||
|
|
||||||
|
/// XP curve coefficient: 18 · 100 · (level−1)² (origin: character.rs:196).
|
||||||
|
pub const XP_CURVE_COEFFICIENT: f64 = 100.0;
|
||||||
|
|
||||||
|
/// Gold cost per target level for level-up (origin: character.rs:200).
|
||||||
|
pub const LEVEL_UP_GOLD_PER_LEVEL: i32 = 500;
|
||||||
|
|
||||||
|
/// Caster levels per spell level: (level / 3) + 1 curve (origin: spells.rs:59).
|
||||||
|
pub const CASTER_LEVELS_PER_SPELL_LEVEL: i32 = 3;
|
||||||
|
/// Spell points granted per caster spell level (origin: spells.rs:62).
|
||||||
|
pub const SPELL_POINTS_PER_SPELL_LEVEL: i32 = 10;
|
||||||
|
|
||||||
|
/// Spell learn cost table, indexed by spell level − 1 (origin: spells.rs:44–52).
|
||||||
|
pub const SPELL_LEARN_COST_BY_LEVEL: [i32; 7] = [0, 1000, 2000, 4000, 7000, 10000, 20000];
|
||||||
|
/// Learn cost reported for spell levels outside 1..=7 (origin: spells.rs:52).
|
||||||
|
pub const SPELL_LEARN_COST_UNKNOWN_LEVEL: i32 = 999_999;
|
||||||
|
|
||||||
|
/// Lowest learn-request spell level — request validation only (origin: routes/mod.rs:1146).
|
||||||
|
pub const MIN_SPELL_LEVEL: i32 = 1;
|
||||||
|
/// Highest learn-request spell level — request validation only (origin: routes/mod.rs:1146).
|
||||||
|
pub const MAX_SPELL_LEVEL: i32 = 7;
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const ENEMIES_JSON: &str =
|
const ENEMIES_JSON: &str = include_str!("../../../../skara-brae-common/data/enemies.json");
|
||||||
include_str!("../../../../skara-brae-common/data/enemies.json");
|
const ZONES_JSON: &str = include_str!("../../../../skara-brae-common/data/adventures/zones.json");
|
||||||
const ZONES_JSON: &str =
|
|
||||||
include_str!("../../../../skara-brae-common/data/adventures/zones.json");
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct EnemyDef {
|
pub struct EnemyDef {
|
||||||
|
|
@ -61,3 +59,18 @@ pub fn load_enemies() -> Vec<EnemyDef> {
|
||||||
pub fn load_zones() -> Vec<ZoneDef> {
|
pub fn load_zones() -> Vec<ZoneDef> {
|
||||||
serde_json::from_str(ZONES_JSON).expect("failed to parse zones.json")
|
serde_json::from_str(ZONES_JSON).expect("failed to parse zones.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enemies_json_parses() {
|
||||||
|
assert!(!load_enemies().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zones_json_parses() {
|
||||||
|
assert!(!load_zones().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
23
crates/sb-core/src/error.rs
Normal file
23
crates/sb-core/src/error.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum DomainError {
|
||||||
|
#[error("character is incapacitated")]
|
||||||
|
CharacterIncapacitated,
|
||||||
|
#[error("not enough nerve")]
|
||||||
|
NotEnoughNerve,
|
||||||
|
#[error("not enough energy")]
|
||||||
|
NotEnoughEnergy,
|
||||||
|
#[error("not enough gold")]
|
||||||
|
NotEnoughGold,
|
||||||
|
#[error("not enough XP")]
|
||||||
|
NotEnoughXp,
|
||||||
|
#[error("already at max level")]
|
||||||
|
AlreadyAtMaxLevel,
|
||||||
|
#[error("invalid stat name")]
|
||||||
|
InvalidStat,
|
||||||
|
#[error("item not in inventory")]
|
||||||
|
ItemNotInInventory,
|
||||||
|
#[error("nothing equipped in slot")]
|
||||||
|
NothingEquippedInSlot,
|
||||||
|
#[error("encounter references unknown enemy")]
|
||||||
|
UnknownEncounterEnemy,
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
use sb_shared::Item;
|
use sb_shared::Item;
|
||||||
|
|
||||||
const WEAPONS_JSON: &str =
|
const WEAPONS_JSON: &str = include_str!("../../../../skara-brae-common/data/items/weapons.json");
|
||||||
include_str!("../../../../skara-brae-common/data/items/weapons.json");
|
const ARMOR_JSON: &str = include_str!("../../../../skara-brae-common/data/items/armor.json");
|
||||||
const ARMOR_JSON: &str =
|
|
||||||
include_str!("../../../../skara-brae-common/data/items/armor.json");
|
|
||||||
const ACCESSORIES_JSON: &str =
|
const ACCESSORIES_JSON: &str =
|
||||||
include_str!("../../../../skara-brae-common/data/items/accessories.json");
|
include_str!("../../../../skara-brae-common/data/items/accessories.json");
|
||||||
|
|
||||||
|
|
@ -14,8 +12,7 @@ pub fn load_all_items() -> Vec<Item> {
|
||||||
serde_json::from_str(WEAPONS_JSON).expect("failed to parse weapons.json");
|
serde_json::from_str(WEAPONS_JSON).expect("failed to parse weapons.json");
|
||||||
items.extend(weapons);
|
items.extend(weapons);
|
||||||
|
|
||||||
let armor: Vec<Item> =
|
let armor: Vec<Item> = serde_json::from_str(ARMOR_JSON).expect("failed to parse armor.json");
|
||||||
serde_json::from_str(ARMOR_JSON).expect("failed to parse armor.json");
|
|
||||||
items.extend(armor);
|
items.extend(armor);
|
||||||
|
|
||||||
let accessories: Vec<Item> =
|
let accessories: Vec<Item> =
|
||||||
|
|
@ -32,3 +29,13 @@ pub fn item_price(item: &Item) -> i32 {
|
||||||
pub fn item_sell_price(item: &Item) -> i32 {
|
pub fn item_sell_price(item: &Item) -> i32 {
|
||||||
item.value / 2
|
item.value / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_items_json_parses() {
|
||||||
|
assert!(!load_all_items().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
pub mod adventure;
|
||||||
pub mod character;
|
pub mod character;
|
||||||
pub mod combat;
|
pub mod combat;
|
||||||
|
pub mod constants;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
|
pub mod error;
|
||||||
pub mod items;
|
pub mod items;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod songs;
|
pub mod songs;
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,43 @@
|
||||||
use sb_shared::{Character, Item, AttackType};
|
use crate::data::{EncounterEntry, EnemyDef};
|
||||||
use crate::data::{EnemyDef, EncounterEntry};
|
use crate::error::DomainError;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
use sb_shared::{AttackType, Character, Item};
|
||||||
|
|
||||||
pub fn pick_enemy(
|
pub fn pick_enemy(
|
||||||
rng: &mut impl Rng,
|
rng: &mut impl Rng,
|
||||||
encounters: &[EncounterEntry],
|
encounters: &[EncounterEntry],
|
||||||
enemies: &[EnemyDef],
|
enemies: &[EnemyDef],
|
||||||
) -> EnemyDef {
|
) -> Result<EnemyDef, DomainError> {
|
||||||
let total_weight: i32 = encounters.iter().map(|e| e.weight).sum();
|
let total_weight: i32 = encounters.iter().map(|e| e.weight).sum();
|
||||||
|
if encounters.is_empty() || total_weight <= 0 {
|
||||||
|
return Err(DomainError::UnknownEncounterEnemy);
|
||||||
|
}
|
||||||
let roll: i32 = rng.random_range(0..total_weight);
|
let roll: i32 = rng.random_range(0..total_weight);
|
||||||
let mut remaining = roll;
|
let mut remaining = roll;
|
||||||
for enc in encounters {
|
for enc in encounters {
|
||||||
remaining -= enc.weight;
|
remaining -= enc.weight;
|
||||||
if remaining < 0 {
|
if remaining < 0 {
|
||||||
return enemies.iter().find(|e| e.id == enc.enemy_id)
|
return enemies
|
||||||
.expect("encounter references valid enemy")
|
.iter()
|
||||||
.clone();
|
.find(|e| e.id == enc.enemy_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(DomainError::UnknownEncounterEnemy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
enemies.iter().find(|e| e.id == encounters.last().unwrap().enemy_id)
|
let last = encounters
|
||||||
.expect("encounter references valid enemy")
|
.last()
|
||||||
.clone()
|
.ok_or(DomainError::UnknownEncounterEnemy)?;
|
||||||
|
enemies
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.id == last.enemy_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(DomainError::UnknownEncounterEnemy)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn equipped_attack_type(equipment: &std::collections::HashMap<String, Option<String>>, items: &[Item]) -> AttackType {
|
pub fn equipped_attack_type(
|
||||||
|
equipment: &std::collections::HashMap<String, Option<String>>,
|
||||||
|
items: &[Item],
|
||||||
|
) -> AttackType {
|
||||||
if let Some(Some(weapon_id)) = equipment.get("main_hand")
|
if let Some(Some(weapon_id)) = equipment.get("main_hand")
|
||||||
&& let Some(weapon) = items.iter().find(|i| &i.id == weapon_id)
|
&& let Some(weapon) = items.iter().find(|i| &i.id == weapon_id)
|
||||||
&& let Some(ref at) = weapon.attack_type
|
&& let Some(ref at) = weapon.attack_type
|
||||||
|
|
@ -33,7 +47,10 @@ pub fn equipped_attack_type(equipment: &std::collections::HashMap<String, Option
|
||||||
AttackType::Crush
|
AttackType::Crush
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn equipped_damage(equipment: &std::collections::HashMap<String, Option<String>>, items: &[Item]) -> i32 {
|
pub fn equipped_damage(
|
||||||
|
equipment: &std::collections::HashMap<String, Option<String>>,
|
||||||
|
items: &[Item],
|
||||||
|
) -> i32 {
|
||||||
let mut total = 0i32;
|
let mut total = 0i32;
|
||||||
for slot in &["main_hand", "off_hand"] {
|
for slot in &["main_hand", "off_hand"] {
|
||||||
if let Some(Some(item_id)) = equipment.get(*slot)
|
if let Some(Some(item_id)) = equipment.get(*slot)
|
||||||
|
|
@ -46,7 +63,10 @@ pub fn equipped_damage(equipment: &std::collections::HashMap<String, Option<Stri
|
||||||
total
|
total
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn total_defense(equipment: &std::collections::HashMap<String, Option<String>>, items: &[Item]) -> i32 {
|
pub fn total_defense(
|
||||||
|
equipment: &std::collections::HashMap<String, Option<String>>,
|
||||||
|
items: &[Item],
|
||||||
|
) -> i32 {
|
||||||
let mut total = 0i32;
|
let mut total = 0i32;
|
||||||
for (_, item_id) in equipment.iter() {
|
for (_, item_id) in equipment.iter() {
|
||||||
if let Some(id) = item_id
|
if let Some(id) = item_id
|
||||||
|
|
@ -59,9 +79,12 @@ pub fn total_defense(equipment: &std::collections::HashMap<String, Option<String
|
||||||
total
|
total
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<(), &'static str> {
|
pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<(), DomainError> {
|
||||||
let inv_pos = char.inventory.iter().position(|id| id == item_id)
|
let inv_pos = char
|
||||||
.ok_or("item not in inventory")?;
|
.inventory
|
||||||
|
.iter()
|
||||||
|
.position(|id| id == item_id)
|
||||||
|
.ok_or(DomainError::ItemNotInInventory)?;
|
||||||
|
|
||||||
char.inventory.remove(inv_pos);
|
char.inventory.remove(inv_pos);
|
||||||
let slot = item.slot.clone();
|
let slot = item.slot.clone();
|
||||||
|
|
@ -72,18 +95,20 @@ pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<()
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, &'static str> {
|
pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, DomainError> {
|
||||||
let prev = char.equipment.insert(slot.to_string(), None)
|
let prev = char
|
||||||
|
.equipment
|
||||||
|
.insert(slot.to_string(), None)
|
||||||
.flatten()
|
.flatten()
|
||||||
.ok_or("nothing equipped in slot")?;
|
.ok_or(DomainError::NothingEquippedInSlot)?;
|
||||||
|
|
||||||
char.inventory.push(prev.clone());
|
char.inventory.push(prev.clone());
|
||||||
Ok(prev)
|
Ok(prev)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str> {
|
pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, DomainError> {
|
||||||
if char.energy < 1 {
|
if char.energy < crate::constants::TRAINING_ENERGY_COST {
|
||||||
return Err("not enough energy");
|
return Err(DomainError::NotEnoughEnergy);
|
||||||
}
|
}
|
||||||
let current = match stat {
|
let current = match stat {
|
||||||
"strength" => char.stats.strength,
|
"strength" => char.stats.strength,
|
||||||
|
|
@ -91,13 +116,13 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str>
|
||||||
"dexterity" => char.stats.dexterity,
|
"dexterity" => char.stats.dexterity,
|
||||||
"constitution" => char.stats.constitution,
|
"constitution" => char.stats.constitution,
|
||||||
"luck" => char.stats.luck,
|
"luck" => char.stats.luck,
|
||||||
_ => return Err("invalid stat"),
|
_ => return Err(DomainError::InvalidStat),
|
||||||
};
|
};
|
||||||
let cost = super::character::training_cost(current);
|
let cost = super::character::training_cost(current);
|
||||||
if char.gold < cost {
|
if char.gold < cost {
|
||||||
return Err("not enough gold");
|
return Err(DomainError::NotEnoughGold);
|
||||||
}
|
}
|
||||||
char.energy -= 1;
|
char.energy -= crate::constants::TRAINING_ENERGY_COST;
|
||||||
char.gold -= cost;
|
char.gold -= cost;
|
||||||
match stat {
|
match stat {
|
||||||
"strength" => char.stats.strength += 1,
|
"strength" => char.stats.strength += 1,
|
||||||
|
|
@ -117,7 +142,7 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str>
|
||||||
"dexterity" => char.stats.dexterity,
|
"dexterity" => char.stats.dexterity,
|
||||||
"constitution" => char.stats.constitution,
|
"constitution" => char.stats.constitution,
|
||||||
"luck" => char.stats.luck,
|
"luck" => char.stats.luck,
|
||||||
_ => return Err("invalid stat"),
|
_ => return Err(DomainError::InvalidStat),
|
||||||
};
|
};
|
||||||
Ok(new_val)
|
Ok(new_val)
|
||||||
}
|
}
|
||||||
|
|
@ -125,42 +150,70 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str>
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use sb_shared::{Character, CharacterStats, Class, Race, Item, Quality, ItemType};
|
use sb_shared::{Character, CharacterStats, Class, Item, ItemType, Quality, Race};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
fn base_char() -> Character {
|
fn base_char() -> Character {
|
||||||
Character {
|
Character {
|
||||||
id: 1, user_id: 1, name: "Test".into(),
|
id: 1,
|
||||||
race: Race::Human, class: Class::Warrior,
|
user_id: 1,
|
||||||
level: 1, xp: 0,
|
name: "Test".into(),
|
||||||
stats: CharacterStats { strength: 20, intelligence: 20, dexterity: 20, constitution: 20, luck: 20 },
|
race: Race::Human,
|
||||||
hp: 80, max_hp: 80, energy: 10, max_energy: 36,
|
class: Class::Warrior,
|
||||||
nerve: 110, max_nerve: 110, gold: 1000, last_regen: 0,
|
level: 1,
|
||||||
equipment: HashMap::new(), inventory: Vec::new(),
|
xp: 0,
|
||||||
active_song: None, known_spells: Vec::new(),
|
stats: CharacterStats {
|
||||||
active_spell: None, spell_points: 0, max_spell_points: 0,
|
strength: 20,
|
||||||
|
intelligence: 20,
|
||||||
|
dexterity: 20,
|
||||||
|
constitution: 20,
|
||||||
|
luck: 20,
|
||||||
|
},
|
||||||
|
hp: 80,
|
||||||
|
max_hp: 80,
|
||||||
|
energy: 10,
|
||||||
|
max_energy: 36,
|
||||||
|
nerve: 110,
|
||||||
|
max_nerve: 110,
|
||||||
|
gold: 1000,
|
||||||
|
last_regen: 0,
|
||||||
|
equipment: HashMap::new(),
|
||||||
|
inventory: Vec::new(),
|
||||||
|
active_song: None,
|
||||||
|
known_spells: Vec::new(),
|
||||||
|
active_spell: None,
|
||||||
|
spell_points: 0,
|
||||||
|
max_spell_points: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sword() -> Item {
|
fn sword() -> Item {
|
||||||
Item {
|
Item {
|
||||||
id: "sword1".into(), name: "Iron Sword".into(),
|
id: "sword1".into(),
|
||||||
r#type: ItemType::Weapon, quality: Quality::Common,
|
name: "Iron Sword".into(),
|
||||||
|
r#type: ItemType::Weapon,
|
||||||
|
quality: Quality::Common,
|
||||||
slot: "main_hand".into(),
|
slot: "main_hand".into(),
|
||||||
stats: HashMap::from([("damage".into(), 15i64)]),
|
stats: HashMap::from([("damage".into(), 15i64)]),
|
||||||
description: String::new(), attack_type: Some(AttackType::Slash),
|
description: String::new(),
|
||||||
value: 100, always_in_shop: true,
|
attack_type: Some(AttackType::Slash),
|
||||||
|
value: 100,
|
||||||
|
always_in_shop: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shield() -> Item {
|
fn shield() -> Item {
|
||||||
Item {
|
Item {
|
||||||
id: "shield1".into(), name: "Wood Shield".into(),
|
id: "shield1".into(),
|
||||||
r#type: ItemType::Armor, quality: Quality::Common,
|
name: "Wood Shield".into(),
|
||||||
|
r#type: ItemType::Armor,
|
||||||
|
quality: Quality::Common,
|
||||||
slot: "off_hand".into(),
|
slot: "off_hand".into(),
|
||||||
stats: HashMap::from([("defense".into(), 10i64)]),
|
stats: HashMap::from([("defense".into(), 10i64)]),
|
||||||
description: String::new(), attack_type: None,
|
description: String::new(),
|
||||||
value: 50, always_in_shop: true,
|
attack_type: None,
|
||||||
|
value: 50,
|
||||||
|
always_in_shop: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,20 +232,29 @@ mod tests {
|
||||||
fn train_stat_fails_without_energy() {
|
fn train_stat_fails_without_energy() {
|
||||||
let mut c = base_char();
|
let mut c = base_char();
|
||||||
c.energy = 0;
|
c.energy = 0;
|
||||||
assert_eq!(train_stat(&mut c, "strength"), Err("not enough energy"));
|
assert_eq!(
|
||||||
|
train_stat(&mut c, "strength"),
|
||||||
|
Err(DomainError::NotEnoughEnergy)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn train_stat_fails_without_gold() {
|
fn train_stat_fails_without_gold() {
|
||||||
let mut c = base_char();
|
let mut c = base_char();
|
||||||
c.gold = 0;
|
c.gold = 0;
|
||||||
assert_eq!(train_stat(&mut c, "strength"), Err("not enough gold"));
|
assert_eq!(
|
||||||
|
train_stat(&mut c, "strength"),
|
||||||
|
Err(DomainError::NotEnoughGold)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn train_stat_fails_invalid_stat() {
|
fn train_stat_fails_invalid_stat() {
|
||||||
let mut c = base_char();
|
let mut c = base_char();
|
||||||
assert_eq!(train_stat(&mut c, "charisma"), Err("invalid stat"));
|
assert_eq!(
|
||||||
|
train_stat(&mut c, "charisma"),
|
||||||
|
Err(DomainError::InvalidStat)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -211,16 +273,23 @@ mod tests {
|
||||||
c.inventory.push("sword1".into());
|
c.inventory.push("sword1".into());
|
||||||
equip_item(&mut c, &sword(), "sword1").unwrap();
|
equip_item(&mut c, &sword(), "sword1").unwrap();
|
||||||
assert!(!c.inventory.contains(&"sword1".to_string()));
|
assert!(!c.inventory.contains(&"sword1".to_string()));
|
||||||
assert_eq!(c.equipment.get("main_hand").cloned(), Some(Some("sword1".into())));
|
assert_eq!(
|
||||||
|
c.equipment.get("main_hand").cloned(),
|
||||||
|
Some(Some("sword1".into()))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn equip_item_swaps_previous() {
|
fn equip_item_swaps_previous() {
|
||||||
let mut c = base_char();
|
let mut c = base_char();
|
||||||
c.equipment.insert("main_hand".into(), Some("old_sword".into()));
|
c.equipment
|
||||||
|
.insert("main_hand".into(), Some("old_sword".into()));
|
||||||
c.inventory.push("sword1".into());
|
c.inventory.push("sword1".into());
|
||||||
equip_item(&mut c, &sword(), "sword1").unwrap();
|
equip_item(&mut c, &sword(), "sword1").unwrap();
|
||||||
assert_eq!(c.equipment.get("main_hand").cloned(), Some(Some("sword1".into())));
|
assert_eq!(
|
||||||
|
c.equipment.get("main_hand").cloned(),
|
||||||
|
Some(Some("sword1".into()))
|
||||||
|
);
|
||||||
assert!(c.inventory.contains(&"old_sword".to_string()));
|
assert!(c.inventory.contains(&"old_sword".to_string()));
|
||||||
assert!(!c.inventory.contains(&"sword1".to_string()));
|
assert!(!c.inventory.contains(&"sword1".to_string()));
|
||||||
}
|
}
|
||||||
|
|
@ -228,13 +297,17 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn equip_item_fails_if_not_in_inventory() {
|
fn equip_item_fails_if_not_in_inventory() {
|
||||||
let mut c = base_char();
|
let mut c = base_char();
|
||||||
assert_eq!(equip_item(&mut c, &sword(), "sword1"), Err("item not in inventory"));
|
assert_eq!(
|
||||||
|
equip_item(&mut c, &sword(), "sword1"),
|
||||||
|
Err(DomainError::ItemNotInInventory)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unequip_item_moves_to_inventory() {
|
fn unequip_item_moves_to_inventory() {
|
||||||
let mut c = base_char();
|
let mut c = base_char();
|
||||||
c.equipment.insert("main_hand".into(), Some("sword1".into()));
|
c.equipment
|
||||||
|
.insert("main_hand".into(), Some("sword1".into()));
|
||||||
let result = unequip_item(&mut c, "main_hand").unwrap();
|
let result = unequip_item(&mut c, "main_hand").unwrap();
|
||||||
assert_eq!(result, "sword1");
|
assert_eq!(result, "sword1");
|
||||||
assert!(c.inventory.contains(&"sword1".to_string()));
|
assert!(c.inventory.contains(&"sword1".to_string()));
|
||||||
|
|
@ -244,7 +317,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn unequip_item_fails_empty_slot() {
|
fn unequip_item_fails_empty_slot() {
|
||||||
let mut c = base_char();
|
let mut c = base_char();
|
||||||
assert_eq!(unequip_item(&mut c, "main_hand"), Err("nothing equipped in slot"));
|
assert_eq!(
|
||||||
|
unequip_item(&mut c, "main_hand"),
|
||||||
|
Err(DomainError::NothingEquippedInSlot)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -270,49 +346,84 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn equipped_attack_type_returns_weapon_type() {
|
fn equipped_attack_type_returns_weapon_type() {
|
||||||
let items = vec![sword()];
|
let items = vec![sword()];
|
||||||
let eq: HashMap<String, Option<String>> = HashMap::from([
|
let eq: HashMap<String, Option<String>> =
|
||||||
("main_hand".into(), Some("sword1".into())),
|
HashMap::from([("main_hand".into(), Some("sword1".into()))]);
|
||||||
]);
|
assert!(matches!(
|
||||||
assert!(matches!(equipped_attack_type(&eq, &items), AttackType::Slash));
|
equipped_attack_type(&eq, &items),
|
||||||
|
AttackType::Slash
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn equipped_attack_type_defaults_to_crush() {
|
fn equipped_attack_type_defaults_to_crush() {
|
||||||
let items: Vec<Item> = vec![];
|
let items: Vec<Item> = vec![];
|
||||||
let eq: HashMap<String, Option<String>> = HashMap::new();
|
let eq: HashMap<String, Option<String>> = HashMap::new();
|
||||||
assert!(matches!(equipped_attack_type(&eq, &items), AttackType::Crush));
|
assert!(matches!(
|
||||||
|
equipped_attack_type(&eq, &items),
|
||||||
|
AttackType::Crush
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pick_enemy_respects_weights() {
|
fn pick_enemy_respects_weights() {
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
let encounters = vec![
|
let encounters = vec![
|
||||||
crate::data::EncounterEntry { enemy_id: "kobold".into(), weight: 3 },
|
crate::data::EncounterEntry {
|
||||||
crate::data::EncounterEntry { enemy_id: "rat".into(), weight: 1 },
|
enemy_id: "kobold".into(),
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
crate::data::EncounterEntry {
|
||||||
|
enemy_id: "rat".into(),
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
let enemies = vec![
|
let enemies = vec![
|
||||||
crate::data::EnemyDef {
|
crate::data::EnemyDef {
|
||||||
id: "kobold".into(), name: "Kobold".into(),
|
id: "kobold".into(),
|
||||||
hp: 20, level: 1, damage: 5, defense: 2,
|
name: "Kobold".into(),
|
||||||
|
hp: 20,
|
||||||
|
level: 1,
|
||||||
|
damage: 5,
|
||||||
|
defense: 2,
|
||||||
attack_type: "crush".into(),
|
attack_type: "crush".into(),
|
||||||
xp: 10, gold_min: 1, gold_max: 5,
|
xp: 10,
|
||||||
stats: crate::data::EnemyStats { strength: 15, intelligence: 10, dexterity: 15, constitution: 15, luck: 5 },
|
gold_min: 1,
|
||||||
|
gold_max: 5,
|
||||||
|
stats: crate::data::EnemyStats {
|
||||||
|
strength: 15,
|
||||||
|
intelligence: 10,
|
||||||
|
dexterity: 15,
|
||||||
|
constitution: 15,
|
||||||
|
luck: 5,
|
||||||
|
},
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
loot: vec![],
|
loot: vec![],
|
||||||
},
|
},
|
||||||
crate::data::EnemyDef {
|
crate::data::EnemyDef {
|
||||||
id: "rat".into(), name: "Giant Rat".into(),
|
id: "rat".into(),
|
||||||
hp: 10, level: 1, damage: 3, defense: 1,
|
name: "Giant Rat".into(),
|
||||||
|
hp: 10,
|
||||||
|
level: 1,
|
||||||
|
damage: 3,
|
||||||
|
defense: 1,
|
||||||
attack_type: "puncture".into(),
|
attack_type: "puncture".into(),
|
||||||
xp: 5, gold_min: 0, gold_max: 2,
|
xp: 5,
|
||||||
stats: crate::data::EnemyStats { strength: 5, intelligence: 5, dexterity: 15, constitution: 5, luck: 5 },
|
gold_min: 0,
|
||||||
|
gold_max: 2,
|
||||||
|
stats: crate::data::EnemyStats {
|
||||||
|
strength: 5,
|
||||||
|
intelligence: 5,
|
||||||
|
dexterity: 15,
|
||||||
|
constitution: 5,
|
||||||
|
luck: 5,
|
||||||
|
},
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
loot: vec![],
|
loot: vec![],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
|
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
|
||||||
for _ in 0..100 {
|
for _ in 0..100 {
|
||||||
let enemy = pick_enemy(&mut rng, &encounters, &enemies);
|
let enemy = pick_enemy(&mut rng, &encounters, &enemies).unwrap();
|
||||||
assert!(enemy.id == "kobold" || enemy.id == "rat");
|
assert!(enemy.id == "kobold" || enemy.id == "rat");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,5 +55,8 @@ pub const BARD_SONGS: &[SongDef] = &[
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn available_songs(level: i32) -> Vec<&'static SongDef> {
|
pub fn available_songs(level: i32) -> Vec<&'static SongDef> {
|
||||||
BARD_SONGS.iter().filter(|s| s.required_level <= level).collect()
|
BARD_SONGS
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.required_level <= level)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use crate::constants;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -18,8 +19,7 @@ pub struct SpellDef {
|
||||||
pub heal_max: i32,
|
pub heal_max: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPELLS_JSON: &str =
|
const SPELLS_JSON: &str = include_str!("../../../../skara-brae-common/data/spells/spells.json");
|
||||||
include_str!("../../../../skara-brae-common/data/spells/spells.json");
|
|
||||||
|
|
||||||
pub fn load_spells() -> Vec<SpellDef> {
|
pub fn load_spells() -> Vec<SpellDef> {
|
||||||
serde_json::from_str(SPELLS_JSON).expect("failed to parse spells.json")
|
serde_json::from_str(SPELLS_JSON).expect("failed to parse spells.json")
|
||||||
|
|
@ -31,9 +31,10 @@ pub fn class_for_spells(class: &str) -> &str {
|
||||||
|
|
||||||
pub fn spells_for_class(class: &str, spell_level: i32) -> Vec<&'static SpellDef> {
|
pub fn spells_for_class(class: &str, spell_level: i32) -> Vec<&'static SpellDef> {
|
||||||
let spell_class = class_for_spells(class);
|
let spell_class = class_for_spells(class);
|
||||||
ALL_SPELLS.iter().filter(|s| {
|
ALL_SPELLS
|
||||||
s.caster_class == spell_class && s.spell_level <= spell_level
|
.iter()
|
||||||
}).collect()
|
.filter(|s| s.caster_class == spell_class && s.spell_level <= spell_level)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_spell(id: &str) -> Option<&'static SpellDef> {
|
pub fn find_spell(id: &str) -> Option<&'static SpellDef> {
|
||||||
|
|
@ -41,29 +42,31 @@ pub fn find_spell(id: &str) -> Option<&'static SpellDef> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spell_learn_cost(spell_level: i32) -> i32 {
|
pub fn spell_learn_cost(spell_level: i32) -> i32 {
|
||||||
match spell_level {
|
if (constants::MIN_SPELL_LEVEL..=constants::MAX_SPELL_LEVEL).contains(&spell_level) {
|
||||||
1 => 0,
|
constants::SPELL_LEARN_COST_BY_LEVEL[(spell_level - 1) as usize]
|
||||||
2 => 1000,
|
} else {
|
||||||
3 => 2000,
|
constants::SPELL_LEARN_COST_UNKNOWN_LEVEL
|
||||||
4 => 4000,
|
|
||||||
5 => 7000,
|
|
||||||
6 => 10000,
|
|
||||||
7 => 20000,
|
|
||||||
_ => 999999,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn caster_spell_level(character_level: i32) -> i32 {
|
||||||
|
(character_level / constants::CASTER_LEVELS_PER_SPELL_LEVEL) + 1
|
||||||
|
}
|
||||||
|
|
||||||
pub fn max_spell_points(class: &str, level: i32) -> i32 {
|
pub fn max_spell_points(class: &str, level: i32) -> i32 {
|
||||||
let spell_class = class_for_spells(class);
|
let spell_class = class_for_spells(class);
|
||||||
let spell_level = match spell_class {
|
let spell_level = match spell_class {
|
||||||
"conjurer" | "magician" | "sorcerer" | "wizard" => (level / 3) + 1,
|
"conjurer" | "magician" | "sorcerer" | "wizard" => caster_spell_level(level),
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
spell_level * 10
|
spell_level * constants::SPELL_POINTS_PER_SPELL_LEVEL
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_caster_class(class: &str) -> bool {
|
pub fn is_caster_class(class: &str) -> bool {
|
||||||
matches!(class_for_spells(class), "conjurer" | "magician" | "sorcerer" | "wizard")
|
matches!(
|
||||||
|
class_for_spells(class),
|
||||||
|
"conjurer" | "magician" | "sorcerer" | "wizard"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn caster_classes() -> &'static [&'static str] {
|
pub fn caster_classes() -> &'static [&'static str] {
|
||||||
|
|
@ -72,3 +75,44 @@ pub fn caster_classes() -> &'static [&'static str] {
|
||||||
|
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
static ALL_SPELLS: LazyLock<Vec<SpellDef>> = LazyLock::new(load_spells);
|
static ALL_SPELLS: LazyLock<Vec<SpellDef>> = LazyLock::new(load_spells);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn caster_spell_level_spot_values() {
|
||||||
|
assert_eq!(caster_spell_level(1), 1);
|
||||||
|
assert_eq!(caster_spell_level(2), 1);
|
||||||
|
assert_eq!(caster_spell_level(3), 2);
|
||||||
|
assert_eq!(caster_spell_level(6), 3);
|
||||||
|
assert_eq!(caster_spell_level(100), 34);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spell_learn_cost_guard_spot_checks() {
|
||||||
|
assert_eq!(
|
||||||
|
spell_learn_cost(0),
|
||||||
|
constants::SPELL_LEARN_COST_UNKNOWN_LEVEL
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spell_learn_cost(-1),
|
||||||
|
constants::SPELL_LEARN_COST_UNKNOWN_LEVEL
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spell_learn_cost(8),
|
||||||
|
constants::SPELL_LEARN_COST_UNKNOWN_LEVEL
|
||||||
|
);
|
||||||
|
for level in constants::MIN_SPELL_LEVEL..=constants::MAX_SPELL_LEVEL {
|
||||||
|
assert_eq!(
|
||||||
|
spell_learn_cost(level),
|
||||||
|
constants::SPELL_LEARN_COST_BY_LEVEL[(level - 1) as usize]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spells_json_parses() {
|
||||||
|
assert!(!load_spells().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use sb_shared::{Character, CharacterStats, Class, Race, User, Role};
|
use sb_shared::{Character, CharacterStats, Class, Race, Role, User};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
@ -11,7 +11,9 @@ pub struct Database {
|
||||||
impl Database {
|
impl Database {
|
||||||
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
let db = Self { conn: Mutex::new(conn) };
|
let db = Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
};
|
||||||
db.run_migrations()?;
|
db.run_migrations()?;
|
||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +61,7 @@ impl Database {
|
||||||
quantity INTEGER NOT NULL DEFAULT 0
|
quantity INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_characters_user ON characters(user_id);
|
CREATE INDEX IF NOT EXISTS idx_characters_user ON characters(user_id);
|
||||||
"
|
",
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -114,10 +116,15 @@ impl Database {
|
||||||
let inventory_json: String = row.get(22)?;
|
let inventory_json: String = row.get(22)?;
|
||||||
let known_spells_json: String = row.get(24)?;
|
let known_spells_json: String = row.get(24)?;
|
||||||
|
|
||||||
let equipment_raw: HashMap<String, String> = serde_json::from_str(&equipment_json).unwrap_or_default();
|
let equipment_raw: HashMap<String, String> =
|
||||||
let equipment: HashMap<String, Option<String>> = equipment_raw.into_iter().map(|(k, v)| (k, Some(v))).collect();
|
serde_json::from_str(&equipment_json).unwrap_or_default();
|
||||||
|
let equipment: HashMap<String, Option<String>> = equipment_raw
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k, Some(v)))
|
||||||
|
.collect();
|
||||||
let inventory: Vec<String> = serde_json::from_str(&inventory_json).unwrap_or_default();
|
let inventory: Vec<String> = serde_json::from_str(&inventory_json).unwrap_or_default();
|
||||||
let known_spells: Vec<String> = serde_json::from_str(&known_spells_json).unwrap_or_default();
|
let known_spells: Vec<String> =
|
||||||
|
serde_json::from_str(&known_spells_json).unwrap_or_default();
|
||||||
|
|
||||||
let race_str: String = row.get(3)?;
|
let race_str: String = row.get(3)?;
|
||||||
let class_str: String = row.get(4)?;
|
let class_str: String = row.get(4)?;
|
||||||
|
|
@ -177,15 +184,32 @@ impl Database {
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
|
||||||
?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27)",
|
?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
char.id, char.user_id, char.name,
|
char.id,
|
||||||
char.race.as_str(), char.class.as_str(),
|
char.user_id,
|
||||||
char.level, char.xp, char.gold,
|
char.name,
|
||||||
char.hp, char.max_hp, char.energy, char.max_energy,
|
char.race.as_str(),
|
||||||
char.nerve, char.max_nerve, char.spell_points, char.max_spell_points,
|
char.class.as_str(),
|
||||||
char.stats.strength, char.stats.intelligence, char.stats.dexterity,
|
char.level,
|
||||||
char.stats.constitution, char.stats.luck,
|
char.xp,
|
||||||
equipment_json, inventory_json,
|
char.gold,
|
||||||
char.active_song, known_spells_json, char.active_spell,
|
char.hp,
|
||||||
|
char.max_hp,
|
||||||
|
char.energy,
|
||||||
|
char.max_energy,
|
||||||
|
char.nerve,
|
||||||
|
char.max_nerve,
|
||||||
|
char.spell_points,
|
||||||
|
char.max_spell_points,
|
||||||
|
char.stats.strength,
|
||||||
|
char.stats.intelligence,
|
||||||
|
char.stats.dexterity,
|
||||||
|
char.stats.constitution,
|
||||||
|
char.stats.luck,
|
||||||
|
equipment_json,
|
||||||
|
inventory_json,
|
||||||
|
char.active_song,
|
||||||
|
known_spells_json,
|
||||||
|
char.active_spell,
|
||||||
char.last_regen,
|
char.last_regen,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -217,11 +241,9 @@ impl Database {
|
||||||
|
|
||||||
pub fn next_character_id(&self) -> Result<u32, rusqlite::Error> {
|
pub fn next_character_id(&self) -> Result<u32, rusqlite::Error> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let id: u32 = conn.query_row(
|
let id: u32 = conn.query_row("SELECT COALESCE(MAX(id), 0) FROM characters", [], |row| {
|
||||||
"SELECT COALESCE(MAX(id), 0) FROM characters",
|
row.get(0)
|
||||||
[],
|
})?;
|
||||||
|row| row.get(0),
|
|
||||||
)?;
|
|
||||||
Ok(id + 1)
|
Ok(id + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,12 @@ pub struct Character {
|
||||||
pub max_spell_points: i32,
|
pub max_spell_points: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_sp() -> i32 { 0 }
|
fn default_sp() -> i32 {
|
||||||
fn default_max_sp() -> i32 { 0 }
|
0
|
||||||
|
}
|
||||||
|
fn default_max_sp() -> i32 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct CreateCharacter {
|
pub struct CreateCharacter {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,18 @@ pub enum ItemType {
|
||||||
Material,
|
Material,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ItemType {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ItemType::Weapon => "weapon",
|
||||||
|
ItemType::Armor => "armor",
|
||||||
|
ItemType::Accessory => "accessory",
|
||||||
|
ItemType::Consumable => "consumable",
|
||||||
|
ItemType::Material => "material",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AttackType {
|
pub enum AttackType {
|
||||||
|
|
@ -44,3 +56,52 @@ pub enum Quality {
|
||||||
Epic,
|
Epic,
|
||||||
Legendary,
|
Legendary,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Quality {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Quality::Common => "common",
|
||||||
|
Quality::Uncommon => "uncommon",
|
||||||
|
Quality::Rare => "rare",
|
||||||
|
Quality::Epic => "epic",
|
||||||
|
Quality::Legendary => "legendary",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_type_as_str_matches_serde_wire() {
|
||||||
|
for variant in [
|
||||||
|
ItemType::Weapon,
|
||||||
|
ItemType::Armor,
|
||||||
|
ItemType::Accessory,
|
||||||
|
ItemType::Consumable,
|
||||||
|
ItemType::Material,
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&variant).unwrap(),
|
||||||
|
serde_json::json!(variant.as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quality_as_str_matches_serde_wire() {
|
||||||
|
for variant in [
|
||||||
|
Quality::Common,
|
||||||
|
Quality::Uncommon,
|
||||||
|
Quality::Rare,
|
||||||
|
Quality::Epic,
|
||||||
|
Quality::Legendary,
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&variant).unwrap(),
|
||||||
|
serde_json::json!(variant.as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use crate::character::Character;
|
use crate::character::Character;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CritEntry {
|
pub struct CritEntry {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue