From 5d5a594a7af5ad49e387cbc240bc14a717f81bbd Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Sat, 3 Oct 2026 12:06:05 +0200 Subject: [PATCH] sb-api: responses module + handler rewiring (WI-2, issue #2) --- crates/sb-api/src/routes/mod.rs | 321 ++++++++------------------ crates/sb-api/src/routes/responses.rs | 239 +++++++++++++++++++ 2 files changed, 339 insertions(+), 221 deletions(-) create mode 100644 crates/sb-api/src/routes/responses.rs diff --git a/crates/sb-api/src/routes/mod.rs b/crates/sb-api/src/routes/mod.rs index 1af0478..0021f0d 100644 --- a/crates/sb-api/src/routes/mod.rs +++ b/crates/sb-api/src/routes/mod.rs @@ -5,8 +5,6 @@ use axum::extract::{Path, Query, State}; use axum::routing::{get, post}; use axum::{Extension, Json, Router}; use sb_core::character::{self, CLASSES, RACES, TRAINING_GROUNDS}; -use sb_core::combat::parse_attack_type; -use sb_core::combat::{self, CombatStats}; use sb_core::data; use sb_core::items; use sb_core::services as domain; @@ -19,14 +17,40 @@ use sb_shared::{ ShopItemResponse, ShopResponse, SongSummary, SongsResponse, TrainStatResponse, TrainingStatResponse, User, ZoneSummary, }; +use std::collections::BTreeMap; use std::sync::Arc; use tokio::sync::RwLock; +mod responses; + fn request_rng() -> rand::rngs::StdRng { use rand::SeedableRng; rand::rngs::StdRng::from_os_rng() } +fn now_unix_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as i64) + .unwrap_or(0) +} + +fn domain_status(error: sb_core::error::DomainError) -> axum::http::StatusCode { + use sb_core::error::DomainError; + match error { + DomainError::CharacterIncapacitated + | DomainError::InvalidStat + | DomainError::ItemNotInInventory + | DomainError::NothingEquippedInSlot => axum::http::StatusCode::BAD_REQUEST, + DomainError::NotEnoughNerve + | DomainError::NotEnoughEnergy + | DomainError::NotEnoughGold + | DomainError::NotEnoughXp + | DomainError::AlreadyAtMaxLevel => axum::http::StatusCode::UNPROCESSABLE_ENTITY, + DomainError::UnknownEncounterEnemy => axum::http::StatusCode::INTERNAL_SERVER_ERROR, + } +} + pub(crate) struct AppState { users: RwLock>, items: Vec, @@ -259,10 +283,7 @@ async fn get_characters( 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 now = now_unix_ms(); let mut chars = state.characters.write().await; for c in chars.iter_mut() { sb_core::character::apply_regen(c, now); @@ -330,10 +351,7 @@ async fn get_character( Some(_) => {} } } - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; + let now = now_unix_ms(); let mut chars = state.characters.write().await; let char = chars .iter_mut() @@ -379,8 +397,7 @@ async fn equip_item( .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)?; + domain::equip_item(char, item, &input.item_id).map_err(domain_status)?; char.clone() }; @@ -418,7 +435,7 @@ async fn unequip_item( .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)?; + domain::unequip_item(char, &input.slot).map_err(domain_status)?; char.clone() }; @@ -432,7 +449,7 @@ async fn run_adventure( Extension(auth): Extension, Path(char_id): Path, Query(query): Query, -) -> Result, axum::http::StatusCode> { +) -> Result, axum::http::StatusCode> { let zone_id = query.zone_id.as_deref().unwrap_or("skara_brae_streets"); { @@ -453,158 +470,31 @@ async fn run_adventure( .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 now_ms = now_unix_ms(); let mut rng = request_rng(); - let (char, enemy_def) = { + let (outcome, updated) = { let mut chars = state.characters.write().await; - let char = chars + let character = 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 enemy = domain::pick_enemy(&mut rng, &zone.encounters, &state.enemies) - .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; - (char.clone(), enemy) + let outcome = sb_core::adventure::run_adventure( + &mut rng, + character, + zone, + &state.enemies, + &state.items, + now_ms, + ) + .map_err(domain_status)?; + (outcome, character.clone()) }; - 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); + persist_character(&state, &updated).await; - 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 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; - - 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, - }))) + Ok(Json(outcome.into())) } async fn get_gym( @@ -684,10 +574,7 @@ async fn train_stat( Some(_) => {} } } - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; + let now = now_unix_ms(); let (updated, _value) = { let mut chars = state.characters.write().await; @@ -698,13 +585,7 @@ async fn train_stat( sb_core::character::apply_regen(char, now); - let new_val = domain::train_stat(char, &input.stat).map_err(|e| match e { - sb_core::error::DomainError::NotEnoughEnergy - | sb_core::error::DomainError::NotEnoughGold => { - axum::http::StatusCode::UNPROCESSABLE_ENTITY - } - _ => axum::http::StatusCode::BAD_REQUEST, - })?; + let new_val = domain::train_stat(char, &input.stat).map_err(domain_status)?; (char.clone(), new_val) }; @@ -952,7 +833,7 @@ async fn heal_character( Extension(auth): Extension, Path(char_id): Path, Json(input): Json, -) -> Result, axum::http::StatusCode> { +) -> Result, axum::http::StatusCode> { { let chars = state.characters.read().await; let char = chars.iter().find(|c| c.id == char_id); @@ -995,12 +876,12 @@ async fn heal_character( }; persist_character(&state, &updated).await; - Ok(Json(serde_json::json!({ - "hp": updated.hp, - "max_hp": updated.max_hp, - "gold": updated.gold, - "healed": true, - }))) + Ok(Json(responses::HealResponse { + gold: updated.gold, + healed: true, + hp: updated.hp, + max_hp: updated.max_hp, + })) } async fn get_songs( @@ -1114,7 +995,7 @@ async fn get_spells( State(state): State>, Extension(auth): Extension, Path(char_id): Path, -) -> Result, axum::http::StatusCode> { +) -> Result, axum::http::StatusCode> { { let chars = state.characters.read().await; let char = chars.iter().find(|c| c.id == char_id); @@ -1138,43 +1019,37 @@ async fn get_spells( if !spells::is_caster_class(&class_str) { return Err(axum::http::StatusCode::FORBIDDEN); } - let spell_level = (char.level / 3) + 1; + let spell_level = spells::caster_spell_level(char.level); let available = spells::spells_for_class(&class_str, spell_level); - let spells_json: Vec = available + 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()), - }) + .map(|s| responses::SpellEntryResponse { + code: s.code.clone(), + combat_damage_max: s.combat_damage_max, + combat_damage_min: s.combat_damage_min, + combat_effect: s.combat_effect.clone(), + description: s.description.clone(), + heal_max: s.heal_max, + heal_min: s.heal_min, + id: s.id.clone(), + known: char.known_spells.contains(&s.id.to_string()), + name: s.name.clone(), + range: s.range.clone(), + spell_level: s.spell_level, + spell_points: s.spell_points, }) .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), - }, - }))) + let learn_costs = (2..=7) + .map(|level| (level.to_string(), spells::spell_learn_cost(level))) + .collect::>(); + Ok(Json(responses::SpellbookResponse { + caster_class: class_str, + learn_costs, + max_spell_points: char.max_spell_points, + spell_level, + spell_points: char.spell_points, + spells: spells_json, + })) } #[derive(serde::Deserialize)] @@ -1207,7 +1082,7 @@ async fn level_up( .ok_or(axum::http::StatusCode::NOT_FOUND)?; match character::level_up(char) { Ok(_) => char.clone(), - Err(_) => return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY), + Err(e) => return Err(domain_status(e)), } }; persist_character(&state, &updated).await; @@ -1223,7 +1098,7 @@ async fn learn_spell_level( Extension(auth): Extension, Path(char_id): Path, Json(input): Json, -) -> Result, axum::http::StatusCode> { +) -> Result, axum::http::StatusCode> { { let chars = state.characters.read().await; let char = chars.iter().find(|c| c.id == char_id); @@ -1258,10 +1133,12 @@ async fn learn_spell_level( char.clone() }; persist_character(&state, &updated).await; - return Ok(Json(serde_json::json!({ - "active_spell": updated.active_spell, - "spell_points": updated.spell_points, - }))); + return Ok(Json(responses::LearnSpellResponse::Activate( + responses::ActivateSpellResponse { + active_spell: updated.active_spell, + spell_points: updated.spell_points, + }, + ))); } let spell_level = input @@ -1283,7 +1160,7 @@ async fn learn_spell_level( return Err(axum::http::StatusCode::BAD_REQUEST); } - let current_level = (char.level / 3) + 1; + let current_level = spells::caster_spell_level(char.level); if spell_level > current_level { return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); } @@ -1317,12 +1194,14 @@ async fn learn_spell_level( }; 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, - }))) + Ok(Json(responses::LearnSpellResponse::Learn( + responses::LearnSpellsResponse { + gold: updated.gold, + learned_count: updated.known_spells.len(), + max_spell_points: updated.max_spell_points, + spell_points: updated.spell_points, + }, + ))) } #[derive(serde::Deserialize)] diff --git a/crates/sb-api/src/routes/responses.rs b/crates/sb-api/src/routes/responses.rs new file mode 100644 index 0000000..98ce1c5 --- /dev/null +++ b/crates/sb-api/src/routes/responses.rs @@ -0,0 +1,239 @@ +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Debug, Serialize)] +pub struct AdventureRunResponse { + pub combat_log: serde_json::Value, + pub enemy: String, + pub loot: Vec, + pub zone: String, +} + +#[derive(Debug, Serialize)] +pub struct HealResponse { + pub gold: i32, + pub healed: bool, + pub hp: i32, + pub max_hp: i32, +} + +#[derive(Debug, Serialize)] +pub struct SpellEntryResponse { + pub code: String, + pub combat_damage_max: i32, + pub combat_damage_min: i32, + pub combat_effect: String, + pub description: String, + pub heal_max: i32, + pub heal_min: i32, + pub id: String, + pub known: bool, + pub name: String, + pub range: String, + pub spell_level: i32, + pub spell_points: i32, +} + +#[derive(Debug, Serialize)] +pub struct SpellbookResponse { + pub caster_class: String, + pub learn_costs: BTreeMap, + pub max_spell_points: i32, + pub spell_level: i32, + pub spell_points: i32, + pub spells: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ActivateSpellResponse { + pub active_spell: Option, + pub spell_points: i32, +} + +#[derive(Debug, Serialize)] +pub struct LearnSpellsResponse { + pub gold: i32, + pub learned_count: usize, + pub max_spell_points: i32, + pub spell_points: i32, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum LearnSpellResponse { + Activate(ActivateSpellResponse), + Learn(LearnSpellsResponse), +} + +impl From for AdventureRunResponse { + fn from(outcome: sb_core::adventure::AdventureOutcome) -> Self { + AdventureRunResponse { + // to_value mirrors exactly what json! does to &log today: struct → + // Value::Object with BTreeMap ordering — nested keys sorted, byte-identical wire. + combat_log: serde_json::to_value(&outcome.combat_log) + .expect("CombatLog graph contains no map types; to_value is infallible"), + enemy: outcome.enemy_name, + loot: outcome.loot_item_names, + zone: outcome.zone_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sb_shared::{CombatLog, CombatRound, CritEntry, CritResult}; + + fn representative_log() -> CombatLog { + CombatLog { + rounds: vec![ + CombatRound { + attacker: "Test".into(), + defender: "Giant Rat".into(), + raw_roll: 67, + ob: 15, + db: 0, + result: 82, + damage: 12, + crit: None, + defender_hp: 0, + }, + CombatRound { + attacker: "Giant Rat".into(), + defender: "Test".into(), + raw_roll: 99, + ob: 3, + db: 5, + result: 97, + damage: 18, + crit: Some(CritResult { + severity: "A".into(), + crit: CritEntry { + name: "critical".into(), + damage_multiplier: 2.0, + stun_rounds: 1, + bleed_per_round: 2, + bleed_rounds: 3, + effects: vec!["stun".into()], + }, + }), + defender_hp: 62, + }, + ], + winner: "Test".into(), + loser: "Giant Rat".into(), + xp_gained: 5, + gold_gained: 2, + } + } + + fn representative_outcome() -> sb_core::adventure::AdventureOutcome { + sb_core::adventure::AdventureOutcome { + zone_id: "skara_brae_streets".into(), + enemy_name: "Giant Rat".into(), + combat_log: representative_log(), + loot_item_names: vec!["Iron Sword".into()], + } + } + + const GOLDEN_W1: &str = r#"{"combat_log":{"gold_gained":2,"loser":"Giant Rat","rounds":[{"attacker":"Test","crit":null,"damage":12,"db":0,"defender":"Giant Rat","defender_hp":0,"ob":15,"raw_roll":67,"result":82},{"attacker":"Giant Rat","crit":{"crit":{"bleed_per_round":2,"bleed_rounds":3,"damage_multiplier":2.0,"effects":["stun"],"name":"critical","stun_rounds":1},"severity":"A"},"damage":18,"db":5,"defender":"Test","defender_hp":62,"ob":3,"raw_roll":99,"result":97}],"winner":"Test","xp_gained":5},"enemy":"Giant Rat","loot":["Iron Sword"],"zone":"skara_brae_streets"}"#; + + const GOLDEN_W2: &str = r#"{"gold":123,"healed":true,"hp":45,"max_hp":80}"#; + + const GOLDEN_W3: &str = r#"{"code":"fire_bolt","combat_damage_max":6,"combat_damage_min":2,"combat_effect":"damage","description":"Hurls a mote of fire at the target.","heal_max":0,"heal_min":0,"id":"spell_fire_bolt","known":true,"name":"Fire Bolt","range":"short","spell_level":1,"spell_points":10}"#; + + const GOLDEN_W4: &str = r#"{"caster_class":"mage","learn_costs":{"2":1000,"3":2000,"4":4000,"5":7000,"6":10000,"7":20000},"max_spell_points":40,"spell_level":4,"spell_points":12,"spells":[{"code":"fire_bolt","combat_damage_max":6,"combat_damage_min":2,"combat_effect":"damage","description":"Hurls a mote of fire at the target.","heal_max":0,"heal_min":0,"id":"spell_fire_bolt","known":true,"name":"Fire Bolt","range":"short","spell_level":1,"spell_points":10}]}"#; + + const GOLDEN_W5: &str = r#"{"active_spell":"mage_armor","spell_points":12}"#; + const GOLDEN_W5_NULL: &str = r#"{"active_spell":null,"spell_points":12}"#; + + const GOLDEN_W6: &str = + r#"{"gold":19700,"learned_count":4,"max_spell_points":40,"spell_points":40}"#; + + #[test] + fn wire_identity_adventure_response() { + let dto: AdventureRunResponse = representative_outcome().into(); + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W1); + } + + #[test] + fn wire_identity_heal_response() { + let dto = HealResponse { + gold: 123, + healed: true, + hp: 45, + max_hp: 80, + }; + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W2); + } + + fn representative_spell_entry() -> SpellEntryResponse { + SpellEntryResponse { + code: "fire_bolt".into(), + combat_damage_max: 6, + combat_damage_min: 2, + combat_effect: "damage".into(), + description: "Hurls a mote of fire at the target.".into(), + heal_max: 0, + heal_min: 0, + id: "spell_fire_bolt".into(), + known: true, + name: "Fire Bolt".into(), + range: "short".into(), + spell_level: 1, + spell_points: 10, + } + } + + #[test] + fn wire_identity_spell_entry_response() { + let dto = representative_spell_entry(); + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W3); + } + + #[test] + fn wire_identity_spellbook_response() { + let dto = SpellbookResponse { + caster_class: "mage".into(), + learn_costs: BTreeMap::from([ + ("2".into(), 1000), + ("3".into(), 2000), + ("4".into(), 4000), + ("5".into(), 7000), + ("6".into(), 10000), + ("7".into(), 20000), + ]), + max_spell_points: 40, + spell_level: 4, + spell_points: 12, + spells: vec![representative_spell_entry()], + }; + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W4); + } + + #[test] + fn wire_identity_activate_spell_response() { + let dto = ActivateSpellResponse { + active_spell: Some("mage_armor".into()), + spell_points: 12, + }; + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W5); + + let dto = ActivateSpellResponse { + active_spell: None, + spell_points: 12, + }; + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W5_NULL); + } + + #[test] + fn wire_identity_learn_response() { + let dto = LearnSpellResponse::Learn(LearnSpellsResponse { + gold: 19700, + learned_count: 4, + max_spell_points: 40, + spell_points: 40, + }); + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W6); + } +}