sb-api: label helpers wiring + gym/stat_value + policy constants (WI-2, issue #2)
This commit is contained in:
parent
5d5a594a7a
commit
f0659421e7
7 changed files with 217 additions and 47 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,4 +110,9 @@ mod tests {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spells_json_parses() {
|
||||
assert!(!load_spells().is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue