sb-api: responses module + handler rewiring (WI-2, issue #2)

This commit is contained in:
Jan-Peter von Hunnius 2026-10-03 12:06:05 +02:00
parent 6d9f02b77f
commit 5d5a594a7a
2 changed files with 339 additions and 221 deletions

View file

@ -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<Vec<User>>,
items: Vec<Item>,
@ -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<AuthUser>,
Path(char_id): Path<u32>,
Query(query): Query<AdventureRequest>,
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
) -> Result<Json<responses::AdventureRunResponse>, 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 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(),
let outcome = sb_core::adventure::run_adventure(
&mut rng,
);
let (loot_names, updated_char) = {
let mut chars = state.characters.write().await;
let dropped_ids: Vec<String> = 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()
character,
zone,
&state.enemies,
&state.items,
now_ms,
)
.map_err(domain_status)?;
(outcome, character.clone())
};
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);
}
persist_character(&state, &updated).await;
let updated = chars.iter().find(|c| c.id == char_id).cloned();
drop(chars);
let loot: Vec<String> = 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<AuthUser>,
Path(char_id): Path<u32>,
Json(input): Json<HealRequest>,
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
) -> Result<Json<responses::HealResponse>, 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<Arc<AppState>>,
Extension(auth): Extension<AuthUser>,
Path(char_id): Path<u32>,
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
) -> Result<Json<responses::SpellbookResponse>, 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<serde_json::Value> = available
let spells_json: Vec<responses::SpellEntryResponse> = 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::<BTreeMap<String, i32>>();
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<AuthUser>,
Path(char_id): Path<u32>,
Json(input): Json<SpellRequest>,
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
) -> Result<Json<responses::LearnSpellResponse>, 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)]

View file

