diff --git a/crates/sb-api/Cargo.toml b/crates/sb-api/Cargo.toml index 0a94120..947f467 100644 --- a/crates/sb-api/Cargo.toml +++ b/crates/sb-api/Cargo.toml @@ -10,6 +10,7 @@ tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +rand = "0.9" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } sb-core = { path = "../sb-core" } diff --git a/crates/sb-api/src/main.rs b/crates/sb-api/src/main.rs index 25cc881..bc673b3 100644 --- a/crates/sb-api/src/main.rs +++ b/crates/sb-api/src/main.rs @@ -1,12 +1,11 @@ #![allow(dead_code)] -use axum::routing::get; -use axum::Router; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; pub mod routes; pub mod ws; +pub mod middleware; #[tokio::main] async fn main() { @@ -17,9 +16,9 @@ async fn main() { )) .init(); - let app = Router::new() - .route("/health", get(health)) - .merge(routes::build()); + let state = routes::seed_users(); + + let app = routes::build(state); let listener = tokio::net::TcpListener::bind("0.0.0.0:8080") .await @@ -28,7 +27,3 @@ async fn main() { tracing::info!("listening on {:?}", listener.local_addr()); axum::serve(listener, app).await.expect("server error"); } - -async fn health() -> &'static str { - "ok" -} diff --git a/crates/sb-api/src/middleware/auth.rs b/crates/sb-api/src/middleware/auth.rs index e69de29..5db3365 100644 --- a/crates/sb-api/src/middleware/auth.rs +++ b/crates/sb-api/src/middleware/auth.rs @@ -0,0 +1,53 @@ +use axum::{ + extract::Request, + http::{header, StatusCode}, + middleware::Next, + response::Response, +}; +use sb_auth::jwt::verify_token; + +#[derive(Clone)] +pub struct AuthUser { + pub user_id: u32, + pub name: String, + pub role: String, +} + +pub async fn auth_middleware( + mut request: Request, + next: Next, +) -> Result { + let auth_header = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let token = auth_header + .strip_prefix("Bearer ") + .unwrap_or(auth_header); + + if token.is_empty() { + return Err(StatusCode::UNAUTHORIZED); + } + + let claims = verify_token(token).map_err(|_| StatusCode::UNAUTHORIZED)?; + + request.extensions_mut().insert(AuthUser { + user_id: claims.sub, + name: claims.name, + role: claims.role, + }); + + Ok(next.run(request).await) +} + +impl std::fmt::Debug for AuthUser { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AuthUser") + .field("user_id", &self.user_id) + .field("name", &self.name) + .field("role", &self.role) + .finish() + } +} diff --git a/crates/sb-api/src/routes/mod.rs b/crates/sb-api/src/routes/mod.rs index b98e5f6..edd8d65 100644 --- a/crates/sb-api/src/routes/mod.rs +++ b/crates/sb-api/src/routes/mod.rs @@ -1,5 +1,1348 @@ -use axum::{routing::get, Router}; +#![allow(private_interfaces)] -pub fn build() -> Router { - Router::new().route("/", get(|| async { "skara-brae api" })) +use axum::extract::{Path, Query, State}; +use axum::routing::{get, post}; +use axum::{Extension, Json, Router}; +use crate::middleware::auth::AuthUser; +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::data; +use sb_core::services as domain; +use sb_core::items; +use sb_core::songs; +use sb_core::spells; +use sb_shared::{ + Character, Class, CreateCharacter, Item, Role, User, + ApiInfoResponse, RacesResponse, RaceInfo, + ClassesResponse, ClassInfo, EnemiesResponse, EnemySummary, + AdventuresResponse, ZoneSummary, + GymResponse, TrainStatResponse, TrainingStatResponse, ShopResponse, + InventoryItemResponse, ShopItemResponse, ShopActionResponse, + SongsResponse, SongSummary, SetSongResponse, + LevelUpResponse, +}; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub(crate) struct AppState { + users: RwLock>, + items: Vec, + enemies: Vec, + zones: Vec, + characters: RwLock>, + next_char_id: RwLock, + shop_stock: RwLock>, + db: Option, +} + +async fn persist_character(state: &Arc, char: &Character) { + if let Some(ref db) = state.db + && let Err(e) = db.save_character(char) + { + tracing::error!("failed to persist character {}: {}", char.id, e); + } +} + +async fn persist_user(state: &Arc, user: &User) { + if let Some(ref db) = state.db + && let Err(e) = db.save_user(user) + { + tracing::error!("failed to persist user {}: {}", user.id, e); + } +} + +async fn persist_shop_stock(state: &Arc, item_id: &str, quantity: u32) { + if let Some(ref db) = state.db + && let Err(e) = db.save_shop_stock(item_id, quantity) + { + tracing::error!("failed to persist shop stock {}: {}", item_id, e); + } +} + +#[derive(serde::Deserialize)] +pub struct ItemsQuery { + r#type: Option, + quality: Option, + slot: Option, +} + +#[derive(serde::Deserialize)] +pub struct AdventureRequest { + pub zone_id: Option, +} + +pub fn build(state: Arc) -> Router { + let public = Router::new() + .route("/", get(|| async { "skara-brae api" })) + .route("/api/auth/register", post(register)) + .route("/api/auth/login", post(login)); + + let protected = Router::new() + .route("/api/info", get(api_info)) + .route("/api/users", get(get_users)) + .route("/api/users/{id}", get(get_user)) + .route("/api/items", get(get_items)) + .route("/api/items/{id}", get(get_item)) + .route("/api/races", get(get_races)) + .route("/api/classes", get(get_classes)) + .route("/api/enemies", get(get_enemies)) + .route("/api/adventures", get(get_adventures)) + .route("/api/users/{user_id}/characters", get(get_characters).post(create_character)) + .route("/api/characters/{id}", get(get_character)) + .route("/api/characters/{id}/adventure", get(run_adventure)) + .route("/api/characters/{id}/equip", post(equip_item)) + .route("/api/characters/{id}/unequip", post(unequip_item)) + .route("/api/characters/{id}/gym", get(get_gym).post(train_stat)) + .route("/api/characters/{id}/shop", get(get_shop).post(shop_action)) + .route("/api/characters/{id}/heal", post(heal_character)) + .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}/level-up", post(level_up)) + .route_layer(axum::middleware::from_fn( + crate::middleware::auth::auth_middleware, + )); + + Router::new() + .merge(public) + .merge(protected) + .with_state(state) +} + +async fn api_info() -> Json { + Json(ApiInfoResponse { + name: sb_common::PROJECT_NAME.to_string(), + version: "0.1.0".to_string(), + }) +} + +async fn get_users(State(state): State>) -> Json> { + Json(state.users.read().await.clone()) +} + +async fn get_user( + State(state): State>, + Extension(auth): Extension, + Path(id): Path, +) -> Result, axum::http::StatusCode> { + if id != auth.user_id && auth.role != "admin" { + return Err(axum::http::StatusCode::FORBIDDEN); + } + state + .users + .read() + .await + .iter() + .find(|u| u.id == id) + .cloned() + .map(Json) + .ok_or(axum::http::StatusCode::NOT_FOUND) +} + +async fn get_items( + State(state): State>, + Query(query): Query, +) -> Json> { + let items = &state.items; + let filtered = items + .iter() + .filter(|item| { + if let Some(ref t) = query.r#type + && format!("{:?}", item.r#type).to_lowercase() != t.to_lowercase() + { + return false; + } + if let Some(ref q) = query.quality + && format!("{:?}", item.quality).to_lowercase() != q.to_lowercase() + { + return false; + } + if let Some(ref s) = query.slot + && item.slot != *s + { + return false; + } + true + }) + .cloned() + .collect(); + Json(filtered) +} + +async fn get_item( + State(state): State>, + Path(id): Path, +) -> Result, axum::http::StatusCode> { + state + .items + .iter() + .find(|item| item.id == id) + .cloned() + .map(Json) + .ok_or(axum::http::StatusCode::NOT_FOUND) +} + +async fn get_races() -> Json { + let races: Vec = RACES + .iter() + .map(|r| RaceInfo { + race: r.race.as_str().to_string(), + name: r.name.to_string(), + str_mod: r.str_mod, + int_mod: r.int_mod, + dex_mod: r.dex_mod, + con_mod: r.con_mod, + luck_mod: r.luck_mod, + }) + .collect(); + Json(RacesResponse { races }) +} + +async fn get_classes() -> Json { + let classes: Vec = CLASSES + .iter() + .map(|c| ClassInfo { + class: c.class.as_str().to_string(), + name: c.name.to_string(), + primary_stat: c.primary_stat.to_string(), + secondary_stat: c.secondary_stat.to_string(), + base_hp: c.base_hp, + hp_per_con: c.hp_per_con, + is_caster: spells::caster_classes().contains(&c.class.as_str()), + }) + .collect(); + Json(ClassesResponse { classes }) +} + +async fn get_enemies(State(state): State>) -> Json { + let enemies: Vec = state + .enemies + .iter() + .map(|e| EnemySummary { + id: e.id.clone(), + name: e.name.clone(), + level: e.level, + hp: e.hp, + }) + .collect(); + Json(EnemiesResponse { enemies }) +} + +async fn get_adventures(State(state): State>) -> Json { + let zones: Vec = state + .zones + .iter() + .map(|z| ZoneSummary { + id: z.id.clone(), + name: z.name.clone(), + description: z.description.clone(), + min_level: z.min_level, + nerve_cost: z.nerve_cost, + }) + .collect(); + Json(AdventuresResponse { zones }) +} + +async fn get_characters( + State(state): State>, + Extension(auth): Extension, + Path(user_id): Path, +) -> Result>, axum::http::StatusCode> { + if user_id != auth.user_id && auth.role != "admin" { + return Err(axum::http::StatusCode::FORBIDDEN); + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + let mut chars = state.characters.write().await; + for c in chars.iter_mut() { + sb_core::character::apply_regen(c, now); + } + let user_chars: Vec = chars.iter().filter(|c| c.user_id == user_id).cloned().collect(); + Ok(Json(user_chars)) +} + +async fn create_character( + State(state): State>, + Extension(auth): Extension, + Path(user_id): Path, + Json(input): Json, +) -> Result, axum::http::StatusCode> { + if user_id != auth.user_id && auth.role != "admin" { + return Err(axum::http::StatusCode::FORBIDDEN); + } + let valid = { + let users = state.users.read().await; + users.iter().any(|u| u.id == user_id) + }; + if !valid { + return Err(axum::http::StatusCode::NOT_FOUND); + } + + let name = input.name.trim().to_string(); + if name.is_empty() || name.len() > 32 { + return Err(axum::http::StatusCode::BAD_REQUEST); + } + + let mut char = character::create_character(user_id, name, input.race, input.class); + + { + let mut id = state.next_char_id.write().await; + *id += 1; + char.id = *id; + } + + state.characters.write().await.push(char.clone()); + persist_character(&state, &char).await; + + Ok(Json(char)) +} + +async fn get_character( + State(state): State>, + Extension(auth): Extension, + Path(id): Path, +) -> Result, axum::http::StatusCode> { + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == id); + match char { + 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(_) => {}, + } + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + let mut chars = state.characters.write().await; + let char = chars.iter_mut().find(|c| c.id == id) + .ok_or(axum::http::StatusCode::NOT_FOUND)?; + sb_core::character::apply_regen(char, now); + Ok(Json(char.clone())) +} + +#[derive(serde::Deserialize)] +struct EquipRequest { + item_id: String, +} + +async fn equip_item( + State(state): State>, + Extension(auth): Extension, + Path(char_id): Path, + Json(input): Json, +) -> Result, axum::http::StatusCode> { + let item = state.items.iter().find(|i| i.id == input.item_id) + .ok_or(axum::http::StatusCode::NOT_FOUND)?; + + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id); + match char { + 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(_) => {}, + } + } + + let updated = { + 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)?; + + domain::equip_item(char, item, &input.item_id) + .map_err(|_| axum::http::StatusCode::BAD_REQUEST)?; + + char.clone() + }; + persist_character(&state, &updated).await; + + Ok(Json(updated)) +} + +#[derive(serde::Deserialize)] +struct UnequipRequest { + slot: String, +} + +async fn unequip_item( + State(state): State>, + Extension(auth): Extension, + Path(char_id): Path, + Json(input): Json, +) -> Result, axum::http::StatusCode> { + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id); + match char { + 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(_) => {}, + } + } + let updated = { + 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)?; + + domain::unequip_item(char, &input.slot) + .map_err(|_| axum::http::StatusCode::BAD_REQUEST)?; + + char.clone() + }; + persist_character(&state, &updated).await; + + Ok(Json(updated)) +} + +async fn run_adventure( + State(state): State>, + Extension(auth): Extension, + Path(char_id): Path, + Query(query): Query, +) -> Result, axum::http::StatusCode> { + let zone_id = query.zone_id.as_deref().unwrap_or("skara_brae_streets"); + + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id); + match char { + 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(_) => {}, + } + } + + let zone = state.zones.iter().find(|z| z.id == zone_id) + .ok_or(axum::http::StatusCode::NOT_FOUND)?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + let (char, enemy_def) = { + 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)?; + + sb_core::character::apply_regen(char, now); + + if char.hp <= 0 { + return Err(axum::http::StatusCode::BAD_REQUEST); + } + if char.nerve < zone.nerve_cost { + return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); + } + char.nerve -= zone.nerve_cost; + + let mut rng = rand::rng(); + let enemy = domain::pick_enemy(&mut rng, &zone.encounters, &state.enemies); + (char.clone(), enemy) + }; + + let char_attack_type = domain::equipped_attack_type(&char.equipment, &state.items); + let char_ob = domain::equipped_damage(&char.equipment, &state.items); + let char_db = domain::total_defense(&char.equipment, &state.items); + + let char_combat = CombatStats { + name: char.name.clone(), + hp: char.hp, + level: char.level, + strength: char.stats.strength, + dexterity: char.stats.dexterity, + constitution: char.stats.constitution, + luck: char.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: parse_attack_type(&enemy_def.attack_type), + }; + + let spell_effect = char.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 log = combat::resolve_combat_full( + char_combat, + enemy_combat, + enemy_def.xp, + enemy_def.gold_min, + enemy_def.gold_max, + char.active_song.as_deref().and_then(|sid| { + songs::BARD_SONGS.iter().find(|s| s.id == sid).map(|s| s.combat_effect) + }), + spell_effect.as_ref(), + &mut rand::rng(), + ); + + let (loot_names, updated_char) = { + let mut chars = state.characters.write().await; + let dropped_ids: Vec = if let Some(c) = chars.iter_mut().find(|c| c.id == char_id) { + use rand::Rng; + let mut rng = rand::rng(); + + if log.winner == c.name { + c.xp += log.xp_gained as i64; + c.gold += log.gold_gained; + let char_hp = log.rounds.iter() + .rfind(|r| r.defender == c.name) + .map(|r| r.defender_hp); + if let Some(hp) = char_hp { + c.hp = hp; + } + + let mut drops = Vec::new(); + for drop in &enemy_def.loot { + if rng.random_bool(drop.chance) + && state.items.iter().any(|i| i.id == drop.item_id) + { + c.inventory.push(drop.item_id.clone()); + drops.push(drop.item_id.clone()); + } + } + drops + } else { + c.hp = 0; + Vec::new() + } + } else { + Vec::new() + }; + + if let Some(c) = chars.iter_mut().find(|c| c.id == char_id) + && c.active_spell.is_some() + && let Some(sp) = spells::find_spell(c.active_spell.as_deref().unwrap_or("")) + { + c.spell_points = (c.spell_points - sp.spell_points).max(0); + } + + let updated = chars.iter().find(|c| c.id == char_id).cloned(); + drop(chars); + let loot: Vec = dropped_ids.iter().filter_map(|id| { + state.items.iter().find(|i| &i.id == id).map(|i| i.name.clone()) + }).collect(); + (loot, updated) + }; + + if let Some(ref c) = updated_char { + persist_character(&state, c).await; + } + + Ok(Json(serde_json::json!({ + "zone": zone_id, + "enemy": &enemy_def.name, + "combat_log": &log, + "loot": loot_names, + }))) +} + +async fn get_gym( + State(state): State>, + Extension(auth): Extension, + Path(char_id): Path, +) -> Result, axum::http::StatusCode> { + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id); + match char { + 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(_) => {}, + } + } + let char = { + let chars = state.characters.read().await; + chars.iter().find(|c| c.id == char_id) + .ok_or(axum::http::StatusCode::NOT_FOUND)?.clone() + }; + let stats = [ + ("strength", char.stats.strength), + ("intelligence", char.stats.intelligence), + ("dexterity", char.stats.dexterity), + ("constitution", char.stats.constitution), + ("luck", char.stats.luck), + ]; + let stat_responses: Vec = stats.iter().map(|(name, val)| { + let ground = TRAINING_GROUNDS.iter().find(|g| g.stat == *name).expect("valid stat"); + TrainingStatResponse { + stat: name.to_string(), + name: ground.name.to_string(), + description: ground.description.to_string(), + value: *val, + cost: character::training_cost(*val), + } + }).collect(); + Ok(Json(GymResponse { + energy: char.energy, + max_energy: char.max_energy, + gold: char.gold, + stats: stat_responses, + })) +} + +#[derive(serde::Deserialize)] +struct TrainRequest { + stat: String, +} + +async fn train_stat( + State(state): State>, + Extension(auth): Extension, + Path(char_id): Path, + Json(input): Json, +) -> Result, axum::http::StatusCode> { + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id); + match char { + 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(_) => {}, + } + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + let (updated, _value) = { 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)?; + + sb_core::character::apply_regen(char, now); + + let new_val = domain::train_stat(char, &input.stat) + .map_err(|e| match e { + "not enough energy" => axum::http::StatusCode::UNPROCESSABLE_ENTITY, + "not enough gold" => axum::http::StatusCode::UNPROCESSABLE_ENTITY, + _ => axum::http::StatusCode::BAD_REQUEST, + })?; + + (char.clone(), new_val) + }; + persist_character(&state, &updated).await; + + Ok(Json(TrainStatResponse { + success: true, + message: format!("Trained {} (+1)", input.stat), + character: Some(updated), + })) +} + +async fn get_shop( + State(state): State>, + Extension(auth): Extension, + Path(char_id): Path, +) -> Result, axum::http::StatusCode> { + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id); + match char { + 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(_) => {}, + } + } + let char = { + let chars = state.characters.read().await; + chars.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_items: Vec = state.items.iter().filter(|item| { + item.always_in_shop || shop_stock.contains_key(&item.id) + }).map(|item| { + ShopItemResponse { + id: item.id.clone(), + name: item.name.clone(), + item_type: serde_json::to_value(&item.r#type).unwrap().as_str().unwrap().to_string(), + quality: serde_json::to_value(&item.quality).unwrap().as_str().unwrap().to_string(), + slot: Some(item.slot.clone()), + stats: item.stats.clone(), + description: item.description.clone(), + attack_type: item.attack_type.as_ref().map(|at| format!("{:?}", at)), + value: items::item_price(item), + stock: shop_stock.get(&item.id).copied().unwrap_or(0), + } + }).collect(); + let bag_items: Vec = char.inventory.iter().filter_map(|id| { + state.items.iter().find(|i| &i.id == id).map(|item| { + InventoryItemResponse { + id: item.id.clone(), + name: item.name.clone(), + item_type: serde_json::to_value(&item.r#type).unwrap().as_str().unwrap().to_string(), + quality: serde_json::to_value(&item.quality).unwrap().as_str().unwrap().to_string(), + slot: Some(item.slot.clone()), + stats: item.stats.clone(), + description: item.description.clone(), + attack_type: item.attack_type.as_ref().map(|t| format!("{:?}", t)), + value: item.value, + equipped: false, + } + }) + }).collect(); + Ok(Json(ShopResponse { + gold: char.gold, + inventory: bag_items, + shop_stock: shop_items, + })) +} + +#[derive(serde::Deserialize)] +struct ShopAction { + action: String, + item_id: String, +} + +async fn shop_action( + State(state): State>, + Extension(auth): Extension, + Path(char_id): Path, + Json(input): Json, +) -> Result, axum::http::StatusCode> { + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id); + match char { + 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(_) => {}, + } + } + match input.action.as_str() { + "buy" => buy_item(State(state), Path(char_id), input).await, + "sell" => sell_item(State(state), Path(char_id), input).await, + _ => Err(axum::http::StatusCode::BAD_REQUEST), + } +} + +async fn buy_item( + State(state): State>, + Path(char_id): Path, + input: ShopAction, +) -> Result, axum::http::StatusCode> { + let item = state.items.iter().find(|i| i.id == input.item_id) + .ok_or(axum::http::StatusCode::NOT_FOUND)?; + + let price = items::item_price(item); + + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id) + .ok_or(axum::http::StatusCode::NOT_FOUND)?; + if char.gold < price { + return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); + } + } + + let stock_qty = if !item.always_in_shop { + let mut stock = state.shop_stock.write().await; + let qty = stock.entry(input.item_id.clone()).or_insert(0); + if *qty < 1 { + return Err(axum::http::StatusCode::GONE); + } + *qty -= 1; + *qty + } else { + 0 + }; + + let updated = { + 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)?; + char.gold -= price; + char.inventory.push(input.item_id.clone()); + char.clone() + }; + persist_character(&state, &updated).await; + if !item.always_in_shop { + persist_shop_stock(&state, &input.item_id, stock_qty).await; + } + + Ok(Json(ShopActionResponse { + success: true, + message: format!("Bought {}", item.name), + gold: updated.gold, + inventory: Vec::new(), + })) +} + +async fn sell_item( + State(state): State>, + Path(char_id): Path, + input: ShopAction, +) -> Result, axum::http::StatusCode> { + let item = state.items.iter().find(|i| i.id == input.item_id) + .ok_or(axum::http::StatusCode::NOT_FOUND)?; + + let sell_price = items::item_sell_price(item); + + let updated = { + 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)?; + + let pos = char.inventory.iter().position(|id| id == &input.item_id) + .ok_or(axum::http::StatusCode::BAD_REQUEST)?; + + char.inventory.remove(pos); + char.gold += sell_price; + + char.clone() + }; + persist_character(&state, &updated).await; + + if !item.always_in_shop { + let mut stock = state.shop_stock.write().await; + *stock.entry(input.item_id.clone()).or_insert(0) += 1; + let qty = stock.get(&input.item_id).copied().unwrap_or(0); + drop(stock); + persist_shop_stock(&state, &input.item_id, qty).await; + } + + Ok(Json(ShopActionResponse { + success: true, + message: format!("Sold {}", item.name), + gold: updated.gold, + inventory: Vec::new(), + })) +} + +#[derive(serde::Deserialize)] +struct HealRequest { + amount: Option, +} + +async fn heal_character( + State(state): State>, + Extension(auth): Extension, + Path(char_id): Path, + Json(input): Json, +) -> Result, axum::http::StatusCode> { + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id); + match char { + 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(_) => {}, + } + } + let updated = { + 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)?; + + let missing = char.max_hp - char.hp; + if missing <= 0 { + return Err(axum::http::StatusCode::BAD_REQUEST); + } + + let heal_amount = match input.amount.as_deref() { + Some("full") => missing, + Some("partial") => (missing as f64 / 2.0).ceil() as i32, + _ => missing, + }; + let actual = heal_amount.min(missing); + let cost = actual.max(1); + + if char.gold < cost { + return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); + } + + char.gold -= cost; + char.hp = (char.hp + actual).min(char.max_hp); + + char.clone() + }; + persist_character(&state, &updated).await; + + Ok(Json(serde_json::json!({ + "hp": updated.hp, + "max_hp": updated.max_hp, + "gold": updated.gold, + "healed": true, + }))) +} + +async fn get_songs( + State(state): State>, + Extension(auth): Extension, + Path(char_id): Path, +) -> Result, axum::http::StatusCode> { + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id); + match char { + 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(_) => {}, + } + } + let char = { + let chars = state.characters.read().await; + chars.iter().find(|c| c.id == char_id) + .ok_or(axum::http::StatusCode::NOT_FOUND)?.clone() + }; + if char.class != Class::Bard { + return Err(axum::http::StatusCode::FORBIDDEN); + } + let available = songs::available_songs(char.level); + let songs_json: Vec = available.iter().map(|s| { + SongSummary { + id: s.id.to_string(), + name: s.name.to_string(), + description: s.description.to_string(), + required_level: s.required_level, + combat_effect: s.combat_effect.to_string(), + } + }).collect(); + Ok(Json(SongsResponse { + active_song: char.active_song.clone(), + available_songs: songs_json, + })) +} + +#[derive(serde::Deserialize)] +struct SongRequest { + song_id: Option, +} + +async fn set_song( + State(state): State>, + Extension(auth): Extension, + Path(char_id): Path, + Json(input): Json, +) -> Result, axum::http::StatusCode> { + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id); + match char { + 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(_) => {}, + } + } + let updated = { + 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)?; + + if char.class != Class::Bard { + return Err(axum::http::StatusCode::FORBIDDEN); + } + + match &input.song_id { + None => { char.active_song = None; } + Some(song_id) => { + let song = songs::BARD_SONGS.iter().find(|s| s.id == *song_id) + .ok_or(axum::http::StatusCode::NOT_FOUND)?; + if song.required_level > char.level { + return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); + } + char.active_song = Some(song_id.clone()); + } + } + + char.clone() + }; + persist_character(&state, &updated).await; + + Ok(Json(SetSongResponse { + success: true, + message: format!("Song set to {}", updated.active_song.as_deref().unwrap_or("none")), + active_song: updated.active_song.clone(), + })) +} + +async fn get_spells( + State(state): State>, + Extension(auth): Extension, + Path(char_id): Path, +) -> Result, axum::http::StatusCode> { + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id); + match char { + 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(_) => {}, + } + } + let char = { + let chars = state.characters.read().await; + chars.iter().find(|c| c.id == char_id) + .ok_or(axum::http::StatusCode::NOT_FOUND)?.clone() + }; + let class_str = char.class.as_str().to_string(); + if !spells::is_caster_class(&class_str) { + return Err(axum::http::StatusCode::FORBIDDEN); + } + let spell_level = (char.level / 3) + 1; + let available = spells::spells_for_class(&class_str, spell_level); + let spells_json: Vec = available.iter().map(|s| { + serde_json::json!({ + "id": &s.id, + "code": &s.code, + "name": &s.name, + "spell_level": s.spell_level, + "spell_points": s.spell_points, + "range": &s.range, + "description": &s.description, + "combat_effect": &s.combat_effect, + "combat_damage_min": s.combat_damage_min, + "combat_damage_max": s.combat_damage_max, + "heal_min": s.heal_min, + "heal_max": s.heal_max, + "known": char.known_spells.contains(&s.id.to_string()), + }) + }).collect(); + Ok(Json(serde_json::json!({ + "caster_class": class_str, + "spell_level": spell_level, + "spell_points": char.spell_points, + "max_spell_points": char.max_spell_points, + "spells": spells_json, + "learn_costs": { + "2": spells::spell_learn_cost(2), + "3": spells::spell_learn_cost(3), + "4": spells::spell_learn_cost(4), + "5": spells::spell_learn_cost(5), + "6": spells::spell_learn_cost(6), + "7": spells::spell_learn_cost(7), + }, + }))) +} + +#[derive(serde::Deserialize)] +struct SpellRequest { + spell_level: Option, + spell_id: Option, +} + + +async fn level_up( + State(state): State>, + Extension(auth): Extension, + Path(char_id): Path, +) -> Result, axum::http::StatusCode> { + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id); + match char { + 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(_) => {}, + } + } + let updated = { + 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)?; + match character::level_up(char) { + Ok(_) => char.clone(), + Err(msg) => { + let status = match msg { + "not enough XP" => axum::http::StatusCode::UNPROCESSABLE_ENTITY, + _ => axum::http::StatusCode::UNPROCESSABLE_ENTITY, + }; + return Err(status); + } + } + }; + persist_character(&state, &updated).await; + Ok(Json(LevelUpResponse { + success: true, + message: format!("Level up! You are now level {}", updated.level), + character: Some(updated), + })) +} + +async fn learn_spell_level( + State(state): State>, + Extension(auth): Extension, + Path(char_id): Path, + Json(input): Json, +) -> Result, axum::http::StatusCode> { + { + let chars = state.characters.read().await; + let char = chars.iter().find(|c| c.id == char_id); + match char { + 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(_) => {}, + } + } + if let Some(spell_id) = input.spell_id { + let updated = { + 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)?; + if spell_id.is_empty() { + char.active_spell = None; + } else { + if !char.known_spells.contains(&spell_id) { + return Err(axum::http::StatusCode::FORBIDDEN); + } + let sp = spells::find_spell(&spell_id).ok_or(axum::http::StatusCode::NOT_FOUND)?; + if char.spell_points < sp.spell_points { + return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); + } + char.spell_points -= sp.spell_points; + char.active_spell = Some(spell_id); + } + char.clone() + }; + persist_character(&state, &updated).await; + return Ok(Json(serde_json::json!({ + "active_spell": updated.active_spell, + "spell_points": updated.spell_points, + }))); + } + + let spell_level = input.spell_level + .ok_or(axum::http::StatusCode::BAD_REQUEST)?; + let updated = { + 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)?; + + let class_str = char.class.as_str().to_string(); + if !spells::is_caster_class(&class_str) { + return Err(axum::http::StatusCode::FORBIDDEN); + } + + if !(1..=7).contains(&spell_level) { + return Err(axum::http::StatusCode::BAD_REQUEST); + } + + let current_level = (char.level / 3) + 1; + if spell_level > current_level { + return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); + } + + let cost = spells::spell_learn_cost(spell_level); + if char.gold < cost { + return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); + } + + let spell_list = spells::spells_for_class(&class_str, spell_level); + let new_ids: Vec = spell_list.iter() + .filter(|s| !char.known_spells.contains(&s.id.to_string())) + .map(|s| s.id.to_string()) + .collect(); + + if new_ids.is_empty() { + return Err(axum::http::StatusCode::BAD_REQUEST); + } + + char.gold -= cost; + for id in &new_ids { + if !char.known_spells.contains(id) { + char.known_spells.push(id.clone()); + } + } + char.max_spell_points = spells::max_spell_points(&class_str, char.level); + char.spell_points = char.max_spell_points; + + char.clone() + }; + persist_character(&state, &updated).await; + + Ok(Json(serde_json::json!({ + "learned_count": updated.known_spells.len(), + "spell_points": updated.spell_points, + "max_spell_points": updated.max_spell_points, + "gold": updated.gold, + }))) +} + +#[derive(serde::Deserialize)] +struct RegisterRequest { + name: String, + password: String, +} + +#[derive(serde::Serialize)] +struct AuthResponse { + token: String, + user_id: u32, + name: String, + role: String, +} + +#[derive(serde::Deserialize)] +struct LoginRequest { + name: String, + password: String, +} + +async fn register( + State(state): State>, + Json(input): Json, +) -> Result, axum::http::StatusCode> { + if input.name.len() < 3 || input.name.len() > 32 { + return Err(axum::http::StatusCode::BAD_REQUEST); + } + if input.password.len() < 6 { + return Err(axum::http::StatusCode::BAD_REQUEST); + } + + let user = { + let mut users = state.users.write().await; + if users.iter().any(|u| u.name == input.name) { + return Err(axum::http::StatusCode::CONFLICT); + } + + let password_hash = sb_auth::hash::hash_password(&input.password) + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + + let id = users.iter().map(|u| u.id).max().unwrap_or(0) + 1; + let user = User { + id, + name: input.name.clone(), + role: Role::Player, + password_hash, + }; + users.push(user.clone()); + user + }; + persist_user(&state, &user).await; + + let token = sb_auth::jwt::create_token(user.id, &user.name, "player") + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(AuthResponse { + token, + user_id: user.id, + name: user.name.clone(), + role: "player".to_string(), + })) +} + +async fn login( + State(state): State>, + Json(input): Json, +) -> Result, axum::http::StatusCode> { + let users = state.users.read().await; + let user = users.iter().find(|u| u.name == input.name) + .ok_or(axum::http::StatusCode::UNAUTHORIZED)?; + + if !sb_auth::hash::verify_password(&input.password, &user.password_hash) { + return Err(axum::http::StatusCode::UNAUTHORIZED); + } + + let role_str = match user.role { + Role::Admin => "admin", + Role::Player => "player", + }; + + let token = sb_auth::jwt::create_token(user.id, &user.name, role_str) + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(AuthResponse { + token, + user_id: user.id, + name: user.name.clone(), + role: role_str.to_string(), + })) +} + +pub fn seed_users() -> Arc { + let db_path = std::env::var("SB_DB_PATH"); + let db = match db_path.as_deref() { + Ok(path) if !path.is_empty() => { + match sb_db::Database::open(std::path::Path::new(path)) { + Ok(db) => { + tracing::info!("database loaded from {}", path); + Some(db) + } + Err(e) => { + tracing::warn!("failed to open database at {}: {}, falling back to in-memory", path, e); + None + } + } + } + _ => None, + }; + + 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 admin_hash = sb_auth::hash::hash_password(&admin_pass) + .expect("failed to hash admin password"); + let player_hash = 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 = db.load_users().unwrap_or_else(|e| { + tracing::warn!("failed to load users from db: {}", e); + Vec::new() + }); + let characters = db.load_characters().unwrap_or_else(|e| { + tracing::warn!("failed to load characters from db: {}", e); + Vec::new() + }); + let stock = db.load_shop_stock().unwrap_or_else(|e| { + tracing::warn!("failed to load shop stock from db: {}", e); + std::collections::HashMap::new() + }); + let next_id = db.next_character_id().unwrap_or(0); + (users, characters, stock, next_id) + } else { + (Vec::new(), Vec::new(), std::collections::HashMap::new(), 0) + }; + + let mut initial_users = vec![ + User { 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 users.iter().all(|u| u.name != "admin") { + db.save_user(&initial_users[0]).ok(); + } + if users.iter().all(|u| u.name != "player") { + db.save_user(&initial_users[1]).ok(); + } + for user in &users { + if user.name != "admin" && user.name != "player" { + initial_users.push(user.clone()); + } + } + } + + Arc::new(AppState { + users: RwLock::new(initial_users), + items: sb_core::items::load_all_items(), + enemies: data::load_enemies(), + zones: data::load_zones(), + characters: RwLock::new(characters), + next_char_id: RwLock::new(next_id), + shop_stock: RwLock::new(shop_stock), + db, + }) } diff --git a/crates/sb-auth/Cargo.toml b/crates/sb-auth/Cargo.toml index 9493713..5782451 100644 --- a/crates/sb-auth/Cargo.toml +++ b/crates/sb-auth/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] jsonwebtoken = "9" argon2 = "0.5" +getrandom = "0.3" serde = { version = "1", features = ["derive"] } chrono = { version = "0.4", features = ["serde"] } sb-shared = { path = "../sb-shared" } diff --git a/crates/sb-auth/src/hash.rs b/crates/sb-auth/src/hash.rs index e69de29..e67a1ea 100644 --- a/crates/sb-auth/src/hash.rs +++ b/crates/sb-auth/src/hash.rs @@ -0,0 +1,21 @@ +use argon2::{ + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; + +pub fn hash_password(password: &str) -> Result { + let mut salt_bytes = [0u8; 16]; + getrandom::fill(&mut salt_bytes).expect("failed to generate salt"); + let salt = SaltString::encode_b64(&salt_bytes).expect("valid salt"); + let argon2 = Argon2::default(); + let hash = argon2.hash_password(password.as_bytes(), &salt)?; + Ok(hash.to_string()) +} + +pub fn verify_password(password: &str, hash: &str) -> bool { + let parsed = match PasswordHash::new(hash) { + Ok(h) => h, + Err(_) => return false, + }; + Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok() +} diff --git a/crates/sb-auth/src/jwt.rs b/crates/sb-auth/src/jwt.rs index e69de29..3745c22 100644 --- a/crates/sb-auth/src/jwt.rs +++ b/crates/sb-auth/src/jwt.rs @@ -0,0 +1,47 @@ +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use chrono::{Utc, Duration}; + +fn jwt_secret() -> Vec { + std::env::var("JWT_SECRET") + .map(|s| s.into_bytes()) + .unwrap_or_else(|_| b"skara_brae_dev_secret_change_in_prod".to_vec()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + pub sub: u32, + pub name: String, + pub role: String, + pub exp: usize, +} + +pub fn create_token(user_id: u32, name: &str, role: &str) -> Result { + let exp = Utc::now() + .checked_add_signed(Duration::hours(24)) + .expect("valid timestamp") + .timestamp() as usize; + + let claims = Claims { + sub: user_id, + name: name.to_string(), + role: role.to_string(), + exp, + }; + + jsonwebtoken::encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(&jwt_secret()), + ) +} + +pub fn verify_token(token: &str) -> Result { + let mut validation = Validation::default(); + validation.leeway = 60; + jsonwebtoken::decode::( + token, + &DecodingKey::from_secret(&jwt_secret()), + &validation, + ).map(|d| d.claims) +} diff --git a/crates/sb-auth/src/lib.rs b/crates/sb-auth/src/lib.rs index 1e7a355..ee79ece 100644 --- a/crates/sb-auth/src/lib.rs +++ b/crates/sb-auth/src/lib.rs @@ -1,3 +1,2 @@ -pub mod jwt; pub mod hash; -pub mod session; +pub mod jwt; diff --git a/crates/sb-auth/src/session.rs b/crates/sb-auth/src/session.rs deleted file mode 100644 index e69de29..0000000 diff --git a/crates/sb-core/src/adventure/mod.rs b/crates/sb-core/src/adventure/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/crates/sb-core/src/adventure/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/sb-core/src/blessings/mod.rs b/crates/sb-core/src/blessings/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/crates/sb-core/src/blessings/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/sb-core/src/character.rs b/crates/sb-core/src/character.rs new file mode 100644 index 0000000..1c89d2d --- /dev/null +++ b/crates/sb-core/src/character.rs @@ -0,0 +1,415 @@ +use sb_shared::{Character, CharacterStats, Class, Race}; +use crate::spells; + +pub struct TrainingGround { + pub stat: &'static str, + pub name: &'static str, + pub description: &'static str, +} + +pub const TRAINING_GROUNDS: &[TrainingGround] = &[ + TrainingGround { stat: "strength", name: "The Anvil", description: "Pound steel and lift stone under the guidance of master smiths." }, + TrainingGround { stat: "dexterity", name: "The Archery Range", description: "Sharpen reflexes with bow, blade, and balance drills." }, + TrainingGround { stat: "constitution", name: "The Endurance Pits", description: "Push your body past its limits in grueling trials of resilience." }, + TrainingGround { stat: "intelligence", name: "The Sage's Study", description: "Study ancient tomes and practice arcane disciplines." }, + TrainingGround { stat: "luck", name: "Fortune's Shrine", description: "Consult oracles and tempt fate in games of chance." }, +]; + +pub struct RaceInfo { + pub race: Race, + pub name: &'static str, + pub str_mod: i64, + pub int_mod: i64, + pub dex_mod: i64, + pub con_mod: i64, + pub luck_mod: i64, +} + +pub const RACES: &[RaceInfo] = &[ + RaceInfo { race: Race::Human, name: "Human", str_mod: 0, int_mod: 0, dex_mod: 0, con_mod: 0, luck_mod: 0 }, + RaceInfo { race: Race::Elf, name: "Elf", str_mod: -2, int_mod: 4, dex_mod: 2, con_mod: -2, luck_mod: 0 }, + RaceInfo { race: Race::Dwarf, name: "Dwarf", str_mod: 2, int_mod: -2, dex_mod: -2, con_mod: 4, luck_mod: 0 }, + RaceInfo { race: Race::Hobbit, name: "Hobbit", str_mod: -4, int_mod: 0, dex_mod: 2, con_mod: -2, luck_mod: 4 }, + RaceInfo { race: Race::HalfElf, name: "Half-Elf", str_mod: 0, int_mod: 2, dex_mod: 2, con_mod: 0, luck_mod: 0 }, +]; + +pub struct ClassInfo { + pub class: Class, + pub name: &'static str, + pub primary_stat: &'static str, + pub secondary_stat: &'static str, + pub base_hp: i32, + pub hp_per_con: f64, +} + +pub const CLASSES: &[ClassInfo] = &[ + ClassInfo { class: Class::Warrior, name: "Warrior", primary_stat: "strength", secondary_stat: "constitution", base_hp: 20, hp_per_con: 3.0 }, + ClassInfo { class: Class::Paladin, name: "Paladin", primary_stat: "strength", secondary_stat: "luck", base_hp: 18, hp_per_con: 2.5 }, + ClassInfo { class: Class::Rogue, name: "Rogue", primary_stat: "dexterity", secondary_stat: "luck", base_hp: 14, hp_per_con: 2.0 }, + ClassInfo { class: Class::Bard, name: "Bard", primary_stat: "dexterity", secondary_stat: "intelligence", base_hp: 14, hp_per_con: 2.0 }, + ClassInfo { class: Class::Hunter, name: "Hunter", primary_stat: "dexterity", secondary_stat: "strength", base_hp: 16, hp_per_con: 2.5 }, + ClassInfo { class: Class::Monk, name: "Monk", primary_stat: "dexterity", secondary_stat: "constitution", base_hp: 15, hp_per_con: 2.5 }, + ClassInfo { class: Class::Mage, name: "Mage", primary_stat: "intelligence", secondary_stat: "dexterity", base_hp: 11, hp_per_con: 1.8 }, + ClassInfo { class: Class::Wizard, name: "Wizard", primary_stat: "intelligence", secondary_stat: "constitution", base_hp: 10, hp_per_con: 1.5 }, + ClassInfo { class: Class::Conjurer, name: "Conjurer", primary_stat: "intelligence", secondary_stat: "luck", base_hp: 10, hp_per_con: 1.5 }, + ClassInfo { class: Class::Sorcerer, name: "Sorcerer", primary_stat: "intelligence", secondary_stat: "strength", base_hp: 12, hp_per_con: 2.0 }, +]; + +pub fn stat_bonus(stat: i64) -> i64 { + (stat - 20) / 2 +} + +fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 { + let base: i64 = rng.random_range(6..=16); + base + modifier +} + +pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats { + let info = RACES.iter().find(|r| &r.race == race).expect("invalid race"); + CharacterStats { + strength: roll_stat(rng, info.str_mod), + intelligence: roll_stat(rng, info.int_mod), + dexterity: roll_stat(rng, info.dex_mod), + constitution: roll_stat(rng, info.con_mod), + luck: roll_stat(rng, info.luck_mod), + } +} + +pub fn calc_max_hp(class: &Class, stats: &CharacterStats) -> i32 { + let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class"); + (info.base_hp as f64 + stats.constitution as f64 * info.hp_per_con) as i32 +} + +pub fn calc_max_energy(class: &Class) -> i32 { + let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class"); + 30 + info.base_hp / 3 +} + +pub fn calc_max_nerve(class: &Class) -> i32 { + let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class"); + 100 + info.base_hp / 2 +} + +pub fn energy_regen_rate() -> i64 { + 180000 +} + +pub fn nerve_regen_rate() -> i64 { + 300000 +} + +pub fn hp_regen_rate() -> i64 { + 300000 +} + +pub fn apply_regen(char: &mut Character, now_ms: i64) { + let elapsed = now_ms - char.last_regen; + if elapsed <= 0 { return; } + let energy_ticks = (elapsed / energy_regen_rate()) as i32; + let nerve_ticks = (elapsed / nerve_regen_rate()) as i32; + let hp_ticks = (elapsed / hp_regen_rate()) as i32; + if energy_ticks > 0 { + char.energy = (char.energy + energy_ticks).min(char.max_energy); + } + if nerve_ticks > 0 { + char.nerve = (char.nerve + nerve_ticks).min(char.max_nerve); + } + if hp_ticks > 0 && char.hp > 0 && char.hp < char.max_hp { + let regen_per_tick = (char.max_hp as f64 * 0.05).ceil() as i32; + char.hp = (char.hp + regen_per_tick * hp_ticks).min(char.max_hp); + } + char.last_regen = now_ms; +} + +pub fn training_cost(stat: i64) -> i32 { + let scaled = (stat as f64 / 10.0).floor() as i32; + (5.0_f64 * 1.3_f64.powi(scaled)) as i32 +} + +pub fn create_character(user_id: u32, name: String, race: Race, class: Class) -> Character { + let mut rng = rand::rng(); + let class_str = class.as_str().to_string(); + let stats = roll_stats(&mut rng, &race); + let max_hp = calc_max_hp(&class, &stats); + let max_energy = calc_max_energy(&class); + let max_nerve = calc_max_nerve(&class); + + let known_spells: Vec = if spells::is_caster_class(&class_str) { + spells::spells_for_class(&class_str, 1).iter() + .filter(|s| s.spell_level <= 1) + .map(|s| s.id.clone()) + .collect() + } else { + Vec::new() + }; + + Character { + id: 0, + user_id, + name, + race, + class, + level: 1, + xp: 0, + stats, + hp: max_hp, + max_hp, + energy: max_energy, + max_energy, + nerve: max_nerve, + max_nerve, + gold: 50, + last_regen: 0, + equipment: [ + ("head".into(), None), + ("body".into(), None), + ("off_hand".into(), None), + ("main_hand".into(), None), + ("hands".into(), None), + ("feet".into(), None), + ("back".into(), None), + ("neck".into(), None), + ("ring".into(), None), + ("trinket".into(), None), + ("instrument".into(), None), + ].into_iter().collect(), + inventory: Vec::new(), + active_song: None, + known_spells, + active_spell: None, + spell_points: spells::max_spell_points(&class_str, 1), + max_spell_points: spells::max_spell_points(&class_str, 1), + } +} + +pub const MAX_LEVEL: i32 = 100; +pub const XP_FACTOR: i64 = 18; + +pub fn xp_for_level(level: i32) -> i64 { + if level <= 1 { return 0; } + let n = (level - 1) as f64; + XP_FACTOR * (100.0 * n * n) as i64 +} + +pub fn level_up_cost(level: i32) -> i32 { + 500 * level +} + +pub fn level_up(char: &mut Character) -> Result { + if char.level >= MAX_LEVEL { + return Err("already at max level"); + } + let next_level = char.level + 1; + if char.xp < xp_for_level(next_level) { + return Err("not enough XP"); + } + let cost = level_up_cost(next_level); + if char.gold < cost { + return Err("not enough gold"); + } + char.gold -= cost; + char.level = next_level; + let class_str = char.class.as_str().to_string(); + char.max_hp = calc_max_hp(&char.class, &char.stats); + char.hp = char.max_hp; + char.max_energy = calc_max_energy(&char.class); + char.energy = char.max_energy; + char.max_nerve = calc_max_nerve(&char.class); + char.nerve = char.max_nerve; + char.max_spell_points = spells::max_spell_points(&class_str, char.level); + char.spell_points = char.max_spell_points; + Ok(char.level) +} + +pub fn find_race_info(race: &Race) -> &'static RaceInfo { + RACES.iter().find(|r| &r.race == race).expect("invalid race") +} + +pub fn find_class_info(class: &Class) -> &'static ClassInfo { + CLASSES.iter().find(|c| &c.class == class).expect("invalid class") +} + +#[cfg(test)] +mod tests { + use super::*; + use sb_shared::{Character, CharacterStats, Class, Race}; + use std::collections::HashMap; + + fn base_char() -> Character { + Character { + id: 1, user_id: 1, name: "Test".into(), + race: Race::Human, class: Class::Warrior, + level: 1, xp: 0, + stats: CharacterStats { strength: 20, intelligence: 20, dexterity: 20, constitution: 20, luck: 20 }, + hp: 80, max_hp: 80, energy: 36, max_energy: 36, + nerve: 110, max_nerve: 110, gold: 0, last_regen: 0, + equipment: HashMap::new(), inventory: Vec::new(), + active_song: None, known_spells: Vec::new(), + active_spell: None, spell_points: 0, max_spell_points: 0, + } + } + + #[test] + fn stat_bonus_at_20_is_zero() { + assert_eq!(stat_bonus(20), 0); + } + + #[test] + fn stat_bonus_below_20_is_negative() { + assert_eq!(stat_bonus(10), -5); + assert_eq!(stat_bonus(1), -9); + } + + #[test] + fn stat_bonus_above_20_is_positive() { + assert_eq!(stat_bonus(30), 5); + assert_eq!(stat_bonus(100), 40); + } + + #[test] + fn stat_bonus_handles_large_values() { + assert_eq!(stat_bonus(1_000_000_000), 499_999_990); + } + + #[test] + fn xp_for_level_1_is_zero() { + assert_eq!(xp_for_level(1), 0); + } + + #[test] + fn xp_for_level_2_is_1800() { + assert_eq!(xp_for_level(2), 1800); + } + + #[test] + fn xp_for_level_scales_subexponentially() { + assert!(xp_for_level(3) > xp_for_level(2)); + assert!(xp_for_level(5) > xp_for_level(4)); + assert!(xp_for_level(10) > xp_for_level(5)); + assert!(xp_for_level(50) > xp_for_level(25)); + } + + #[test] + fn xp_for_level_does_not_overflow() { + assert!(xp_for_level(50) > 0); + assert!(xp_for_level(50) < i64::MAX / 2); + } + + #[test] + fn level_up_fails_at_max_level() { + let mut c = base_char(); + c.level = MAX_LEVEL; + c.xp = i64::MAX; + c.gold = i32::MAX; + assert_eq!(level_up(&mut c), Err("already at max level")); + } + + #[test] + fn level_up_cost_scales() { + assert_eq!(level_up_cost(2), 1000); + assert_eq!(level_up_cost(3), 1500); + assert_eq!(level_up_cost(10), 5000); + } + + #[test] + fn level_up_succeeds_with_enough_xp_and_gold() { + let mut c = base_char(); + c.xp = 1800; + c.gold = 1000; + let result = level_up(&mut c); + assert_eq!(result, Ok(2)); + assert_eq!(c.level, 2); + assert_eq!(c.gold, 0); + assert_eq!(c.hp, c.max_hp); + assert_eq!(c.energy, c.max_energy); + assert_eq!(c.nerve, c.max_nerve); + } + + #[test] + fn level_up_fails_without_enough_xp() { + let mut c = base_char(); + c.xp = 100; + c.gold = 10000; + assert_eq!(level_up(&mut c), Err("not enough XP")); + assert_eq!(c.level, 1); + assert_eq!(c.gold, 10000); + } + + #[test] + fn level_up_fails_without_enough_gold() { + let mut c = base_char(); + c.xp = 1800; + c.gold = 0; + assert_eq!(level_up(&mut c), Err("not enough gold")); + assert_eq!(c.level, 1); + } + + #[test] + fn calc_max_hp_depends_on_class_and_con() { + let stats_high = CharacterStats { strength: 10, intelligence: 10, dexterity: 10, constitution: 30, luck: 10 }; + let stats_low = CharacterStats { strength: 10, intelligence: 10, dexterity: 10, constitution: 10, luck: 10 }; + let hp_high = calc_max_hp(&Class::Warrior, &stats_high); + let hp_low = calc_max_hp(&Class::Warrior, &stats_low); + assert!(hp_high > hp_low); + } + + #[test] + fn calc_max_energy_and_nerve_vary_by_class() { + let warrior_energy = calc_max_energy(&Class::Warrior); + let mage_energy = calc_max_energy(&Class::Mage); + assert!(warrior_energy > mage_energy); + } + + #[test] + fn training_cost_scales_with_stat() { + assert!(training_cost(20) < training_cost(30)); + assert!(training_cost(30) < training_cost(40)); + } + + #[test] + fn apply_regen_restores_resources() { + let mut c = base_char(); + c.hp = 40; + c.energy = 10; + c.nerve = 50; + c.last_regen = 0; + apply_regen(&mut c, 1_800_000); + assert!(c.energy > 10); + assert!(c.nerve > 50); + assert!(c.hp > 40); + } + + #[test] + fn apply_regen_no_change_if_no_time_passed() { + let mut c = base_char(); + c.hp = 40; + c.last_regen = 1_800_000; + let before = c.hp; + apply_regen(&mut c, 1_800_000); + assert_eq!(c.hp, before); + } + + #[test] + fn roll_stats_returns_all_five() { + use rand:: SeedableRng; + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let stats = roll_stats(&mut rng, &Race::Human); + assert!(stats.strength >= 6 && stats.strength <= 16); + assert!(stats.intelligence >= 6 && stats.intelligence <= 16); + assert!(stats.dexterity >= 6 && stats.dexterity <= 16); + assert!(stats.constitution >= 6 && stats.constitution <= 16); + assert!(stats.luck >= 6 && stats.luck <= 16); + } + + #[test] + fn race_modifiers_apply() { + use rand:: SeedableRng; + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let human = roll_stats(&mut rng, &Race::Human); + let mut rng2 = rand::rngs::StdRng::seed_from_u64(42); + let dwarf = roll_stats(&mut rng2, &Race::Dwarf); + assert!(dwarf.constitution > human.constitution); + assert!(dwarf.strength > human.strength); + assert!(dwarf.intelligence < human.intelligence); + } +} diff --git a/crates/sb-core/src/character/mod.rs b/crates/sb-core/src/character/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/crates/sb-core/src/character/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/sb-core/src/combat.rs b/crates/sb-core/src/combat.rs new file mode 100644 index 0000000..7894cd5 --- /dev/null +++ b/crates/sb-core/src/combat.rs @@ -0,0 +1,371 @@ +use rand::Rng; +use sb_shared::{AttackType, CombatLog, CombatRound, CritEntry, CritResult}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::LazyLock; + +const CRIT_TABLES_JSON: &str = + include_str!("../../../../skara-brae-common/data/crit-tables/combat.json"); + +static CRIT_TABLES: LazyLock>> = LazyLock::new(|| { + serde_json::from_str(CRIT_TABLES_JSON).expect("failed to parse crit tables") +}); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CombatStats { + pub name: String, + pub hp: i32, + pub level: i32, + pub strength: i64, + pub dexterity: i64, + pub constitution: i64, + pub luck: i64, + pub ob_base: i32, + pub db_base: i32, + pub attack_type: AttackType, +} + +impl CombatStats { + pub fn offensive_bonus(&self) -> i32 { + let str_bonus = super::character::stat_bonus(self.strength) as i32; + str_bonus + self.ob_base + } + + pub fn defensive_bonus(&self) -> i32 { + let dex_bonus = super::character::stat_bonus(self.dexterity) as i32; + let con_bonus = (super::character::stat_bonus(self.constitution) as i32) / 2; + dex_bonus + con_bonus + self.db_base + } +} + +pub fn parse_attack_type(s: &str) -> AttackType { + match s { + "slash" => AttackType::Slash, + "crush" => AttackType::Crush, + "puncture" => AttackType::Puncture, + _ => AttackType::Crush, + } +} + +pub fn crit_damage_cap(attacker_level: i32, defender_level: i32, defender_hp: i32) -> Option { + let diff = defender_level - attacker_level; + if diff <= 0 { + return None; + } + let cap_pct = match diff { + 1..=9 => return None, + 10..=19 => 0.60, + 20..=49 => 0.40, + 50..=99 => 0.15, + _ => 0.05, + }; + Some((defender_hp as f64 * cap_pct) as i32) +} + +pub fn open_ended_roll(rng: &mut impl Rng) -> i32 { + let roll: i32 = rng.random_range(1..=100); + let mut total = roll; + if roll >= 96 { + total += open_ended_roll(rng); + } else if roll <= 5 { + total -= open_ended_roll(rng); + } + total +} + +pub fn resolve_attack( + rng: &mut impl Rng, + attacker: &CombatStats, + defender: &CombatStats, +) -> CombatRound { + let raw_roll = open_ended_roll(rng); + let ob = attacker.offensive_bonus(); + let db = defender.defensive_bonus(); + let attack_total = raw_roll + ob; + let result = attack_total - db; + + let mut damage = 0; + let mut crit = None; + + if result > 0 { + damage = result; + if let Some(severity) = crit_severity(result) + && let Some(crit_entry) = lookup_crit(&attacker.attack_type, &severity) + { + let crit_damage = (damage as f64 * crit_entry.damage_multiplier) as i32; + let capped = if let Some(cap) = crit_damage_cap(attacker.level, defender.level, defender.hp) { + crit_damage.min(cap) + } else { + crit_damage + }; + damage = capped; + crit = Some(CritResult { + severity: severity.clone(), + crit: crit_entry, + }); + } + } + + CombatRound { + attacker: attacker.name.clone(), + defender: defender.name.clone(), + raw_roll, + ob, + db, + result, + damage, + crit, + defender_hp: 0, + } +} + +pub fn crit_severity(result: i32) -> Option { + if result >= 151 { + Some("D".into()) + } else if result >= 101 { + Some("C".into()) + } else if result >= 51 { + Some("B".into()) + } else if result >= 21 { + Some("A".into()) + } else { + None + } +} + +pub fn lookup_crit(attack_type: &AttackType, severity: &str) -> Option { + let type_key = format!("{:?}", attack_type).to_lowercase(); + CRIT_TABLES.get(&type_key)?.get(severity).cloned() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpellEffect { + pub combat_effect: String, + pub damage_min: i32, + pub damage_max: i32, + pub heal_min: i32, + pub heal_max: i32, +} + +pub fn resolve_combat( + attacker: CombatStats, + defender: CombatStats, + xp_reward: i32, + gold_min: i32, + gold_max: i32, + rng: &mut impl Rng, +) -> CombatLog { + resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, None, None, rng) +} + +pub fn resolve_combat_with_song( + attacker: CombatStats, + defender: CombatStats, + xp_reward: i32, + gold_min: i32, + gold_max: i32, + song_effect: Option<&str>, + rng: &mut impl Rng, +) -> CombatLog { + resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, song_effect, None, rng) +} + +#[allow(clippy::too_many_arguments)] +pub fn resolve_combat_full( + attacker: CombatStats, + defender: CombatStats, + xp_reward: i32, + gold_min: i32, + gold_max: i32, + song_effect: Option<&str>, + spell: Option<&SpellEffect>, + rng: &mut impl Rng, +) -> CombatLog { + let mut rng = rng; + let mut a_hp = attacker.hp; + let mut d_hp = defender.hp; + let mut rounds = Vec::new(); + + loop { + let mut round = resolve_attack(&mut rng, &apply_song_to_attacker(&attacker, song_effect), &apply_song_to_defender(&defender, song_effect)); + + if let Some(sp) = spell + && sp.combat_effect == "damage" + { + let spell_dmg = rng.random_range(sp.damage_min..=sp.damage_max); + round.damage += spell_dmg; + } + + d_hp = (d_hp - round.damage).max(0); + round.defender_hp = d_hp; + rounds.push(round); + + if d_hp <= 0 { + let gold = rng.random_range(gold_min..=gold_max); + return CombatLog { + rounds, + winner: attacker.name.clone(), + loser: defender.name.clone(), + xp_gained: xp_reward, + gold_gained: gold, + }; + } + + let mut round = resolve_attack(&mut rng, &defender, &apply_song_to_defender(&make_temp_stats(&attacker, a_hp), song_effect)); + a_hp = (a_hp - round.damage).max(0); + round.defender_hp = a_hp; + rounds.push(round); + + if a_hp <= 0 { + return CombatLog { + rounds, + winner: defender.name.clone(), + loser: attacker.name.clone(), + xp_gained: 0, + gold_gained: 0, + }; + } + + if song_effect == Some("heal") && a_hp > 0 && a_hp < attacker.hp { + let heal_amt = (attacker.hp as f64 * 0.03).ceil() as i32; + a_hp = (a_hp + heal_amt).min(attacker.hp); + } + + if let Some(sp) = spell + && sp.combat_effect == "heal" && a_hp > 0 && a_hp < attacker.hp + { + let heal_amt = rng.random_range(sp.heal_min..=sp.heal_max); + a_hp = (a_hp + heal_amt).min(attacker.hp); + } + } +} + +fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats { + let mut s = stats.clone(); + match song_effect { + Some("accuracy") => { s.ob_base += 15; } + Some("damage") => { s.ob_base += 20; } + _ => {} + } + s +} + +fn apply_song_to_defender(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats { + let mut s = stats.clone(); + match song_effect { + Some("defense") | Some("magic_resist") => { s.db_base += 10; } + Some("weaken") => { s.ob_base = (s.ob_base as f64 * 0.7) as i32; } + _ => {} + } + s +} + +fn make_temp_stats(original: &CombatStats, hp: i32) -> CombatStats { + let mut s = original.clone(); + s.hp = hp; + s +} + +#[cfg(test)] +mod tests { + use super::*; + + fn attacker() -> CombatStats { + CombatStats { + name: "Hero".into(), hp: 100, level: 1, + strength: 50, dexterity: 40, constitution: 40, luck: 20, + ob_base: 20, db_base: 10, + attack_type: AttackType::Slash, + } + } + + fn defender() -> CombatStats { + CombatStats { + name: "Goblin".into(), hp: 50, level: 1, + strength: 30, dexterity: 30, constitution: 30, luck: 10, + ob_base: 10, db_base: 5, + attack_type: AttackType::Crush, + } + } + + #[test] + fn open_ended_roll_stays_in_range() { + use rand::SeedableRng; + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + for _ in 0..1000 { + let roll = open_ended_roll(&mut rng); + assert!(roll > -500 && roll < 500, "roll out of expected range: {}", roll); + } + } + + #[test] + fn offensive_bonus_includes_stat_and_base() { + let a = attacker(); + let ob = a.offensive_bonus(); + assert!(ob > a.ob_base); + } + + #[test] + fn defensive_bonus_includes_stat_and_base() { + let d = defender(); + let db = d.defensive_bonus(); + assert!(db > d.db_base); + } + + #[test] + fn crit_damage_cap_equal_level_is_none() { + assert_eq!(crit_damage_cap(5, 5, 100), None); + assert_eq!(crit_damage_cap(1, 1, 100), None); + } + + #[test] + fn crit_damage_cap_higher_defender_reduces() { + let cap = crit_damage_cap(1, 15, 100).unwrap(); + assert!(cap < 100); + } + + #[test] + fn crit_damage_cap_scaling() { + let cap_10 = crit_damage_cap(1, 11, 100).unwrap(); + let cap_50 = crit_damage_cap(1, 51, 100).unwrap(); + let cap_100 = crit_damage_cap(1, 101, 100).unwrap(); + assert!(cap_100 < cap_50); + assert!(cap_50 < cap_10); + } + + #[test] + fn resolve_attack_can_hit_or_miss() { + use rand::SeedableRng; + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let a = attacker(); + let d = defender(); + for _ in 0..100 { + let round = resolve_attack(&mut rng, &a, &d); + assert_eq!(round.attacker, "Hero"); + assert_eq!(round.defender, "Goblin"); + assert!(round.defender_hp <= d.hp); + assert!(round.defender_hp >= 0); + } + } + + #[test] + fn resolve_combat_produces_winner() { + use rand::SeedableRng; + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let a = attacker(); + let d = defender(); + let log = resolve_combat(a.clone(), d.clone(), 10, 5, 10, &mut rng); + assert!(!log.rounds.is_empty()); + assert!(log.winner == a.name || log.winner == d.name); + assert!(log.loser != log.winner); + } + + #[test] + fn parse_attack_type_maps_known_types() { + assert!(matches!(parse_attack_type("slash"), AttackType::Slash)); + assert!(matches!(parse_attack_type("crush"), AttackType::Crush)); + assert!(matches!(parse_attack_type("puncture"), AttackType::Puncture)); + assert!(matches!(parse_attack_type("unarmed"), AttackType::Crush)); + assert!(matches!(parse_attack_type("unknown"), AttackType::Crush)); + } +} diff --git a/crates/sb-core/src/combat/mod.rs b/crates/sb-core/src/combat/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/crates/sb-core/src/combat/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/sb-core/src/crafting/mod.rs b/crates/sb-core/src/crafting/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/crates/sb-core/src/crafting/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/sb-core/src/data.rs b/crates/sb-core/src/data.rs new file mode 100644 index 0000000..389c801 --- /dev/null +++ b/crates/sb-core/src/data.rs @@ -0,0 +1,63 @@ +use serde::{Deserialize, Serialize}; + +const ENEMIES_JSON: &str = + include_str!("../../../../skara-brae-common/data/enemies.json"); +const ZONES_JSON: &str = + include_str!("../../../../skara-brae-common/data/adventures/zones.json"); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnemyDef { + pub id: String, + pub name: String, + pub level: i32, + pub stats: EnemyStats, + pub hp: i32, + pub attack_type: String, + pub damage: i32, + pub defense: i32, + pub xp: i32, + pub gold_min: i32, + pub gold_max: i32, + pub loot: Vec, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LootDrop { + pub item_id: String, + pub chance: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnemyStats { + pub strength: i64, + pub intelligence: i64, + pub dexterity: i64, + pub constitution: i64, + pub luck: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EncounterEntry { + pub enemy_id: String, + pub weight: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZoneDef { + pub id: String, + pub name: String, + pub min_level: i32, + pub max_level: i32, + pub nerve_cost: i32, + pub description: String, + pub encounters: Vec, +} + +pub fn load_enemies() -> Vec { + serde_json::from_str(ENEMIES_JSON).expect("failed to parse enemies.json") +} + +pub fn load_zones() -> Vec { + serde_json::from_str(ZONES_JSON).expect("failed to parse zones.json") +} diff --git a/crates/sb-core/src/economy/mod.rs b/crates/sb-core/src/economy/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/crates/sb-core/src/economy/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/sb-core/src/guild/mod.rs b/crates/sb-core/src/guild/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/crates/sb-core/src/guild/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/sb-core/src/items.rs b/crates/sb-core/src/items.rs index 7129d1b..e1ef7a6 100644 --- a/crates/sb-core/src/items.rs +++ b/crates/sb-core/src/items.rs @@ -1,13 +1,34 @@ -pub mod mod; +use sb_shared::Item; -use sb_common::PROJECT_NAME; +const WEAPONS_JSON: &str = + include_str!("../../../../skara-brae-common/data/items/weapons.json"); +const ARMOR_JSON: &str = + include_str!("../../../../skara-brae-common/data/items/armor.json"); +const ACCESSORIES_JSON: &str = + include_str!("../../../../skara-brae-common/data/items/accessories.json"); -const WEAPONS: &str = include_str!("../../../../skara-brae-common/data/items/weapons.json"); +pub fn load_all_items() -> Vec { + let mut items = Vec::new(); -pub fn game_name() -> &'static str { - PROJECT_NAME + let weapons: Vec = + serde_json::from_str(WEAPONS_JSON).expect("failed to parse weapons.json"); + items.extend(weapons); + + let armor: Vec = + serde_json::from_str(ARMOR_JSON).expect("failed to parse armor.json"); + items.extend(armor); + + let accessories: Vec = + serde_json::from_str(ACCESSORIES_JSON).expect("failed to parse accessories.json"); + items.extend(accessories); + + items } -pub fn load_weapons() -> Vec { - serde_json::from_str(WEAPONS).expect("failed to parse weapons data") +pub fn item_price(item: &Item) -> i32 { + item.value +} + +pub fn item_sell_price(item: &Item) -> i32 { + item.value / 2 } diff --git a/crates/sb-core/src/lib.rs b/crates/sb-core/src/lib.rs index 2b6e7e1..813f904 100644 --- a/crates/sb-core/src/lib.rs +++ b/crates/sb-core/src/lib.rs @@ -2,13 +2,8 @@ pub mod character; pub mod combat; -pub mod adventure; -pub mod economy; -pub mod guild; -pub mod crafting; -pub mod pets; -pub mod blessings; -pub mod workers; -pub mod slayer; -pub mod world; -pub mod rules; +pub mod data; +pub mod items; +pub mod services; +pub mod songs; +pub mod spells; diff --git a/crates/sb-core/src/pets/mod.rs b/crates/sb-core/src/pets/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/crates/sb-core/src/pets/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/sb-core/src/rules/mod.rs b/crates/sb-core/src/rules/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/crates/sb-core/src/rules/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/sb-core/src/services.rs b/crates/sb-core/src/services.rs new file mode 100644 index 0000000..6aae401 --- /dev/null +++ b/crates/sb-core/src/services.rs @@ -0,0 +1,319 @@ +use sb_shared::{Character, Item, AttackType}; +use crate::data::{EnemyDef, EncounterEntry}; +use rand::Rng; + +pub fn pick_enemy( + rng: &mut impl Rng, + encounters: &[EncounterEntry], + enemies: &[EnemyDef], +) -> EnemyDef { + let total_weight: i32 = encounters.iter().map(|e| e.weight).sum(); + let roll: i32 = rng.random_range(0..total_weight); + let mut remaining = roll; + for enc in encounters { + remaining -= enc.weight; + if remaining < 0 { + return enemies.iter().find(|e| e.id == enc.enemy_id) + .expect("encounter references valid enemy") + .clone(); + } + } + enemies.iter().find(|e| e.id == encounters.last().unwrap().enemy_id) + .expect("encounter references valid enemy") + .clone() +} + +pub fn equipped_attack_type(equipment: &std::collections::HashMap>, items: &[Item]) -> AttackType { + if let Some(Some(weapon_id)) = equipment.get("main_hand") + && let Some(weapon) = items.iter().find(|i| &i.id == weapon_id) + && let Some(ref at) = weapon.attack_type + { + return at.clone(); + } + AttackType::Crush +} + +pub fn equipped_damage(equipment: &std::collections::HashMap>, items: &[Item]) -> i32 { + let mut total = 0i32; + for slot in &["main_hand", "off_hand"] { + if let Some(Some(item_id)) = equipment.get(*slot) + && let Some(item) = items.iter().find(|i| &i.id == item_id) + && let Some(&dmg) = item.stats.get("damage") + { + total += dmg as i32; + } + } + total +} + +pub fn total_defense(equipment: &std::collections::HashMap>, items: &[Item]) -> i32 { + let mut total = 0i32; + for (_, item_id) in equipment.iter() { + if let Some(id) = item_id + && let Some(item) = items.iter().find(|i| &i.id == id) + && let Some(&def) = item.stats.get("defense") + { + total += def as i32; + } + } + total +} + +pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<(), &'static str> { + let inv_pos = char.inventory.iter().position(|id| id == item_id) + .ok_or("item not in inventory")?; + + char.inventory.remove(inv_pos); + let slot = item.slot.clone(); + + if let Some(Some(prev)) = char.equipment.insert(slot, Some(item_id.to_string())) { + char.inventory.push(prev); + } + Ok(()) +} + +pub fn unequip_item(char: &mut Character, slot: &str) -> Result { + let prev = char.equipment.insert(slot.to_string(), None) + .flatten() + .ok_or("nothing equipped in slot")?; + + char.inventory.push(prev.clone()); + Ok(prev) +} + +pub fn train_stat(char: &mut Character, stat: &str) -> Result { + if char.energy < 1 { + return Err("not enough energy"); + } + let current = match stat { + "strength" => char.stats.strength, + "intelligence" => char.stats.intelligence, + "dexterity" => char.stats.dexterity, + "constitution" => char.stats.constitution, + "luck" => char.stats.luck, + _ => return Err("invalid stat"), + }; + let cost = super::character::training_cost(current); + if char.gold < cost { + return Err("not enough gold"); + } + char.energy -= 1; + char.gold -= cost; + match stat { + "strength" => char.stats.strength += 1, + "intelligence" => char.stats.intelligence += 1, + "dexterity" => char.stats.dexterity += 1, + "constitution" => { + char.stats.constitution += 1; + char.max_hp = super::character::calc_max_hp(&char.class, &char.stats); + char.hp = char.hp.min(char.max_hp); + } + "luck" => char.stats.luck += 1, + _ => {} + } + let new_val = match stat { + "strength" => char.stats.strength, + "intelligence" => char.stats.intelligence, + "dexterity" => char.stats.dexterity, + "constitution" => char.stats.constitution, + "luck" => char.stats.luck, + _ => return Err("invalid stat"), + }; + Ok(new_val) +} + +#[cfg(test)] +mod tests { + use super::*; + use sb_shared::{Character, CharacterStats, Class, Race, Item, Quality, ItemType}; + use std::collections::HashMap; + + fn base_char() -> Character { + Character { + id: 1, user_id: 1, name: "Test".into(), + race: Race::Human, class: Class::Warrior, + level: 1, xp: 0, + stats: CharacterStats { strength: 20, intelligence: 20, dexterity: 20, constitution: 20, luck: 20 }, + hp: 80, max_hp: 80, energy: 10, max_energy: 36, + nerve: 110, max_nerve: 110, gold: 1000, last_regen: 0, + equipment: HashMap::new(), inventory: Vec::new(), + active_song: None, known_spells: Vec::new(), + active_spell: None, spell_points: 0, max_spell_points: 0, + } + } + + fn sword() -> Item { + Item { + id: "sword1".into(), name: "Iron Sword".into(), + r#type: ItemType::Weapon, quality: Quality::Common, + slot: "main_hand".into(), + stats: HashMap::from([("damage".into(), 15i64)]), + description: String::new(), attack_type: Some(AttackType::Slash), + value: 100, always_in_shop: true, + } + } + + fn shield() -> Item { + Item { + id: "shield1".into(), name: "Wood Shield".into(), + r#type: ItemType::Armor, quality: Quality::Common, + slot: "off_hand".into(), + stats: HashMap::from([("defense".into(), 10i64)]), + description: String::new(), attack_type: None, + value: 50, always_in_shop: true, + } + } + + #[test] + fn train_stat_increases_stat() { + let mut c = base_char(); + let cost = super::super::character::training_cost(c.stats.strength); + c.gold = cost; + let result = train_stat(&mut c, "strength"); + assert_eq!(result, Ok(21)); + assert_eq!(c.stats.strength, 21); + assert_eq!(c.energy, 9); + } + + #[test] + fn train_stat_fails_without_energy() { + let mut c = base_char(); + c.energy = 0; + assert_eq!(train_stat(&mut c, "strength"), Err("not enough energy")); + } + + #[test] + fn train_stat_fails_without_gold() { + let mut c = base_char(); + c.gold = 0; + assert_eq!(train_stat(&mut c, "strength"), Err("not enough gold")); + } + + #[test] + fn train_stat_fails_invalid_stat() { + let mut c = base_char(); + assert_eq!(train_stat(&mut c, "charisma"), Err("invalid stat")); + } + + #[test] + fn train_constitution_recals_hp() { + let mut c = base_char(); + let old_max = c.max_hp; + let cost = super::super::character::training_cost(c.stats.constitution); + c.gold = cost; + train_stat(&mut c, "constitution").unwrap(); + assert!(c.max_hp > old_max); + } + + #[test] + fn equip_item_moves_from_inventory_to_slot() { + let mut c = base_char(); + c.inventory.push("sword1".into()); + equip_item(&mut c, &sword(), "sword1").unwrap(); + assert!(!c.inventory.contains(&"sword1".to_string())); + assert_eq!(c.equipment.get("main_hand").cloned(), Some(Some("sword1".into()))); + } + + #[test] + fn equip_item_swaps_previous() { + let mut c = base_char(); + c.equipment.insert("main_hand".into(), Some("old_sword".into())); + c.inventory.push("sword1".into()); + equip_item(&mut c, &sword(), "sword1").unwrap(); + assert_eq!(c.equipment.get("main_hand").cloned(), Some(Some("sword1".into()))); + assert!(c.inventory.contains(&"old_sword".to_string())); + assert!(!c.inventory.contains(&"sword1".to_string())); + } + + #[test] + fn equip_item_fails_if_not_in_inventory() { + let mut c = base_char(); + assert_eq!(equip_item(&mut c, &sword(), "sword1"), Err("item not in inventory")); + } + + #[test] + fn unequip_item_moves_to_inventory() { + let mut c = base_char(); + c.equipment.insert("main_hand".into(), Some("sword1".into())); + let result = unequip_item(&mut c, "main_hand").unwrap(); + assert_eq!(result, "sword1"); + assert!(c.inventory.contains(&"sword1".to_string())); + assert_eq!(c.equipment.get("main_hand").cloned(), Some(None)); + } + + #[test] + fn unequip_item_fails_empty_slot() { + let mut c = base_char(); + assert_eq!(unequip_item(&mut c, "main_hand"), Err("nothing equipped in slot")); + } + + #[test] + fn equipped_damage_sums_weapon_slots() { + let items = vec![sword(), shield()]; + let eq: HashMap> = HashMap::from([ + ("main_hand".into(), Some("sword1".into())), + ("off_hand".into(), Some("shield1".into())), + ]); + assert_eq!(equipped_damage(&eq, &items), 15); + } + + #[test] + fn total_defense_sums_all_slots() { + let items = vec![sword(), shield()]; + let eq: HashMap> = HashMap::from([ + ("main_hand".into(), Some("sword1".into())), + ("off_hand".into(), Some("shield1".into())), + ]); + assert_eq!(total_defense(&eq, &items), 10); + } + + #[test] + fn equipped_attack_type_returns_weapon_type() { + let items = vec![sword()]; + let eq: HashMap> = HashMap::from([ + ("main_hand".into(), Some("sword1".into())), + ]); + assert!(matches!(equipped_attack_type(&eq, &items), AttackType::Slash)); + } + + #[test] + fn equipped_attack_type_defaults_to_crush() { + let items: Vec = vec![]; + let eq: HashMap> = HashMap::new(); + assert!(matches!(equipped_attack_type(&eq, &items), AttackType::Crush)); + } + + #[test] + fn pick_enemy_respects_weights() { + use rand::SeedableRng; + let encounters = vec![ + crate::data::EncounterEntry { enemy_id: "kobold".into(), weight: 3 }, + crate::data::EncounterEntry { enemy_id: "rat".into(), weight: 1 }, + ]; + let enemies = vec![ + crate::data::EnemyDef { + id: "kobold".into(), name: "Kobold".into(), + hp: 20, level: 1, damage: 5, defense: 2, + attack_type: "crush".into(), + xp: 10, gold_min: 1, gold_max: 5, + stats: crate::data::EnemyStats { strength: 15, intelligence: 10, dexterity: 15, constitution: 15, luck: 5 }, + description: String::new(), + loot: vec![], + }, + crate::data::EnemyDef { + id: "rat".into(), name: "Giant Rat".into(), + hp: 10, level: 1, damage: 3, defense: 1, + attack_type: "puncture".into(), + xp: 5, gold_min: 0, gold_max: 2, + stats: crate::data::EnemyStats { strength: 5, intelligence: 5, dexterity: 15, constitution: 5, luck: 5 }, + description: String::new(), + loot: vec![], + }, + ]; + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + for _ in 0..100 { + let enemy = pick_enemy(&mut rng, &encounters, &enemies); + assert!(enemy.id == "kobold" || enemy.id == "rat"); + } + } +} diff --git a/crates/sb-core/src/slayer/mod.rs b/crates/sb-core/src/slayer/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/crates/sb-core/src/slayer/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/sb-core/src/songs.rs b/crates/sb-core/src/songs.rs new file mode 100644 index 0000000..8768eb9 --- /dev/null +++ b/crates/sb-core/src/songs.rs @@ -0,0 +1,59 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct SongDef { + pub id: &'static str, + pub name: &'static str, + pub description: &'static str, + pub combat_effect: &'static str, + pub required_level: i32, +} + +pub const BARD_SONGS: &[SongDef] = &[ + SongDef { + id: "seekers_ballad", + name: "The Seeker's Ballad", + description: "Produces light while exploring and increases the party's chance to hit in combat.", + combat_effect: "accuracy", + required_level: 1, + }, + SongDef { + id: "travellers_tune", + name: "The Traveller's Tune", + description: "Makes the party more dexterous and agile, harder to hit.", + combat_effect: "defense", + required_level: 1, + }, + SongDef { + id: "badhr_kilnfest", + name: "Badh'r Kilnfest", + description: "An ancient Elven melody that heals wounds during combat and while traveling.", + combat_effect: "heal", + required_level: 1, + }, + SongDef { + id: "waylands_watch", + name: "Wayland's Watch", + description: "Soothes foes, reducing the damage they deal in combat.", + combat_effect: "weaken", + required_level: 2, + }, + SongDef { + id: "falkentyne_fury", + name: "Falkentyne's Fury", + description: "Drives the party into a berserker rage, increasing melee damage.", + combat_effect: "damage", + required_level: 3, + }, + SongDef { + id: "lucklaran", + name: "Lucklaran", + description: "Sets up a partial anti-magic field, increasing protection against spells.", + combat_effect: "magic_resist", + required_level: 4, + }, +]; + +pub fn available_songs(level: i32) -> Vec<&'static SongDef> { + BARD_SONGS.iter().filter(|s| s.required_level <= level).collect() +} diff --git a/crates/sb-core/src/spells.rs b/crates/sb-core/src/spells.rs new file mode 100644 index 0000000..e6042bc --- /dev/null +++ b/crates/sb-core/src/spells.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpellDef { + pub id: String, + pub code: String, + pub name: String, + pub caster_class: String, + pub spell_level: i32, + pub spell_points: i32, + pub range: String, + pub duration: String, + pub description: String, + pub combat_effect: String, + pub combat_damage_min: i32, + pub combat_damage_max: i32, + pub heal_min: i32, + pub heal_max: i32, +} + +const SPELLS_JSON: &str = + include_str!("../../../../skara-brae-common/data/spells/spells.json"); + +pub fn load_spells() -> Vec { + serde_json::from_str(SPELLS_JSON).expect("failed to parse spells.json") +} + +pub fn class_for_spells(class: &str) -> &str { + if class == "mage" { "magician" } else { class } +} + +pub fn spells_for_class(class: &str, spell_level: i32) -> Vec<&'static SpellDef> { + let spell_class = class_for_spells(class); + ALL_SPELLS.iter().filter(|s| { + s.caster_class == spell_class && s.spell_level <= spell_level + }).collect() +} + +pub fn find_spell(id: &str) -> Option<&'static SpellDef> { + ALL_SPELLS.iter().find(|s| s.id == id) +} + +pub fn spell_learn_cost(spell_level: i32) -> i32 { + match spell_level { + 1 => 0, + 2 => 1000, + 3 => 2000, + 4 => 4000, + 5 => 7000, + 6 => 10000, + 7 => 20000, + _ => 999999, + } +} + +pub fn max_spell_points(class: &str, level: i32) -> i32 { + let spell_class = class_for_spells(class); + let spell_level = match spell_class { + "conjurer" | "magician" | "sorcerer" | "wizard" => (level / 3) + 1, + _ => 0, + }; + spell_level * 10 +} + +pub fn is_caster_class(class: &str) -> bool { + matches!(class_for_spells(class), "conjurer" | "magician" | "sorcerer" | "wizard") +} + +pub fn caster_classes() -> &'static [&'static str] { + &["conjurer", "magician", "sorcerer", "wizard"] +} + +use std::sync::LazyLock; +static ALL_SPELLS: LazyLock> = LazyLock::new(load_spells); diff --git a/crates/sb-core/src/workers/mod.rs b/crates/sb-core/src/workers/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/crates/sb-core/src/workers/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/sb-core/src/world/mod.rs b/crates/sb-core/src/world/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/crates/sb-core/src/world/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/sb-db/Cargo.toml b/crates/sb-db/Cargo.toml index 5c5f802..d44b260 100644 --- a/crates/sb-db/Cargo.toml +++ b/crates/sb-db/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid", "migrate"] } +rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1", features = ["derive"] } +serde_json = "1" sb-shared = { path = "../sb-shared" } diff --git a/crates/sb-db/src/lib.rs b/crates/sb-db/src/lib.rs index 7a884dd..9c62f9d 100644 --- a/crates/sb-db/src/lib.rs +++ b/crates/sb-db/src/lib.rs @@ -1,4 +1,227 @@ -#![allow(dead_code)] +use rusqlite::Connection; +use sb_shared::{Character, CharacterStats, Class, Race, User, Role}; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Mutex; -pub mod models; -pub mod queries; +pub struct Database { + conn: Mutex, +} + +impl Database { + pub fn open(path: &Path) -> Result { + let conn = Connection::open(path)?; + let db = Self { conn: Mutex::new(conn) }; + db.run_migrations()?; + Ok(db) + } + + fn run_migrations(&self) -> Result<(), rusqlite::Error> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + role TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS characters ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + race TEXT NOT NULL, + class TEXT NOT NULL, + level INTEGER NOT NULL DEFAULT 1, + xp INTEGER NOT NULL DEFAULT 0, + gold INTEGER NOT NULL DEFAULT 0, + hp INTEGER NOT NULL DEFAULT 0, + max_hp INTEGER NOT NULL DEFAULT 0, + energy INTEGER NOT NULL DEFAULT 0, + max_energy INTEGER NOT NULL DEFAULT 0, + nerve INTEGER NOT NULL DEFAULT 0, + max_nerve INTEGER NOT NULL DEFAULT 0, + spell_points INTEGER NOT NULL DEFAULT 0, + max_spell_points INTEGER NOT NULL DEFAULT 0, + str_stat INTEGER NOT NULL DEFAULT 10, + int_stat INTEGER NOT NULL DEFAULT 10, + dex_stat INTEGER NOT NULL DEFAULT 10, + con_stat INTEGER NOT NULL DEFAULT 10, + luck_stat INTEGER NOT NULL DEFAULT 10, + equipment TEXT NOT NULL DEFAULT '{}', + inventory TEXT NOT NULL DEFAULT '[]', + active_song TEXT, + known_spells TEXT NOT NULL DEFAULT '[]', + active_spell TEXT, + last_regen INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS shop_stock ( + item_id TEXT PRIMARY KEY, + quantity INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_characters_user ON characters(user_id); + " + )?; + Ok(()) + } + + pub fn load_users(&self) -> Result, rusqlite::Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, name, role, password_hash FROM users")?; + let rows = stmt.query_map([], |row| { + let role_str: String = row.get(2)?; + let role = match role_str.as_str() { + "admin" => Role::Admin, + _ => Role::Player, + }; + Ok(User { + id: row.get::<_, u32>(0)?, + name: row.get(1)?, + role, + password_hash: row.get(3)?, + }) + })?; + let mut users = Vec::new(); + for row in rows { + users.push(row?); + } + Ok(users) + } + + pub fn save_user(&self, user: &User) -> Result<(), rusqlite::Error> { + let conn = self.conn.lock().unwrap(); + let role_str = match &user.role { + Role::Admin => "admin", + Role::Player => "player", + }; + conn.execute( + "INSERT OR REPLACE INTO users (id, name, role, password_hash) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![user.id, user.name, role_str, user.password_hash], + )?; + Ok(()) + } + + pub fn load_characters(&self) -> Result, rusqlite::Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, user_id, name, race, class, level, xp, gold, hp, max_hp, energy, max_energy, + nerve, max_nerve, spell_points, max_spell_points, + str_stat, int_stat, dex_stat, con_stat, luck_stat, + equipment, inventory, active_song, known_spells, active_spell, last_regen + FROM characters" + )?; + let rows = stmt.query_map([], |row| { + let equipment_json: String = row.get(21)?; + let inventory_json: String = row.get(22)?; + let known_spells_json: String = row.get(24)?; + + let equipment_raw: HashMap = serde_json::from_str(&equipment_json).unwrap_or_default(); + let equipment: HashMap> = equipment_raw.into_iter().map(|(k, v)| (k, Some(v))).collect(); + let inventory: Vec = serde_json::from_str(&inventory_json).unwrap_or_default(); + let known_spells: Vec = serde_json::from_str(&known_spells_json).unwrap_or_default(); + + let race_str: String = row.get(3)?; + let class_str: String = row.get(4)?; + let race = Race::from_str(&race_str); + let class = Class::from_str(&class_str); + + Ok(Character { + id: row.get::<_, u32>(0)?, + user_id: row.get::<_, u32>(1)?, + name: row.get(2)?, + race, + class, + stats: CharacterStats { + strength: row.get(16)?, + intelligence: row.get(17)?, + dexterity: row.get(18)?, + constitution: row.get(19)?, + luck: row.get(20)?, + }, + level: row.get::<_, i32>(5)?, + xp: row.get::<_, i64>(6)?, + gold: row.get::<_, i32>(7)?, + hp: row.get::<_, i32>(8)?, + max_hp: row.get::<_, i32>(9)?, + energy: row.get::<_, i32>(10)?, + max_energy: row.get::<_, i32>(11)?, + nerve: row.get::<_, i32>(12)?, + max_nerve: row.get::<_, i32>(13)?, + spell_points: row.get::<_, i32>(14)?, + max_spell_points: row.get::<_, i32>(15)?, + equipment, + inventory, + active_song: row.get(23)?, + known_spells, + active_spell: row.get(25)?, + last_regen: row.get(26)?, + }) + })?; + let mut characters = Vec::new(); + for row in rows { + characters.push(row?); + } + Ok(characters) + } + + pub fn save_character(&self, char: &Character) -> Result<(), rusqlite::Error> { + let conn = self.conn.lock().unwrap(); + let equipment_json = serde_json::to_string(&char.equipment).unwrap_or_default(); + let inventory_json = serde_json::to_string(&char.inventory).unwrap_or_default(); + let known_spells_json = serde_json::to_string(&char.known_spells).unwrap_or_default(); + + conn.execute( + "INSERT OR REPLACE INTO characters (id, user_id, name, race, class, level, xp, gold, + hp, max_hp, energy, max_energy, nerve, max_nerve, spell_points, max_spell_points, + str_stat, int_stat, dex_stat, con_stat, luck_stat, + equipment, inventory, active_song, known_spells, active_spell, last_regen) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, + ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27)", + rusqlite::params![ + char.id, char.user_id, char.name, + char.race.as_str(), char.class.as_str(), + char.level, char.xp, char.gold, + char.hp, char.max_hp, char.energy, char.max_energy, + char.nerve, char.max_nerve, char.spell_points, char.max_spell_points, + char.stats.strength, char.stats.intelligence, char.stats.dexterity, + char.stats.constitution, char.stats.luck, + equipment_json, inventory_json, + char.active_song, known_spells_json, char.active_spell, + char.last_regen, + ], + )?; + Ok(()) + } + + pub fn load_shop_stock(&self) -> Result, rusqlite::Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT item_id, quantity FROM shop_stock")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?)) + })?; + let mut stock = HashMap::new(); + for row in rows { + let (id, qty) = row?; + stock.insert(id, qty); + } + Ok(stock) + } + + pub fn save_shop_stock(&self, item_id: &str, quantity: u32) -> Result<(), rusqlite::Error> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO shop_stock (item_id, quantity) VALUES (?1, ?2)", + rusqlite::params![item_id, quantity], + )?; + Ok(()) + } + + pub fn next_character_id(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let id: u32 = conn.query_row( + "SELECT COALESCE(MAX(id), 0) FROM characters", + [], + |row| row.get(0), + )?; + Ok(id + 1) + } +} diff --git a/crates/sb-db/src/migrations/mod.rs b/crates/sb-db/src/migrations/mod.rs deleted file mode 100644 index e69de29..0000000 diff --git a/crates/sb-db/src/models/mod.rs b/crates/sb-db/src/models/mod.rs deleted file mode 100644 index e69de29..0000000 diff --git a/crates/sb-db/src/queries/mod.rs b/crates/sb-db/src/queries/mod.rs deleted file mode 100644 index e69de29..0000000 diff --git a/crates/sb-shared/src/character.rs b/crates/sb-shared/src/character.rs new file mode 100644 index 0000000..89440dd --- /dev/null +++ b/crates/sb-shared/src/character.rs @@ -0,0 +1,142 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CharacterStats { + pub strength: i64, + pub intelligence: i64, + pub dexterity: i64, + pub constitution: i64, + pub luck: i64, +} + +impl CharacterStats { + pub fn total(&self) -> i64 { + self.strength + self.intelligence + self.dexterity + self.constitution + self.luck + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Race { + Human, + Elf, + Dwarf, + Hobbit, + HalfElf, +} + +impl Race { + pub fn as_str(&self) -> &'static str { + match self { + Race::Human => "human", + Race::Elf => "elf", + Race::Dwarf => "dwarf", + Race::Hobbit => "hobbit", + Race::HalfElf => "half_elf", + } + } + + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> Self { + match s { + "human" => Race::Human, + "elf" => Race::Elf, + "dwarf" => Race::Dwarf, + "hobbit" => Race::Hobbit, + "half_elf" => Race::HalfElf, + _ => Race::Human, + } + } +} + +impl Class { + pub fn as_str(&self) -> &'static str { + match self { + Class::Warrior => "warrior", + Class::Paladin => "paladin", + Class::Rogue => "rogue", + Class::Bard => "bard", + Class::Hunter => "hunter", + Class::Monk => "monk", + Class::Mage => "mage", + Class::Wizard => "wizard", + Class::Conjurer => "conjurer", + Class::Sorcerer => "sorcerer", + } + } + + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> Self { + match s { + "warrior" => Class::Warrior, + "paladin" => Class::Paladin, + "rogue" => Class::Rogue, + "bard" => Class::Bard, + "hunter" => Class::Hunter, + "monk" => Class::Monk, + "mage" => Class::Mage, + "wizard" => Class::Wizard, + "conjurer" => Class::Conjurer, + "sorcerer" => Class::Sorcerer, + _ => Class::Warrior, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Class { + Warrior, + Paladin, + Rogue, + Bard, + Hunter, + Monk, + Mage, + Wizard, + Conjurer, + Sorcerer, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Character { + pub id: u32, + pub user_id: u32, + pub name: String, + pub race: Race, + pub class: Class, + pub level: i32, + pub xp: i64, + pub stats: CharacterStats, + pub hp: i32, + pub max_hp: i32, + pub energy: i32, + pub max_energy: i32, + pub nerve: i32, + pub max_nerve: i32, + pub gold: i32, + pub last_regen: i64, + pub equipment: HashMap>, + pub inventory: Vec, + #[serde(default)] + pub active_song: Option, + #[serde(default)] + pub known_spells: Vec, + #[serde(default)] + pub active_spell: Option, + #[serde(default = "default_sp")] + pub spell_points: i32, + #[serde(default = "default_max_sp")] + pub max_spell_points: i32, +} + +fn default_sp() -> i32 { 0 } +fn default_max_sp() -> i32 { 0 } + +#[derive(Debug, Clone, Deserialize)] +pub struct CreateCharacter { + pub name: String, + pub race: Race, + pub class: Class, +} diff --git a/crates/sb-shared/src/item.rs b/crates/sb-shared/src/item.rs new file mode 100644 index 0000000..466dde0 --- /dev/null +++ b/crates/sb-shared/src/item.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Item { + pub id: String, + pub name: String, + pub r#type: ItemType, + pub quality: Quality, + pub slot: String, + pub stats: HashMap, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub attack_type: Option, + pub value: i32, + #[serde(default)] + pub always_in_shop: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ItemType { + Weapon, + Armor, + Accessory, + Consumable, + Material, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AttackType { + Slash, + Crush, + Puncture, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum Quality { + Common, + Uncommon, + Rare, + Epic, + Legendary, +} diff --git a/crates/sb-shared/src/lib.rs b/crates/sb-shared/src/lib.rs index 700f149..f8253f9 100644 --- a/crates/sb-shared/src/lib.rs +++ b/crates/sb-shared/src/lib.rs @@ -1,3 +1,10 @@ +pub mod character; pub mod errors; +pub mod item; +pub mod responses; +pub mod user; -pub use errors::AppError; +pub use character::{Character, CharacterStats, Class, CreateCharacter, Race}; +pub use item::{AttackType, Item, ItemType, Quality}; +pub use responses::*; +pub use user::{Role, User}; diff --git a/crates/sb-shared/src/responses.rs b/crates/sb-shared/src/responses.rs new file mode 100644 index 0000000..58af405 --- /dev/null +++ b/crates/sb-shared/src/responses.rs @@ -0,0 +1,201 @@ +use serde::{Deserialize, Serialize}; +use crate::character::Character; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CritEntry { + pub name: String, + pub damage_multiplier: f64, + pub stun_rounds: u8, + pub bleed_per_round: i32, + pub bleed_rounds: u8, + pub effects: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CritResult { + pub severity: String, + pub crit: CritEntry, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CombatRound { + pub attacker: String, + pub defender: String, + pub raw_roll: i32, + pub ob: i32, + pub db: i32, + pub result: i32, + pub damage: i32, + pub crit: Option, + pub defender_hp: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CombatLog { + pub rounds: Vec, + pub winner: String, + pub loser: String, + pub xp_gained: i32, + pub gold_gained: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiInfoResponse { + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RacesResponse { + pub races: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RaceInfo { + pub race: String, + pub name: String, + pub str_mod: i64, + pub int_mod: i64, + pub dex_mod: i64, + pub con_mod: i64, + pub luck_mod: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassesResponse { + pub classes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassInfo { + pub class: String, + pub name: String, + pub primary_stat: String, + pub secondary_stat: String, + pub base_hp: i32, + pub hp_per_con: f64, + pub is_caster: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnemiesResponse { + pub enemies: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnemySummary { + pub id: String, + pub name: String, + pub level: i32, + pub hp: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdventuresResponse { + pub zones: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZoneSummary { + pub id: String, + pub name: String, + pub description: String, + pub min_level: i32, + pub nerve_cost: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GymResponse { + pub energy: i32, + pub max_energy: i32, + pub gold: i32, + pub stats: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrainingStatResponse { + pub stat: String, + pub name: String, + pub description: String, + pub value: i64, + pub cost: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrainStatResponse { + pub success: bool, + pub message: String, + pub character: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShopResponse { + pub gold: i32, + pub inventory: Vec, + pub shop_stock: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InventoryItemResponse { + pub id: String, + pub name: String, + pub item_type: String, + pub quality: String, + pub slot: Option, + pub stats: std::collections::HashMap, + pub description: String, + pub attack_type: Option, + pub value: i32, + pub equipped: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShopItemResponse { + pub id: String, + pub name: String, + pub item_type: String, + pub quality: String, + pub slot: Option, + pub stats: std::collections::HashMap, + pub description: String, + pub attack_type: Option, + pub value: i32, + pub stock: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShopActionResponse { + pub success: bool, + pub message: String, + pub gold: i32, + pub inventory: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SongsResponse { + pub active_song: Option, + pub available_songs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SongSummary { + pub id: String, + pub name: String, + pub description: String, + pub required_level: i32, + pub combat_effect: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetSongResponse { + pub success: bool, + pub message: String, + pub active_song: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LevelUpResponse { + pub success: bool, + pub message: String, + pub character: Option, +} diff --git a/crates/sb-shared/src/user.rs b/crates/sb-shared/src/user.rs new file mode 100644 index 0000000..ed0b95b --- /dev/null +++ b/crates/sb-shared/src/user.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: u32, + pub name: String, + pub role: Role, + #[serde(skip_serializing)] + pub password_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Role { + Admin, + Player, +}