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);
}

View file

@ -392,6 +392,27 @@ pub fn find_class_info(class: &Class) -> &'static ClassInfo {
.expect("invalid class")
}
/// Gym stat display order — pinned to the get_gym handler's fixed array
/// (routes/mod.rs:588–594), deliberately differs from TRAINING_GROUNDS order.
pub const GYM_STAT_DISPLAY_ORDER: &[&str] = &[
"strength",
"intelligence",
"dexterity",
"constitution",
"luck",
];
pub fn stat_value(stats: &CharacterStats, stat: &str) -> Option<i64> {
match stat {
"strength" => Some(stats.strength),
"intelligence" => Some(stats.intelligence),
"dexterity" => Some(stats.dexterity),
"constitution" => Some(stats.constitution),
"luck" => Some(stats.luck),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -631,4 +652,70 @@ mod tests {
assert_eq!(first.energy, second.energy);
assert_eq!(first.nerve, second.nerve);
}
#[test]
fn gym_display_order_covers_all_training_grounds() {
let stats = CharacterStats {
strength: 10,
intelligence: 11,
dexterity: 12,
constitution: 13,
luck: 14,
};
assert_eq!(GYM_STAT_DISPLAY_ORDER.len(), TRAINING_GROUNDS.len());
for name in GYM_STAT_DISPLAY_ORDER {
assert!(
TRAINING_GROUNDS.iter().any(|g| g.stat == *name),
"no training ground for stat {}",
name
);
assert!(
stat_value(&stats, name).is_some(),
"stat_value cannot resolve {}",
name
);
}
for ground in TRAINING_GROUNDS {
assert!(
GYM_STAT_DISPLAY_ORDER.contains(&ground.stat),
"gym display order missing stat {}",
ground.stat
);
}
}
#[test]
fn race_table_covers_all_variants() {
let all = [
Race::Human,
Race::Elf,
Race::Dwarf,
Race::Hobbit,
Race::HalfElf,
];
for race in &all {
assert!(RACES.iter().any(|r| &r.race == race));
let _ = find_race_info(race);
}
}
#[test]
fn class_table_covers_all_variants() {
let all = [
Class::Warrior,
Class::Paladin,
Class::Rogue,
Class::Bard,
Class::Hunter,
Class::Monk,
Class::Mage,
Class::Wizard,
Class::Conjurer,
Class::Sorcerer,
];
for class in &all {
assert!(CLASSES.iter().any(|c| &c.class == class));
let _ = find_class_info(class);
}
}
}

View file

@ -424,4 +424,9 @@ mod tests {
assert!(matches!(parse_attack_type("unarmed"), AttackType::Crush));
assert!(matches!(parse_attack_type("unknown"), AttackType::Crush));
}
#[test]
fn crit_tables_parse() {
assert!(!CRIT_TABLES.is_empty());
}
}

View file

@ -59,3 +59,18 @@ pub fn load_enemies() -> Vec<EnemyDef> {
pub fn load_zones() -> Vec<ZoneDef> {
serde_json::from_str(ZONES_JSON).expect("failed to parse zones.json")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enemies_json_parses() {
assert!(!load_enemies().is_empty());
}
#[test]
fn zones_json_parses() {
assert!(!load_zones().is_empty());
}
}

View file

@ -29,3 +29,13 @@ pub fn item_price(item: &Item) -> i32 {
pub fn item_sell_price(item: &Item) -> i32 {
item.value / 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_items_json_parses() {
assert!(!load_all_items().is_empty());
}
}

View file

@ -110,4 +110,9 @@ mod tests {
);
}
}
#[test]
fn spells_json_parses() {
assert!(!load_spells().is_empty());
}
}

View file

@ -27,6 +27,18 @@ pub enum ItemType {
Material,
}
impl ItemType {
pub fn as_str(&self) -> &'static str {
match self {
ItemType::Weapon => "weapon",
ItemType::Armor => "armor",
ItemType::Accessory => "accessory",
ItemType::Consumable => "consumable",
ItemType::Material => "material",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AttackType {
@ -44,3 +56,52 @@ pub enum Quality {
Epic,
Legendary,
}
impl Quality {
pub fn as_str(&self) -> &'static str {
match self {
Quality::Common => "common",
Quality::Uncommon => "uncommon",
Quality::Rare => "rare",
Quality::Epic => "epic",
Quality::Legendary => "legendary",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn item_type_as_str_matches_serde_wire() {
for variant in [
ItemType::Weapon,
ItemType::Armor,
ItemType::Accessory,
ItemType::Consumable,
ItemType::Material,
] {
assert_eq!(
serde_json::to_value(&variant).unwrap(),
serde_json::json!(variant.as_str())
);
}
}
#[test]
fn quality_as_str_matches_serde_wire() {
for variant in [
Quality::Common,
Quality::Uncommon,
Quality::Rare,
Quality::Epic,
Quality::Legendary,
] {
assert_eq!(
serde_json::to_value(&variant).unwrap(),
serde_json::json!(variant.as_str())
);
}
}
}