@ -0,0 +1,239 @@
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Debug, Serialize)]
pub struct AdventureRunResponse {
pub combat_log: serde_json::Value,
pub enemy: String,
pub loot: Vec<String>,
pub zone: String,
}
#[derive(Debug, Serialize)]
pub struct HealResponse {
pub gold: i32,
pub healed: bool,
pub hp: i32,
pub max_hp: i32,
}
#[derive(Debug, Serialize)]
pub struct SpellEntryResponse {
pub code: String,
pub combat_damage_max: i32,
pub combat_damage_min: i32,
pub combat_effect: String,
pub description: String,
pub heal_max: i32,
pub heal_min: i32,
pub id: String,
pub known: bool,
pub name: String,
pub range: String,
pub spell_level: i32,
pub spell_points: i32,
}
#[derive(Debug, Serialize)]
pub struct SpellbookResponse {
pub caster_class: String,
pub learn_costs: BTreeMap<String, i32>,
pub max_spell_points: i32,
pub spell_level: i32,
pub spell_points: i32,
pub spells: Vec<SpellEntryResponse>,
}
#[derive(Debug, Serialize)]
pub struct ActivateSpellResponse {
pub active_spell: Option<String>,
pub spell_points: i32,
}
#[derive(Debug, Serialize)]
pub struct LearnSpellsResponse {
pub gold: i32,
pub learned_count: usize,
pub max_spell_points: i32,
pub spell_points: i32,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum LearnSpellResponse {
Activate(ActivateSpellResponse),
Learn(LearnSpellsResponse),
}
impl From<sb_core::adventure::AdventureOutcome> for AdventureRunResponse {
fn from(outcome: sb_core::adventure::AdventureOutcome) -> Self {
AdventureRunResponse {
// to_value mirrors exactly what json! does to &log today: struct →
// Value::Object with BTreeMap ordering — nested keys sorted, byte-identical wire.
combat_log: serde_json::to_value(&outcome.combat_log)
.expect("CombatLog graph contains no map types; to_value is infallible"),
enemy: outcome.enemy_name,
loot: outcome.loot_item_names,
zone: outcome.zone_id,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use sb_shared::{CombatLog, CombatRound, CritEntry, CritResult};
fn representative_log() -> CombatLog {
CombatLog {
rounds: vec![
CombatRound {
attacker: "Test".into(),
defender: "Giant Rat".into(),
raw_roll: 67,
ob: 15,
db: 0,
result: 82,
damage: 12,
crit: None,
defender_hp: 0,
},
CombatRound {
attacker: "Giant Rat".into(),
defender: "Test".into(),
raw_roll: 99,
ob: 3,
db: 5,
result: 97,
damage: 18,
crit: Some(CritResult {
severity: "A".into(),
crit: CritEntry {
name: "critical".into(),
damage_multiplier: 2.0,
stun_rounds: 1,
bleed_per_round: 2,
bleed_rounds: 3,
effects: vec!["stun".into()],
},
}),
defender_hp: 62,
},
],
winner: "Test".into(),
loser: "Giant Rat".into(),
xp_gained: 5,
gold_gained: 2,
}
}
fn representative_outcome() -> sb_core::adventure::AdventureOutcome {
sb_core::adventure::AdventureOutcome {
zone_id: "skara_brae_streets".into(),
enemy_name: "Giant Rat".into(),
combat_log: representative_log(),
loot_item_names: vec!["Iron Sword".into()],
}
}
const GOLDEN_W1: &str = r#"{"combat_log":{"gold_gained":2,"loser":"Giant Rat","rounds":[{"attacker":"Test","crit":null,"damage":12,"db":0,"defender":"Giant Rat","defender_hp":0,"ob":15,"raw_roll":67,"result":82},{"attacker":"Giant Rat","crit":{"crit":{"bleed_per_round":2,"bleed_rounds":3,"damage_multiplier":2.0,"effects":["stun"],"name":"critical","stun_rounds":1},"severity":"A"},"damage":18,"db":5,"defender":"Test","defender_hp":62,"ob":3,"raw_roll":99,"result":97}],"winner":"Test","xp_gained":5},"enemy":"Giant Rat","loot":["Iron Sword"],"zone":"skara_brae_streets"}"#;
const GOLDEN_W2: &str = r#"{"gold":123,"healed":true,"hp":45,"max_hp":80}"#;
const GOLDEN_W3: &str = r#"{"code":"fire_bolt","combat_damage_max":6,"combat_damage_min":2,"combat_effect":"damage","description":"Hurls a mote of fire at the target.","heal_max":0,"heal_min":0,"id":"spell_fire_bolt","known":true,"name":"Fire Bolt","range":"short","spell_level":1,"spell_points":10}"#;
const GOLDEN_W4: &str = r#"{"caster_class":"mage","learn_costs":{"2":1000,"3":2000,"4":4000,"5":7000,"6":10000,"7":20000},"max_spell_points":40,"spell_level":4,"spell_points":12,"spells":[{"code":"fire_bolt","combat_damage_max":6,"combat_damage_min":2,"combat_effect":"damage","description":"Hurls a mote of fire at the target.","heal_max":0,"heal_min":0,"id":"spell_fire_bolt","known":true,"name":"Fire Bolt","range":"short","spell_level":1,"spell_points":10}]}"#;
const GOLDEN_W5: &str = r#"{"active_spell":"mage_armor","spell_points":12}"#;
const GOLDEN_W5_NULL: &str = r#"{"active_spell":null,"spell_points":12}"#;
const GOLDEN_W6: &str =
r#"{"gold":19700,"learned_count":4,"max_spell_points":40,"spell_points":40}"#;
#[test]
fn wire_identity_adventure_response() {
let dto: AdventureRunResponse = representative_outcome().into();
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W1);
}
#[test]
fn wire_identity_heal_response() {
let dto = HealResponse {
gold: 123,
healed: true,
hp: 45,
max_hp: 80,
};
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W2);
}
fn representative_spell_entry() -> SpellEntryResponse {
SpellEntryResponse {
code: "fire_bolt".into(),
combat_damage_max: 6,
combat_damage_min: 2,
combat_effect: "damage".into(),
description: "Hurls a mote of fire at the target.".into(),
heal_max: 0,
heal_min: 0,
id: "spell_fire_bolt".into(),
known: true,
name: "Fire Bolt".into(),
range: "short".into(),
spell_level: 1,
spell_points: 10,
}
}
#[test]
fn wire_identity_spell_entry_response() {
let dto = representative_spell_entry();
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W3);
}
#[test]
fn wire_identity_spellbook_response() {
let dto = SpellbookResponse {
caster_class: "mage".into(),
learn_costs: BTreeMap::from([
("2".into(), 1000),
("3".into(), 2000),
("4".into(), 4000),
("5".into(), 7000),
("6".into(), 10000),
("7".into(), 20000),
]),
max_spell_points: 40,
spell_level: 4,
spell_points: 12,
spells: vec![representative_spell_entry()],
};
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W4);
}
#[test]
fn wire_identity_activate_spell_response() {
let dto = ActivateSpellResponse {
active_spell: Some("mage_armor".into()),
spell_points: 12,
};
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W5);
let dto = ActivateSpellResponse {
active_spell: None,
spell_points: 12,
};
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W5_NULL);
}
#[test]
fn wire_identity_learn_response() {
let dto = LearnSpellResponse::Learn(LearnSpellsResponse {
gold: 19700,
learned_count: 4,
max_spell_points: 40,
spell_points: 40,
});
assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W6);
}
}