fmt: repo-wide baseline (WI-2, issue #2)

This commit is contained in:
Jan-Peter von Hunnius 2026-10-03 11:53:59 +02:00
parent 899fde8fc3
commit 337e9bcf2c
19 changed files with 847 additions and 335 deletions

View file

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

View file

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

View file

@ -0,0 +1 @@

View file

@ -1,26 +1,23 @@
#![allow(private_interfaces)] #![allow(private_interfaces)]
use crate::middleware::auth::AuthUser;
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{Extension, Json, Router}; use axum::{Extension, Json, Router};
use crate::middleware::auth::AuthUser;
use sb_core::character::{self, CLASSES, RACES, TRAINING_GROUNDS}; use sb_core::character::{self, CLASSES, RACES, TRAINING_GROUNDS};
use sb_core::combat::{self, CombatStats};
use sb_core::combat::parse_attack_type; use sb_core::combat::parse_attack_type;
use sb_core::combat::{self, CombatStats};
use sb_core::data; use sb_core::data;
use sb_core::services as domain;
use sb_core::items; use sb_core::items;
use sb_core::services as domain;
use sb_core::songs; use sb_core::songs;
use sb_core::spells; use sb_core::spells;
use sb_shared::{ use sb_shared::{
Character, Class, CreateCharacter, Item, Role, User, AdventuresResponse, ApiInfoResponse, Character, Class, ClassInfo, ClassesResponse,
ApiInfoResponse, RacesResponse, RaceInfo, CreateCharacter, EnemiesResponse, EnemySummary, GymResponse, InventoryItemResponse, Item,
ClassesResponse, ClassInfo, EnemiesResponse, EnemySummary, LevelUpResponse, RaceInfo, RacesResponse, Role, SetSongResponse, ShopActionResponse,
AdventuresResponse, ZoneSummary, ShopItemResponse, ShopResponse, SongSummary, SongsResponse, TrainStatResponse,
GymResponse, TrainStatResponse, TrainingStatResponse, ShopResponse, TrainingStatResponse, User, ZoneSummary,
InventoryItemResponse, ShopItemResponse, ShopActionResponse,
SongsResponse, SongSummary, SetSongResponse,
LevelUpResponse,
}; };
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@ -93,7 +90,10 @@ pub fn build(state: Arc<AppState>) -> Router {
.route("/api/classes", get(get_classes)) .route("/api/classes", get(get_classes))
.route("/api/enemies", get(get_enemies)) .route("/api/enemies", get(get_enemies))
.route("/api/adventures", get(get_adventures)) .route("/api/adventures", get(get_adventures))
.route("/api/users/{user_id}/characters", get(get_characters).post(create_character)) .route(
"/api/users/{user_id}/characters",
get(get_characters).post(create_character),
)
.route("/api/characters/{id}", get(get_character)) .route("/api/characters/{id}", get(get_character))
.route("/api/characters/{id}/adventure", get(run_adventure)) .route("/api/characters/{id}/adventure", get(run_adventure))
.route("/api/characters/{id}/equip", post(equip_item)) .route("/api/characters/{id}/equip", post(equip_item))
@ -102,7 +102,10 @@ pub fn build(state: Arc<AppState>) -> Router {
.route("/api/characters/{id}/shop", get(get_shop).post(shop_action)) .route("/api/characters/{id}/shop", get(get_shop).post(shop_action))
.route("/api/characters/{id}/heal", post(heal_character)) .route("/api/characters/{id}/heal", post(heal_character))
.route("/api/characters/{id}/songs", get(get_songs).post(set_song)) .route("/api/characters/{id}/songs", get(get_songs).post(set_song))
.route("/api/characters/{id}/spells", get(get_spells).post(learn_spell_level)) .route(
"/api/characters/{id}/spells",
get(get_spells).post(learn_spell_level),
)
.route("/api/characters/{id}/level-up", post(level_up)) .route("/api/characters/{id}/level-up", post(level_up))
.route_layer(axum::middleware::from_fn( .route_layer(axum::middleware::from_fn(
crate::middleware::auth::auth_middleware, crate::middleware::auth::auth_middleware,
@ -264,7 +267,11 @@ async fn get_characters(
for c in chars.iter_mut() { for c in chars.iter_mut() {
sb_core::character::apply_regen(c, now); sb_core::character::apply_regen(c, now);
} }
let user_chars: Vec<Character> = chars.iter().filter(|c| c.user_id == user_id).cloned().collect(); let user_chars: Vec<Character> = chars
.iter()
.filter(|c| c.user_id == user_id)
.cloned()
.collect();
Ok(Json(user_chars)) Ok(Json(user_chars))
} }
@ -317,8 +324,10 @@ async fn get_character(
let char = chars.iter().find(|c| c.id == id); let char = chars.iter().find(|c| c.id == id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
@ -326,7 +335,9 @@ async fn get_character(
.unwrap() .unwrap()
.as_millis() as i64; .as_millis() as i64;
let mut chars = state.characters.write().await; let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == id) let char = chars
.iter_mut()
.find(|c| c.id == id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
sb_core::character::apply_regen(char, now); sb_core::character::apply_regen(char, now);
Ok(Json(char.clone())) Ok(Json(char.clone()))
@ -343,7 +354,10 @@ async fn equip_item(
Path(char_id): Path<u32>, Path(char_id): Path<u32>,
Json(input): Json<EquipRequest>, Json(input): Json<EquipRequest>,
) -> Result<Json<Character>, axum::http::StatusCode> { ) -> Result<Json<Character>, axum::http::StatusCode> {
let item = state.items.iter().find(|i| i.id == input.item_id) let item = state
.items
.iter()
.find(|i| i.id == input.item_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
{ {
@ -351,14 +365,18 @@ async fn equip_item(
let char = chars.iter().find(|c| c.id == char_id); let char = chars.iter().find(|c| c.id == char_id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
let updated = { let updated = {
let mut chars = state.characters.write().await; let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id) let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
domain::equip_item(char, item, &input.item_id) domain::equip_item(char, item, &input.item_id)
@ -387,17 +405,20 @@ async fn unequip_item(
let char = chars.iter().find(|c| c.id == char_id); let char = chars.iter().find(|c| c.id == char_id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
let updated = { let updated = {
let mut chars = state.characters.write().await; let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id) let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
domain::unequip_item(char, &input.slot) domain::unequip_item(char, &input.slot).map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
char.clone() char.clone()
}; };
@ -419,12 +440,17 @@ async fn run_adventure(
let char = chars.iter().find(|c| c.id == char_id); let char = chars.iter().find(|c| c.id == char_id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
let zone = state.zones.iter().find(|z| z.id == zone_id) let zone = state
.zones
.iter()
.find(|z| z.id == zone_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
@ -436,7 +462,9 @@ async fn run_adventure(
let (char, enemy_def) = { let (char, enemy_def) = {
let mut chars = state.characters.write().await; let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id) let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
sb_core::character::apply_regen(char, now); sb_core::character::apply_regen(char, now);
@ -500,7 +528,10 @@ async fn run_adventure(
enemy_def.gold_min, enemy_def.gold_min,
enemy_def.gold_max, enemy_def.gold_max,
char.active_song.as_deref().and_then(|sid| { char.active_song.as_deref().and_then(|sid| {
songs::BARD_SONGS.iter().find(|s| s.id == sid).map(|s| s.combat_effect) songs::BARD_SONGS
.iter()
.find(|s| s.id == sid)
.map(|s| s.combat_effect)
}), }),
spell_effect.as_ref(), spell_effect.as_ref(),
&mut rng, &mut rng,
@ -514,7 +545,9 @@ async fn run_adventure(
if log.winner == c.name { if log.winner == c.name {
c.xp += log.xp_gained as i64; c.xp += log.xp_gained as i64;
c.gold += log.gold_gained; c.gold += log.gold_gained;
let char_hp = log.rounds.iter() let char_hp = log
.rounds
.iter()
.rfind(|r| r.defender == c.name) .rfind(|r| r.defender == c.name)
.map(|r| r.defender_hp); .map(|r| r.defender_hp);
if let Some(hp) = char_hp { if let Some(hp) = char_hp {
@ -548,9 +581,16 @@ async fn run_adventure(
let updated = chars.iter().find(|c| c.id == char_id).cloned(); let updated = chars.iter().find(|c| c.id == char_id).cloned();
drop(chars); drop(chars);
let loot: Vec<String> = dropped_ids.iter().filter_map(|id| { let loot: Vec<String> = dropped_ids
state.items.iter().find(|i| &i.id == id).map(|i| i.name.clone()) .iter()
}).collect(); .filter_map(|id| {
state
.items
.iter()
.find(|i| &i.id == id)
.map(|i| i.name.clone())
})
.collect();
(loot, updated) (loot, updated)
}; };
@ -576,14 +616,19 @@ async fn get_gym(
let char = chars.iter().find(|c| c.id == char_id); let char = chars.iter().find(|c| c.id == char_id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
let char = { let char = {
let chars = state.characters.read().await; let chars = state.characters.read().await;
chars.iter().find(|c| c.id == char_id) chars
.ok_or(axum::http::StatusCode::NOT_FOUND)?.clone() .iter()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?
.clone()
}; };
let stats = [ let stats = [
("strength", char.stats.strength), ("strength", char.stats.strength),
@ -592,8 +637,13 @@ async fn get_gym(
("constitution", char.stats.constitution), ("constitution", char.stats.constitution),
("luck", char.stats.luck), ("luck", char.stats.luck),
]; ];
let stat_responses: Vec<TrainingStatResponse> = stats.iter().map(|(name, val)| { let stat_responses: Vec<TrainingStatResponse> = stats
let ground = TRAINING_GROUNDS.iter().find(|g| g.stat == *name).expect("valid stat"); .iter()
.map(|(name, val)| {
let ground = TRAINING_GROUNDS
.iter()
.find(|g| g.stat == *name)
.expect("valid stat");
TrainingStatResponse { TrainingStatResponse {
stat: name.to_string(), stat: name.to_string(),
name: ground.name.to_string(), name: ground.name.to_string(),
@ -601,7 +651,8 @@ async fn get_gym(
value: *val, value: *val,
cost: character::training_cost(*val), cost: character::training_cost(*val),
} }
}).collect(); })
.collect();
Ok(Json(GymResponse { Ok(Json(GymResponse {
energy: char.energy, energy: char.energy,
max_energy: char.max_energy, max_energy: char.max_energy,
@ -626,8 +677,10 @@ async fn train_stat(
let char = chars.iter().find(|c| c.id == char_id); let char = chars.iter().find(|c| c.id == char_id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
@ -635,14 +688,16 @@ async fn train_stat(
.unwrap() .unwrap()
.as_millis() as i64; .as_millis() as i64;
let (updated, _value) = { let mut chars = state.characters.write().await; let (updated, _value) = {
let char = chars.iter_mut().find(|c| c.id == char_id) let mut chars = state.characters.write().await;
let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
sb_core::character::apply_regen(char, now); sb_core::character::apply_regen(char, now);
let new_val = domain::train_stat(char, &input.stat) let new_val = domain::train_stat(char, &input.stat).map_err(|e| match e {
.map_err(|e| match e {
"not enough energy" => axum::http::StatusCode::UNPROCESSABLE_ENTITY, "not enough energy" => axum::http::StatusCode::UNPROCESSABLE_ENTITY,
"not enough gold" => axum::http::StatusCode::UNPROCESSABLE_ENTITY, "not enough gold" => axum::http::StatusCode::UNPROCESSABLE_ENTITY,
_ => axum::http::StatusCode::BAD_REQUEST, _ => axum::http::StatusCode::BAD_REQUEST,
@ -669,48 +724,76 @@ async fn get_shop(
let char = chars.iter().find(|c| c.id == char_id); let char = chars.iter().find(|c| c.id == char_id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
let char = { let char = {
let chars = state.characters.read().await; let chars = state.characters.read().await;
chars.iter().find(|c| c.id == char_id) chars
.ok_or(axum::http::StatusCode::NOT_FOUND)?.clone() .iter()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?
.clone()
}; };
let shop_stock = state.shop_stock.read().await; let shop_stock = state.shop_stock.read().await;
let shop_items: Vec<ShopItemResponse> = state.items.iter().filter(|item| { let shop_items: Vec<ShopItemResponse> = state
item.always_in_shop || shop_stock.contains_key(&item.id) .items
}).map(|item| { .iter()
ShopItemResponse { .filter(|item| item.always_in_shop || shop_stock.contains_key(&item.id))
.map(|item| ShopItemResponse {
id: item.id.clone(), id: item.id.clone(),
name: item.name.clone(), name: item.name.clone(),
item_type: serde_json::to_value(&item.r#type).unwrap().as_str().unwrap().to_string(), item_type: serde_json::to_value(&item.r#type)
quality: serde_json::to_value(&item.quality).unwrap().as_str().unwrap().to_string(), .unwrap()
.as_str()
.unwrap()
.to_string(),
quality: serde_json::to_value(&item.quality)
.unwrap()
.as_str()
.unwrap()
.to_string(),
slot: Some(item.slot.clone()), slot: Some(item.slot.clone()),
stats: item.stats.clone(), stats: item.stats.clone(),
description: item.description.clone(), description: item.description.clone(),
attack_type: item.attack_type.as_ref().map(|at| format!("{:?}", at)), attack_type: item.attack_type.as_ref().map(|at| format!("{:?}", at)),
value: items::item_price(item), value: items::item_price(item),
stock: shop_stock.get(&item.id).copied().unwrap_or(0), stock: shop_stock.get(&item.id).copied().unwrap_or(0),
} })
}).collect(); .collect();
let bag_items: Vec<InventoryItemResponse> = char.inventory.iter().filter_map(|id| { let bag_items: Vec<InventoryItemResponse> = char
state.items.iter().find(|i| &i.id == id).map(|item| { .inventory
InventoryItemResponse { .iter()
.filter_map(|id| {
state
.items
.iter()
.find(|i| &i.id == id)
.map(|item| InventoryItemResponse {
id: item.id.clone(), id: item.id.clone(),
name: item.name.clone(), name: item.name.clone(),
item_type: serde_json::to_value(&item.r#type).unwrap().as_str().unwrap().to_string(), item_type: serde_json::to_value(&item.r#type)
quality: serde_json::to_value(&item.quality).unwrap().as_str().unwrap().to_string(), .unwrap()
.as_str()
.unwrap()
.to_string(),
quality: serde_json::to_value(&item.quality)
.unwrap()
.as_str()
.unwrap()
.to_string(),
slot: Some(item.slot.clone()), slot: Some(item.slot.clone()),
stats: item.stats.clone(), stats: item.stats.clone(),
description: item.description.clone(), description: item.description.clone(),
attack_type: item.attack_type.as_ref().map(|t| format!("{:?}", t)), attack_type: item.attack_type.as_ref().map(|t| format!("{:?}", t)),
value: item.value, value: item.value,
equipped: false, equipped: false,
}
}) })
}).collect(); })
.collect();
Ok(Json(ShopResponse { Ok(Json(ShopResponse {
gold: char.gold, gold: char.gold,
inventory: bag_items, inventory: bag_items,
@ -735,8 +818,10 @@ async fn shop_action(
let char = chars.iter().find(|c| c.id == char_id); let char = chars.iter().find(|c| c.id == char_id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
match input.action.as_str() { match input.action.as_str() {
@ -751,14 +836,19 @@ async fn buy_item(
Path(char_id): Path<u32>, Path(char_id): Path<u32>,
input: ShopAction, input: ShopAction,
) -> Result<Json<ShopActionResponse>, axum::http::StatusCode> { ) -> Result<Json<ShopActionResponse>, axum::http::StatusCode> {
let item = state.items.iter().find(|i| i.id == input.item_id) let item = state
.items
.iter()
.find(|i| i.id == input.item_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
let price = items::item_price(item); let price = items::item_price(item);
{ {
let chars = state.characters.read().await; let chars = state.characters.read().await;
let char = chars.iter().find(|c| c.id == char_id) let char = chars
.iter()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
if char.gold < price { if char.gold < price {
return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
@ -779,7 +869,9 @@ async fn buy_item(
let updated = { let updated = {
let mut chars = state.characters.write().await; let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id) let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
char.gold -= price; char.gold -= price;
char.inventory.push(input.item_id.clone()); char.inventory.push(input.item_id.clone());
@ -803,17 +895,25 @@ async fn sell_item(
Path(char_id): Path<u32>, Path(char_id): Path<u32>,
input: ShopAction, input: ShopAction,
) -> Result<Json<ShopActionResponse>, axum::http::StatusCode> { ) -> Result<Json<ShopActionResponse>, axum::http::StatusCode> {
let item = state.items.iter().find(|i| i.id == input.item_id) let item = state
.items
.iter()
.find(|i| i.id == input.item_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
let sell_price = items::item_sell_price(item); let sell_price = items::item_sell_price(item);
let updated = { let updated = {
let mut chars = state.characters.write().await; let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id) let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
let pos = char.inventory.iter().position(|id| id == &input.item_id) let pos = char
.inventory
.iter()
.position(|id| id == &input.item_id)
.ok_or(axum::http::StatusCode::BAD_REQUEST)?; .ok_or(axum::http::StatusCode::BAD_REQUEST)?;
char.inventory.remove(pos); char.inventory.remove(pos);
@ -855,13 +955,17 @@ async fn heal_character(
let char = chars.iter().find(|c| c.id == char_id); let char = chars.iter().find(|c| c.id == char_id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
let updated = { let updated = {
let mut chars = state.characters.write().await; let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id) let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
let missing = char.max_hp - char.hp; let missing = char.max_hp - char.hp;
@ -906,28 +1010,34 @@ async fn get_songs(
let char = chars.iter().find(|c| c.id == char_id); let char = chars.iter().find(|c| c.id == char_id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
let char = { let char = {
let chars = state.characters.read().await; let chars = state.characters.read().await;
chars.iter().find(|c| c.id == char_id) chars
.ok_or(axum::http::StatusCode::NOT_FOUND)?.clone() .iter()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?
.clone()
}; };
if char.class != Class::Bard { if char.class != Class::Bard {
return Err(axum::http::StatusCode::FORBIDDEN); return Err(axum::http::StatusCode::FORBIDDEN);
} }
let available = songs::available_songs(char.level); let available = songs::available_songs(char.level);
let songs_json: Vec<SongSummary> = available.iter().map(|s| { let songs_json: Vec<SongSummary> = available
SongSummary { .iter()
.map(|s| SongSummary {
id: s.id.to_string(), id: s.id.to_string(),
name: s.name.to_string(), name: s.name.to_string(),
description: s.description.to_string(), description: s.description.to_string(),
required_level: s.required_level, required_level: s.required_level,
combat_effect: s.combat_effect.to_string(), combat_effect: s.combat_effect.to_string(),
} })
}).collect(); .collect();
Ok(Json(SongsResponse { Ok(Json(SongsResponse {
active_song: char.active_song.clone(), active_song: char.active_song.clone(),
available_songs: songs_json, available_songs: songs_json,
@ -950,13 +1060,17 @@ async fn set_song(
let char = chars.iter().find(|c| c.id == char_id); let char = chars.iter().find(|c| c.id == char_id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
let updated = { let updated = {
let mut chars = state.characters.write().await; let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id) let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
if char.class != Class::Bard { if char.class != Class::Bard {
@ -964,9 +1078,13 @@ async fn set_song(
} }
match &input.song_id { match &input.song_id {
None => { char.active_song = None; } None => {
char.active_song = None;
}
Some(song_id) => { Some(song_id) => {
let song = songs::BARD_SONGS.iter().find(|s| s.id == *song_id) let song = songs::BARD_SONGS
.iter()
.find(|s| s.id == *song_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
if song.required_level > char.level { if song.required_level > char.level {
return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
@ -981,7 +1099,10 @@ async fn set_song(
Ok(Json(SetSongResponse { Ok(Json(SetSongResponse {
success: true, success: true,
message: format!("Song set to {}", updated.active_song.as_deref().unwrap_or("none")), message: format!(
"Song set to {}",
updated.active_song.as_deref().unwrap_or("none")
),
active_song: updated.active_song.clone(), active_song: updated.active_song.clone(),
})) }))
} }
@ -996,14 +1117,19 @@ async fn get_spells(
let char = chars.iter().find(|c| c.id == char_id); let char = chars.iter().find(|c| c.id == char_id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
let char = { let char = {
let chars = state.characters.read().await; let chars = state.characters.read().await;
chars.iter().find(|c| c.id == char_id) chars
.ok_or(axum::http::StatusCode::NOT_FOUND)?.clone() .iter()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?
.clone()
}; };
let class_str = char.class.as_str().to_string(); let class_str = char.class.as_str().to_string();
if !spells::is_caster_class(&class_str) { if !spells::is_caster_class(&class_str) {
@ -1011,7 +1137,9 @@ async fn get_spells(
} }
let spell_level = (char.level / 3) + 1; let spell_level = (char.level / 3) + 1;
let available = spells::spells_for_class(&class_str, spell_level); let available = spells::spells_for_class(&class_str, spell_level);
let spells_json: Vec<serde_json::Value> = available.iter().map(|s| { let spells_json: Vec<serde_json::Value> = available
.iter()
.map(|s| {
serde_json::json!({ serde_json::json!({
"id": &s.id, "id": &s.id,
"code": &s.code, "code": &s.code,
@ -1027,7 +1155,8 @@ async fn get_spells(
"heal_max": s.heal_max, "heal_max": s.heal_max,
"known": char.known_spells.contains(&s.id.to_string()), "known": char.known_spells.contains(&s.id.to_string()),
}) })
}).collect(); })
.collect();
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"caster_class": class_str, "caster_class": class_str,
"spell_level": spell_level, "spell_level": spell_level,
@ -1051,7 +1180,6 @@ struct SpellRequest {
spell_id: Option<String>, spell_id: Option<String>,
} }
async fn level_up( async fn level_up(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Extension(auth): Extension<AuthUser>, Extension(auth): Extension<AuthUser>,
@ -1062,13 +1190,17 @@ async fn level_up(
let char = chars.iter().find(|c| c.id == char_id); let char = chars.iter().find(|c| c.id == char_id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
let updated = { let updated = {
let mut chars = state.characters.write().await; let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id) let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
match character::level_up(char) { match character::level_up(char) {
Ok(_) => char.clone(), Ok(_) => char.clone(),
@ -1100,14 +1232,18 @@ async fn learn_spell_level(
let char = chars.iter().find(|c| c.id == char_id); let char = chars.iter().find(|c| c.id == char_id);
match char { match char {
None => return Err(axum::http::StatusCode::NOT_FOUND), None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN), Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
Some(_) => {}, return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
} }
} }
if let Some(spell_id) = input.spell_id { if let Some(spell_id) = input.spell_id {
let updated = { let updated = {
let mut chars = state.characters.write().await; let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id) let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
if spell_id.is_empty() { if spell_id.is_empty() {
char.active_spell = None; char.active_spell = None;
@ -1131,11 +1267,14 @@ async fn learn_spell_level(
}))); })));
} }
let spell_level = input.spell_level let spell_level = input
.spell_level
.ok_or(axum::http::StatusCode::BAD_REQUEST)?; .ok_or(axum::http::StatusCode::BAD_REQUEST)?;
let updated = { let updated = {
let mut chars = state.characters.write().await; let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id) let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?; .ok_or(axum::http::StatusCode::NOT_FOUND)?;
let class_str = char.class.as_str().to_string(); let class_str = char.class.as_str().to_string();
@ -1158,7 +1297,8 @@ async fn learn_spell_level(
} }
let spell_list = spells::spells_for_class(&class_str, spell_level); let spell_list = spells::spells_for_class(&class_str, spell_level);
let new_ids: Vec<String> = spell_list.iter() let new_ids: Vec<String> = spell_list
.iter()
.filter(|s| !char.known_spells.contains(&s.id.to_string())) .filter(|s| !char.known_spells.contains(&s.id.to_string()))
.map(|s| s.id.to_string()) .map(|s| s.id.to_string())
.collect(); .collect();
@ -1256,7 +1396,9 @@ async fn login(
Json(input): Json<LoginRequest>, Json(input): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, axum::http::StatusCode> { ) -> Result<Json<AuthResponse>, axum::http::StatusCode> {
let users = state.users.read().await; let users = state.users.read().await;
let user = users.iter().find(|u| u.name == input.name) let user = users
.iter()
.find(|u| u.name == input.name)
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?; .ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
if !sb_auth::hash::verify_password(&input.password, &user.password_hash) { if !sb_auth::hash::verify_password(&input.password, &user.password_hash) {
@ -1282,28 +1424,30 @@ async fn login(
pub fn seed_users() -> Arc<AppState> { pub fn seed_users() -> Arc<AppState> {
let db_path = std::env::var("SB_DB_PATH"); let db_path = std::env::var("SB_DB_PATH");
let db = match db_path.as_deref() { let db = match db_path.as_deref() {
Ok(path) if !path.is_empty() => { Ok(path) if !path.is_empty() => match sb_db::Database::open(std::path::Path::new(path)) {
match sb_db::Database::open(std::path::Path::new(path)) {
Ok(db) => { Ok(db) => {
tracing::info!("database loaded from {}", path); tracing::info!("database loaded from {}", path);
Some(db) Some(db)
} }
Err(e) => { Err(e) => {
tracing::warn!("failed to open database at {}: {}, falling back to in-memory", path, e); tracing::warn!(
"failed to open database at {}: {}, falling back to in-memory",
path,
e
);
None None
} }
} },
}
_ => None, _ => None,
}; };
let admin_pass = std::env::var("SB_ADMIN_PASS").unwrap_or_else(|_| "admin".into()); let admin_pass = std::env::var("SB_ADMIN_PASS").unwrap_or_else(|_| "admin".into());
let player_pass = std::env::var("SB_PLAYER_PASS").unwrap_or_else(|_| "player".into()); let player_pass = std::env::var("SB_PLAYER_PASS").unwrap_or_else(|_| "player".into());
let admin_hash = sb_auth::hash::hash_password(&admin_pass) let admin_hash =
.expect("failed to hash admin password"); sb_auth::hash::hash_password(&admin_pass).expect("failed to hash admin password");
let player_hash = sb_auth::hash::hash_password(&player_pass) let player_hash =
.expect("failed to hash player password"); sb_auth::hash::hash_password(&player_pass).expect("failed to hash player password");
let (users, characters, shop_stock, next_id) = if let Some(ref db) = db { let (users, characters, shop_stock, next_id) = if let Some(ref db) = db {
let users = db.load_users().unwrap_or_else(|e| { let users = db.load_users().unwrap_or_else(|e| {
@ -1325,8 +1469,18 @@ pub fn seed_users() -> Arc<AppState> {
}; };
let mut initial_users = vec![ let mut initial_users = vec![
User { id: 1, name: "admin".into(), role: Role::Admin, password_hash: admin_hash }, User {
User { id: 2, name: "player".into(), role: Role::Player, password_hash: player_hash }, id: 1,
name: "admin".into(),
role: Role::Admin,
password_hash: admin_hash,
},
User {
id: 2,
name: "player".into(),
role: Role::Player,
password_hash: player_hash,
},
]; ];
if let Some(ref db) = db { if let Some(ref db) = db {

View file

@ -0,0 +1 @@

View file

@ -0,0 +1 @@

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
use sb_shared::{Character, CharacterStats, Class, Race};
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 +8,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 +46,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,16 +103,86 @@ 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 {
@ -65,7 +195,10 @@ fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 {
} }
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,17 +209,26 @@ 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
.iter()
.find(|c| &c.class == class)
.expect("invalid class");
30 + info.base_hp / 3 30 + info.base_hp / 3
} }
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
.iter()
.find(|c| &c.class == class)
.expect("invalid class");
100 + info.base_hp / 2 100 + info.base_hp / 2
} }
@ -104,7 +246,9 @@ pub fn hp_regen_rate() -> i64 {
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;
@ -140,7 +284,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()
@ -177,7 +322,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,7 +338,9 @@ 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 * (100.0 * n * n) as i64
} }
@ -227,11 +376,17 @@ 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")
} }
#[cfg(test)] #[cfg(test)]
@ -242,15 +397,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,
} }
} }
@ -351,8 +526,20 @@ mod tests {
#[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);

View file

@ -7,9 +7,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 {
@ -93,7 +92,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
@ -155,7 +155,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 +169,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 +198,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 +226,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);
@ -232,7 +251,9 @@ pub fn resolve_combat_full(
} }
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 +264,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 += 15;
}
Some("damage") => {
s.ob_base += 20;
}
_ => {} _ => {}
} }
s s
@ -253,8 +278,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 += 10;
}
Some("weaken") => {
s.ob_base = (s.ob_base as f64 * 0.7) as i32;
}
_ => {} _ => {}
} }
s s
@ -272,18 +301,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 +335,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,7 +409,10 @@ 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));
} }

View file

@ -1,9 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const ENEMIES_JSON: &str = const ENEMIES_JSON: &str = include_str!("../../../../skara-brae-common/data/enemies.json");
include_str!("../../../../skara-brae-common/data/enemies.json"); const ZONES_JSON: &str = include_str!("../../../../skara-brae-common/data/adventures/zones.json");
const ZONES_JSON: &str =
include_str!("../../../../skara-brae-common/data/adventures/zones.json");
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnemyDef { pub struct EnemyDef {

View file

@ -1,9 +1,7 @@
use sb_shared::Item; use sb_shared::Item;
const WEAPONS_JSON: &str = const WEAPONS_JSON: &str = include_str!("../../../../skara-brae-common/data/items/weapons.json");
include_str!("../../../../skara-brae-common/data/items/weapons.json"); const ARMOR_JSON: &str = include_str!("../../../../skara-brae-common/data/items/armor.json");
const ARMOR_JSON: &str =
include_str!("../../../../skara-brae-common/data/items/armor.json");
const ACCESSORIES_JSON: &str = const ACCESSORIES_JSON: &str =
include_str!("../../../../skara-brae-common/data/items/accessories.json"); include_str!("../../../../skara-brae-common/data/items/accessories.json");
@ -14,8 +12,7 @@ pub fn load_all_items() -> Vec<Item> {
serde_json::from_str(WEAPONS_JSON).expect("failed to parse weapons.json"); serde_json::from_str(WEAPONS_JSON).expect("failed to parse weapons.json");
items.extend(weapons); items.extend(weapons);
let armor: Vec<Item> = let armor: Vec<Item> = serde_json::from_str(ARMOR_JSON).expect("failed to parse armor.json");
serde_json::from_str(ARMOR_JSON).expect("failed to parse armor.json");
items.extend(armor); items.extend(armor);
let accessories: Vec<Item> = let accessories: Vec<Item> =

View file

@ -1,6 +1,6 @@
use sb_shared::{Character, Item, AttackType}; use crate::data::{EncounterEntry, EnemyDef};
use crate::data::{EnemyDef, EncounterEntry};
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,
@ -13,17 +13,24 @@ pub fn pick_enemy(
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
.iter()
.find(|e| e.id == enc.enemy_id)
.expect("encounter references valid enemy") .expect("encounter references valid enemy")
.clone(); .clone();
} }
} }
enemies.iter().find(|e| e.id == encounters.last().unwrap().enemy_id) enemies
.iter()
.find(|e| e.id == encounters.last().unwrap().enemy_id)
.expect("encounter references valid enemy") .expect("encounter references valid enemy")
.clone() .clone()
} }
pub fn equipped_attack_type(equipment: &std::collections::HashMap<String, Option<String>>, items: &[Item]) -> AttackType { pub fn equipped_attack_type(
equipment: &std::collections::HashMap<String, Option<String>>,
items: &[Item],
) -> AttackType {
if let Some(Some(weapon_id)) = equipment.get("main_hand") 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 +40,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 +56,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
@ -60,7 +73,10 @@ pub fn total_defense(equipment: &std::collections::HashMap<String, Option<String
} }
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<(), &'static str> {
let inv_pos = char.inventory.iter().position(|id| id == item_id) let inv_pos = char
.inventory
.iter()
.position(|id| id == item_id)
.ok_or("item not in inventory")?; .ok_or("item not in inventory")?;
char.inventory.remove(inv_pos); char.inventory.remove(inv_pos);
@ -73,7 +89,9 @@ pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<()
} }
pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, &'static str> { pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, &'static str> {
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("nothing equipped in slot")?;
@ -125,42 +143,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,
} }
} }
@ -211,16 +257,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 +281,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("item not in inventory")
);
} }
#[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 +301,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("nothing equipped in slot")
);
} }
#[test] #[test]
@ -270,42 +330,77 @@ 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![],
}, },

View file

@ -55,5 +55,8 @@ pub const BARD_SONGS: &[SongDef] = &[
]; ];
pub fn available_songs(level: i32) -> Vec<&'static SongDef> { pub fn available_songs(level: i32) -> Vec<&'static SongDef> {
BARD_SONGS.iter().filter(|s| s.required_level <= level).collect() BARD_SONGS
.iter()
.filter(|s| s.required_level <= level)
.collect()
} }

View file

@ -18,8 +18,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 +30,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> {
@ -63,7 +63,10 @@ pub fn max_spell_points(class: &str, level: i32) -> i32 {
} }
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] {

View file

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

View file

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

View file

@ -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 {