sb-api: label helpers wiring + gym/stat_value + policy constants (WI-2, issue #2)

This commit is contained in:
Jan-Peter von Hunnius 2026-10-03 12:08:21 +02:00
parent 5d5a594a7a
commit f0659421e7
7 changed files with 217 additions and 47 deletions

View file

@ -4,7 +4,7 @@ use crate::middleware::auth::AuthUser;
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::character::{self, CLASSES, GYM_STAT_DISPLAY_ORDER, RACES, TRAINING_GROUNDS};
use sb_core::data;
use sb_core::items;
use sb_core::services as domain;
@ -23,6 +23,16 @@ use tokio::sync::RwLock;
mod responses;
const DEFAULT_ZONE_ID: &str = "skara_brae_streets";
const CHARACTER_NAME_MAX_LEN: usize = 32;
const REGISTER_NAME_MIN_LEN: usize = 3;
const REGISTER_NAME_MAX_LEN: usize = 32;
const PASSWORD_MIN_LEN: usize = 6;
const PARTIAL_HEAL_FRACTION: f64 = 0.5;
const MIN_HEAL_COST: i32 = 1;
const SPELL_LEARN_COST_DISPLAY_MIN: i32 = 2;
const SPELL_LEARN_COST_DISPLAY_MAX: i32 = 7;
fn request_rng() -> rand::rngs::StdRng {
use rand::SeedableRng;
rand::rngs::StdRng::from_os_rng()
@ -314,7 +324,7 @@ async fn create_character(
}
let name = input.name.trim().to_string();
if name.is_empty() || name.len() > 32 {
if name.is_empty() || name.len() > CHARACTER_NAME_MAX_LEN {
return Err(axum::http::StatusCode::BAD_REQUEST);
}
@ -450,7 +460,7 @@ async fn run_adventure(
Path(char_id): Path<u32>,
Query(query): Query<AdventureRequest>,
) -> 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(DEFAULT_ZONE_ID);
{
let chars = state.characters.read().await;
@ -521,27 +531,18 @@ async fn get_gym(
.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<TrainingStatResponse> = stats
let stat_responses: Vec<TrainingStatResponse> = GYM_STAT_DISPLAY_ORDER
.iter()
.map(|(name, val)| {
let ground = TRAINING_GROUNDS
.iter()
.find(|g| g.stat == *name)
.expect("valid stat");
TrainingStatResponse {
stat: name.to_string(),
.filter_map(|stat_name| {
let ground = TRAINING_GROUNDS.iter().find(|g| g.stat == *stat_name)?;
let value = character::stat_value(&char.stats, stat_name)?;
Some(TrainingStatResponse {
stat: stat_name.to_string(),
name: ground.name.to_string(),
description: ground.description.to_string(),
value: *val,
cost: character::training_cost(*val),
}
value,
cost: character::training_cost(value),
})
})
.collect();
Ok(Json(GymResponse {
@ -630,16 +631,8 @@ async fn get_shop(
.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(),
item_type: item.r#type.as_str().to_string(),
quality: item.quality.as_str().to_string(),
slot: Some(item.slot.clone()),
stats: item.stats.clone(),
description: item.description.clone(),
@ -659,16 +652,8 @@ async fn get_shop(
.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(),
item_type: item.r#type.as_str().to_string(),
quality: item.quality.as_str().to_string(),
slot: Some(item.slot.clone()),
stats: item.stats.clone(),
description: item.description.clone(),
@ -859,11 +844,11 @@ async fn heal_character(
let heal_amount = match input.amount.as_deref() {
Some("full") => missing,
Some("partial") => (missing as f64 / 2.0).ceil() as i32,
Some("partial") => (missing as f64 * PARTIAL_HEAL_FRACTION).ceil() as i32,
_ => missing,
};
let actual = heal_amount.min(missing);
let cost = actual.max(1);
let cost = actual.max(MIN_HEAL_COST);
if char.gold < cost {
return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
@ -1039,7 +1024,7 @@ async fn get_spells(
spell_points: s.spell_points,
})
.collect();
let learn_costs = (2..=7)
let learn_costs = (SPELL_LEARN_COST_DISPLAY_MIN..=SPELL_LEARN_COST_DISPLAY_MAX)
.map(|level| (level.to_string(), spells::spell_learn_cost(level)))
.collect::<BTreeMap<String, i32>>();
Ok(Json(responses::SpellbookResponse {
@ -1156,7 +1141,9 @@ async fn learn_spell_level(
return Err(axum::http::StatusCode::FORBIDDEN);
}
if !(1..=7).contains(&spell_level) {
if !(sb_core::constants::MIN_SPELL_LEVEL..=sb_core::constants::MAX_SPELL_LEVEL)
.contains(&spell_level)
{
return Err(axum::http::StatusCode::BAD_REQUEST);
}
@ -1228,10 +1215,10 @@ async fn register(
State(state): State<Arc<AppState>>,
Json(input): Json<RegisterRequest>,
) -> Result<Json<AuthResponse>, axum::http::StatusCode> {
if input.name.len() < 3 || input.name.len() > 32 {
if input.name.len() < REGISTER_NAME_MIN_LEN || input.name.len() > REGISTER_NAME_MAX_LEN {
return Err(axum::http::StatusCode::BAD_REQUEST);
}
if input.password.len() < 6 {
if input.password.len() < PASSWORD_MIN_LEN {
return Err(axum::http::StatusCode::BAD_REQUEST);
}