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