fmt: repo-wide baseline (WI-2, issue #2)
This commit is contained in:
parent
899fde8fc3
commit
337e9bcf2c
19 changed files with 847 additions and 335 deletions
|
|
@ -3,9 +3,9 @@
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
|
||||||
|
pub mod middleware;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
pub mod middleware;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::Request,
|
extract::Request,
|
||||||
http::{header, StatusCode},
|
http::{StatusCode, header},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
|
|
@ -13,19 +13,14 @@ pub struct AuthUser {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn auth_middleware(
|
pub async fn auth_middleware(mut request: Request, next: Next) -> Result<Response, StatusCode> {
|
||||||
mut request: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Result<Response, StatusCode> {
|
|
||||||
let auth_header = request
|
let auth_header = request
|
||||||
.headers()
|
.headers()
|
||||||
.get(header::AUTHORIZATION)
|
.get(header::AUTHORIZATION)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
let token = auth_header
|
let token = auth_header.strip_prefix("Bearer ").unwrap_or(auth_header);
|
||||||
.strip_prefix("Bearer ")
|
|
||||||
.unwrap_or(auth_header);
|
|
||||||
|
|
||||||
if token.is_empty() {
|
if token.is_empty() {
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -1,26 +1,23 @@
|
||||||
#![allow(private_interfaces)]
|
#![allow(private_interfaces)]
|
||||||
|
|
||||||
|
use crate::middleware::auth::AuthUser;
|
||||||
use axum::extract::{Path, Query, State};
|
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 crate::middleware::auth::AuthUser;
|
|
||||||
use sb_core::character::{self, CLASSES, RACES, TRAINING_GROUNDS};
|
use sb_core::character::{self, CLASSES, RACES, TRAINING_GROUNDS};
|
||||||
use sb_core::combat::{self, CombatStats};
|
|
||||||
use sb_core::combat::parse_attack_type;
|
use sb_core::combat::parse_attack_type;
|
||||||
|
use sb_core::combat::{self, CombatStats};
|
||||||
use sb_core::data;
|
use sb_core::data;
|
||||||
use sb_core::services as domain;
|
|
||||||
use sb_core::items;
|
use sb_core::items;
|
||||||
|
use sb_core::services as domain;
|
||||||
use sb_core::songs;
|
use sb_core::songs;
|
||||||
use sb_core::spells;
|
use sb_core::spells;
|
||||||
use sb_shared::{
|
use sb_shared::{
|
||||||
Character, Class, CreateCharacter, Item, Role, User,
|
AdventuresResponse, ApiInfoResponse, Character, Class, ClassInfo, ClassesResponse,
|
||||||
ApiInfoResponse, RacesResponse, RaceInfo,
|
CreateCharacter, EnemiesResponse, EnemySummary, GymResponse, InventoryItemResponse, Item,
|
||||||
ClassesResponse, ClassInfo, EnemiesResponse, EnemySummary,
|
LevelUpResponse, RaceInfo, RacesResponse, Role, SetSongResponse, ShopActionResponse,
|
||||||
AdventuresResponse, ZoneSummary,
|
ShopItemResponse, ShopResponse, SongSummary, SongsResponse, TrainStatResponse,
|
||||||
GymResponse, TrainStatResponse, TrainingStatResponse, ShopResponse,
|
TrainingStatResponse, User, ZoneSummary,
|
||||||
InventoryItemResponse, ShopItemResponse, ShopActionResponse,
|
|
||||||
SongsResponse, SongSummary, SetSongResponse,
|
|
||||||
LevelUpResponse,
|
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
@ -93,7 +90,10 @@ pub fn build(state: Arc<AppState>) -> Router {
|
||||||
.route("/api/classes", get(get_classes))
|
.route("/api/classes", get(get_classes))
|
||||||
.route("/api/enemies", get(get_enemies))
|
.route("/api/enemies", get(get_enemies))
|
||||||
.route("/api/adventures", get(get_adventures))
|
.route("/api/adventures", get(get_adventures))
|
||||||
.route("/api/users/{user_id}/characters", get(get_characters).post(create_character))
|
.route(
|
||||||
|
"/api/users/{user_id}/characters",
|
||||||
|
get(get_characters).post(create_character),
|
||||||
|
)
|
||||||
.route("/api/characters/{id}", get(get_character))
|
.route("/api/characters/{id}", get(get_character))
|
||||||
.route("/api/characters/{id}/adventure", get(run_adventure))
|
.route("/api/characters/{id}/adventure", get(run_adventure))
|
||||||
.route("/api/characters/{id}/equip", post(equip_item))
|
.route("/api/characters/{id}/equip", post(equip_item))
|
||||||
|
|
@ -102,7 +102,10 @@ pub fn build(state: Arc<AppState>) -> Router {
|
||||||
.route("/api/characters/{id}/shop", get(get_shop).post(shop_action))
|
.route("/api/characters/{id}/shop", get(get_shop).post(shop_action))
|
||||||
.route("/api/characters/{id}/heal", post(heal_character))
|
.route("/api/characters/{id}/heal", post(heal_character))
|
||||||
.route("/api/characters/{id}/songs", get(get_songs).post(set_song))
|
.route("/api/characters/{id}/songs", get(get_songs).post(set_song))
|
||||||
.route("/api/characters/{id}/spells", get(get_spells).post(learn_spell_level))
|
.route(
|
||||||
|
"/api/characters/{id}/spells",
|
||||||
|
get(get_spells).post(learn_spell_level),
|
||||||
|
)
|
||||||
.route("/api/characters/{id}/level-up", post(level_up))
|
.route("/api/characters/{id}/level-up", post(level_up))
|
||||||
.route_layer(axum::middleware::from_fn(
|
.route_layer(axum::middleware::from_fn(
|
||||||
crate::middleware::auth::auth_middleware,
|
crate::middleware::auth::auth_middleware,
|
||||||
|
|
@ -264,7 +267,11 @@ async fn get_characters(
|
||||||
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);
|
||||||
}
|
}
|
||||||
let user_chars: Vec<Character> = chars.iter().filter(|c| c.user_id == user_id).cloned().collect();
|
let user_chars: Vec<Character> = chars
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.user_id == user_id)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
Ok(Json(user_chars))
|
Ok(Json(user_chars))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,8 +324,10 @@ async fn get_character(
|
||||||
let char = chars.iter().find(|c| c.id == id);
|
let char = chars.iter().find(|c| c.id == id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
|
|
@ -326,7 +335,9 @@ async fn get_character(
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_millis() as i64;
|
.as_millis() as i64;
|
||||||
let mut chars = state.characters.write().await;
|
let mut chars = state.characters.write().await;
|
||||||
let char = chars.iter_mut().find(|c| c.id == id)
|
let char = chars
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.id == id)
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
sb_core::character::apply_regen(char, now);
|
sb_core::character::apply_regen(char, now);
|
||||||
Ok(Json(char.clone()))
|
Ok(Json(char.clone()))
|
||||||
|
|
@ -343,7 +354,10 @@ async fn equip_item(
|
||||||
Path(char_id): Path<u32>,
|
Path(char_id): Path<u32>,
|
||||||
Json(input): Json<EquipRequest>,
|
Json(input): Json<EquipRequest>,
|
||||||
) -> Result<Json<Character>, axum::http::StatusCode> {
|
) -> Result<Json<Character>, axum::http::StatusCode> {
|
||||||
let item = state.items.iter().find(|i| i.id == input.item_id)
|
let item = state
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|i| i.id == input.item_id)
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
@ -351,14 +365,18 @@ async fn equip_item(
|
||||||
let char = chars.iter().find(|c| c.id == char_id);
|
let char = chars.iter().find(|c| c.id == char_id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = {
|
let updated = {
|
||||||
let mut chars = state.characters.write().await;
|
let mut chars = state.characters.write().await;
|
||||||
let char = chars.iter_mut().find(|c| c.id == char_id)
|
let char = chars
|
||||||
|
.iter_mut()
|
||||||
|
.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)
|
||||||
|
|
@ -387,17 +405,20 @@ async fn unequip_item(
|
||||||
let char = chars.iter().find(|c| c.id == char_id);
|
let char = chars.iter().find(|c| c.id == char_id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let updated = {
|
let updated = {
|
||||||
let mut chars = state.characters.write().await;
|
let mut chars = state.characters.write().await;
|
||||||
let char = chars.iter_mut().find(|c| c.id == char_id)
|
let char = chars
|
||||||
|
.iter_mut()
|
||||||
|
.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)
|
domain::unequip_item(char, &input.slot).map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
|
||||||
|
|
||||||
char.clone()
|
char.clone()
|
||||||
};
|
};
|
||||||
|
|
@ -419,12 +440,17 @@ async fn run_adventure(
|
||||||
let char = chars.iter().find(|c| c.id == char_id);
|
let char = chars.iter().find(|c| c.id == char_id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let zone = state.zones.iter().find(|z| z.id == zone_id)
|
let zone = state
|
||||||
|
.zones
|
||||||
|
.iter()
|
||||||
|
.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 = std::time::SystemTime::now()
|
||||||
|
|
@ -436,7 +462,9 @@ async fn run_adventure(
|
||||||
|
|
||||||
let (char, enemy_def) = {
|
let (char, enemy_def) = {
|
||||||
let mut chars = state.characters.write().await;
|
let mut chars = state.characters.write().await;
|
||||||
let char = chars.iter_mut().find(|c| c.id == char_id)
|
let char = chars
|
||||||
|
.iter_mut()
|
||||||
|
.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);
|
sb_core::character::apply_regen(char, now);
|
||||||
|
|
@ -500,7 +528,10 @@ async fn run_adventure(
|
||||||
enemy_def.gold_min,
|
enemy_def.gold_min,
|
||||||
enemy_def.gold_max,
|
enemy_def.gold_max,
|
||||||
char.active_song.as_deref().and_then(|sid| {
|
char.active_song.as_deref().and_then(|sid| {
|
||||||
songs::BARD_SONGS.iter().find(|s| s.id == sid).map(|s| s.combat_effect)
|
songs::BARD_SONGS
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == sid)
|
||||||
|
.map(|s| s.combat_effect)
|
||||||
}),
|
}),
|
||||||
spell_effect.as_ref(),
|
spell_effect.as_ref(),
|
||||||
&mut rng,
|
&mut rng,
|
||||||
|
|
@ -514,7 +545,9 @@ async fn run_adventure(
|
||||||
if log.winner == c.name {
|
if log.winner == c.name {
|
||||||
c.xp += log.xp_gained as i64;
|
c.xp += log.xp_gained as i64;
|
||||||
c.gold += log.gold_gained;
|
c.gold += log.gold_gained;
|
||||||
let char_hp = log.rounds.iter()
|
let char_hp = log
|
||||||
|
.rounds
|
||||||
|
.iter()
|
||||||
.rfind(|r| r.defender == c.name)
|
.rfind(|r| r.defender == c.name)
|
||||||
.map(|r| r.defender_hp);
|
.map(|r| r.defender_hp);
|
||||||
if let Some(hp) = char_hp {
|
if let Some(hp) = char_hp {
|
||||||
|
|
@ -548,9 +581,16 @@ async fn run_adventure(
|
||||||
|
|
||||||
let updated = chars.iter().find(|c| c.id == char_id).cloned();
|
let updated = chars.iter().find(|c| c.id == char_id).cloned();
|
||||||
drop(chars);
|
drop(chars);
|
||||||
let loot: Vec<String> = dropped_ids.iter().filter_map(|id| {
|
let loot: Vec<String> = dropped_ids
|
||||||
state.items.iter().find(|i| &i.id == id).map(|i| i.name.clone())
|
.iter()
|
||||||
}).collect();
|
.filter_map(|id| {
|
||||||
|
state
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|i| &i.id == id)
|
||||||
|
.map(|i| i.name.clone())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
(loot, updated)
|
(loot, updated)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -576,14 +616,19 @@ async fn get_gym(
|
||||||
let char = chars.iter().find(|c| c.id == char_id);
|
let char = chars.iter().find(|c| c.id == char_id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let char = {
|
let char = {
|
||||||
let chars = state.characters.read().await;
|
let chars = state.characters.read().await;
|
||||||
chars.iter().find(|c| c.id == char_id)
|
chars
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?.clone()
|
.iter()
|
||||||
|
.find(|c| c.id == char_id)
|
||||||
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
let stats = [
|
let stats = [
|
||||||
("strength", char.stats.strength),
|
("strength", char.stats.strength),
|
||||||
|
|
@ -592,8 +637,13 @@ async fn get_gym(
|
||||||
("constitution", char.stats.constitution),
|
("constitution", char.stats.constitution),
|
||||||
("luck", char.stats.luck),
|
("luck", char.stats.luck),
|
||||||
];
|
];
|
||||||
let stat_responses: Vec<TrainingStatResponse> = stats.iter().map(|(name, val)| {
|
let stat_responses: Vec<TrainingStatResponse> = stats
|
||||||
let ground = TRAINING_GROUNDS.iter().find(|g| g.stat == *name).expect("valid stat");
|
.iter()
|
||||||
|
.map(|(name, val)| {
|
||||||
|
let ground = TRAINING_GROUNDS
|
||||||
|
.iter()
|
||||||
|
.find(|g| g.stat == *name)
|
||||||
|
.expect("valid stat");
|
||||||
TrainingStatResponse {
|
TrainingStatResponse {
|
||||||
stat: name.to_string(),
|
stat: name.to_string(),
|
||||||
name: ground.name.to_string(),
|
name: ground.name.to_string(),
|
||||||
|
|
@ -601,7 +651,8 @@ async fn get_gym(
|
||||||
value: *val,
|
value: *val,
|
||||||
cost: character::training_cost(*val),
|
cost: character::training_cost(*val),
|
||||||
}
|
}
|
||||||
}).collect();
|
})
|
||||||
|
.collect();
|
||||||
Ok(Json(GymResponse {
|
Ok(Json(GymResponse {
|
||||||
energy: char.energy,
|
energy: char.energy,
|
||||||
max_energy: char.max_energy,
|
max_energy: char.max_energy,
|
||||||
|
|
@ -626,8 +677,10 @@ async fn train_stat(
|
||||||
let char = chars.iter().find(|c| c.id == char_id);
|
let char = chars.iter().find(|c| c.id == char_id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
|
|
@ -635,14 +688,16 @@ async fn train_stat(
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_millis() as i64;
|
.as_millis() as i64;
|
||||||
|
|
||||||
let (updated, _value) = { let mut chars = state.characters.write().await;
|
let (updated, _value) = {
|
||||||
let char = chars.iter_mut().find(|c| c.id == char_id)
|
let mut chars = state.characters.write().await;
|
||||||
|
let char = chars
|
||||||
|
.iter_mut()
|
||||||
|
.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);
|
sb_core::character::apply_regen(char, now);
|
||||||
|
|
||||||
let new_val = domain::train_stat(char, &input.stat)
|
let new_val = domain::train_stat(char, &input.stat).map_err(|e| match e {
|
||||||
.map_err(|e| match e {
|
|
||||||
"not enough energy" => axum::http::StatusCode::UNPROCESSABLE_ENTITY,
|
"not enough energy" => axum::http::StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
"not enough gold" => axum::http::StatusCode::UNPROCESSABLE_ENTITY,
|
"not enough gold" => axum::http::StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
_ => axum::http::StatusCode::BAD_REQUEST,
|
_ => axum::http::StatusCode::BAD_REQUEST,
|
||||||
|
|
@ -669,48 +724,76 @@ async fn get_shop(
|
||||||
let char = chars.iter().find(|c| c.id == char_id);
|
let char = chars.iter().find(|c| c.id == char_id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let char = {
|
let char = {
|
||||||
let chars = state.characters.read().await;
|
let chars = state.characters.read().await;
|
||||||
chars.iter().find(|c| c.id == char_id)
|
chars
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?.clone()
|
.iter()
|
||||||
|
.find(|c| c.id == char_id)
|
||||||
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
let shop_stock = state.shop_stock.read().await;
|
let shop_stock = state.shop_stock.read().await;
|
||||||
let shop_items: Vec<ShopItemResponse> = state.items.iter().filter(|item| {
|
let shop_items: Vec<ShopItemResponse> = state
|
||||||
item.always_in_shop || shop_stock.contains_key(&item.id)
|
.items
|
||||||
}).map(|item| {
|
.iter()
|
||||||
ShopItemResponse {
|
.filter(|item| item.always_in_shop || shop_stock.contains_key(&item.id))
|
||||||
|
.map(|item| ShopItemResponse {
|
||||||
id: item.id.clone(),
|
id: item.id.clone(),
|
||||||
name: item.name.clone(),
|
name: item.name.clone(),
|
||||||
item_type: serde_json::to_value(&item.r#type).unwrap().as_str().unwrap().to_string(),
|
item_type: serde_json::to_value(&item.r#type)
|
||||||
quality: serde_json::to_value(&item.quality).unwrap().as_str().unwrap().to_string(),
|
.unwrap()
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string(),
|
||||||
|
quality: serde_json::to_value(&item.quality)
|
||||||
|
.unwrap()
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string(),
|
||||||
slot: Some(item.slot.clone()),
|
slot: Some(item.slot.clone()),
|
||||||
stats: item.stats.clone(),
|
stats: item.stats.clone(),
|
||||||
description: item.description.clone(),
|
description: item.description.clone(),
|
||||||
attack_type: item.attack_type.as_ref().map(|at| format!("{:?}", at)),
|
attack_type: item.attack_type.as_ref().map(|at| format!("{:?}", at)),
|
||||||
value: items::item_price(item),
|
value: items::item_price(item),
|
||||||
stock: shop_stock.get(&item.id).copied().unwrap_or(0),
|
stock: shop_stock.get(&item.id).copied().unwrap_or(0),
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
let bag_items: Vec<InventoryItemResponse> = char.inventory.iter().filter_map(|id| {
|
let bag_items: Vec<InventoryItemResponse> = char
|
||||||
state.items.iter().find(|i| &i.id == id).map(|item| {
|
.inventory
|
||||||
InventoryItemResponse {
|
.iter()
|
||||||
|
.filter_map(|id| {
|
||||||
|
state
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|i| &i.id == id)
|
||||||
|
.map(|item| InventoryItemResponse {
|
||||||
id: item.id.clone(),
|
id: item.id.clone(),
|
||||||
name: item.name.clone(),
|
name: item.name.clone(),
|
||||||
item_type: serde_json::to_value(&item.r#type).unwrap().as_str().unwrap().to_string(),
|
item_type: serde_json::to_value(&item.r#type)
|
||||||
quality: serde_json::to_value(&item.quality).unwrap().as_str().unwrap().to_string(),
|
.unwrap()
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string(),
|
||||||
|
quality: serde_json::to_value(&item.quality)
|
||||||
|
.unwrap()
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string(),
|
||||||
slot: Some(item.slot.clone()),
|
slot: Some(item.slot.clone()),
|
||||||
stats: item.stats.clone(),
|
stats: item.stats.clone(),
|
||||||
description: item.description.clone(),
|
description: item.description.clone(),
|
||||||
attack_type: item.attack_type.as_ref().map(|t| format!("{:?}", t)),
|
attack_type: item.attack_type.as_ref().map(|t| format!("{:?}", t)),
|
||||||
value: item.value,
|
value: item.value,
|
||||||
equipped: false,
|
equipped: false,
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}).collect();
|
})
|
||||||
|
.collect();
|
||||||
Ok(Json(ShopResponse {
|
Ok(Json(ShopResponse {
|
||||||
gold: char.gold,
|
gold: char.gold,
|
||||||
inventory: bag_items,
|
inventory: bag_items,
|
||||||
|
|
@ -735,8 +818,10 @@ async fn shop_action(
|
||||||
let char = chars.iter().find(|c| c.id == char_id);
|
let char = chars.iter().find(|c| c.id == char_id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match input.action.as_str() {
|
match input.action.as_str() {
|
||||||
|
|
@ -751,14 +836,19 @@ async fn buy_item(
|
||||||
Path(char_id): Path<u32>,
|
Path(char_id): Path<u32>,
|
||||||
input: ShopAction,
|
input: ShopAction,
|
||||||
) -> Result<Json<ShopActionResponse>, axum::http::StatusCode> {
|
) -> Result<Json<ShopActionResponse>, axum::http::StatusCode> {
|
||||||
let item = state.items.iter().find(|i| i.id == input.item_id)
|
let item = state
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|i| i.id == input.item_id)
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
let price = items::item_price(item);
|
let price = items::item_price(item);
|
||||||
|
|
||||||
{
|
{
|
||||||
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)
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
if char.gold < price {
|
if char.gold < price {
|
||||||
return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
@ -779,7 +869,9 @@ async fn buy_item(
|
||||||
|
|
||||||
let updated = {
|
let updated = {
|
||||||
let mut chars = state.characters.write().await;
|
let mut chars = state.characters.write().await;
|
||||||
let char = chars.iter_mut().find(|c| c.id == char_id)
|
let char = chars
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.id == char_id)
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
char.gold -= price;
|
char.gold -= price;
|
||||||
char.inventory.push(input.item_id.clone());
|
char.inventory.push(input.item_id.clone());
|
||||||
|
|
@ -803,17 +895,25 @@ async fn sell_item(
|
||||||
Path(char_id): Path<u32>,
|
Path(char_id): Path<u32>,
|
||||||
input: ShopAction,
|
input: ShopAction,
|
||||||
) -> Result<Json<ShopActionResponse>, axum::http::StatusCode> {
|
) -> Result<Json<ShopActionResponse>, axum::http::StatusCode> {
|
||||||
let item = state.items.iter().find(|i| i.id == input.item_id)
|
let item = state
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|i| i.id == input.item_id)
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
let sell_price = items::item_sell_price(item);
|
let sell_price = items::item_sell_price(item);
|
||||||
|
|
||||||
let updated = {
|
let updated = {
|
||||||
let mut chars = state.characters.write().await;
|
let mut chars = state.characters.write().await;
|
||||||
let char = chars.iter_mut().find(|c| c.id == char_id)
|
let char = chars
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.id == char_id)
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
let pos = char.inventory.iter().position(|id| id == &input.item_id)
|
let pos = char
|
||||||
|
.inventory
|
||||||
|
.iter()
|
||||||
|
.position(|id| id == &input.item_id)
|
||||||
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
|
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
char.inventory.remove(pos);
|
char.inventory.remove(pos);
|
||||||
|
|
@ -855,13 +955,17 @@ async fn heal_character(
|
||||||
let char = chars.iter().find(|c| c.id == char_id);
|
let char = chars.iter().find(|c| c.id == char_id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let updated = {
|
let updated = {
|
||||||
let mut chars = state.characters.write().await;
|
let mut chars = state.characters.write().await;
|
||||||
let char = chars.iter_mut().find(|c| c.id == char_id)
|
let char = chars
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.id == char_id)
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
let missing = char.max_hp - char.hp;
|
let missing = char.max_hp - char.hp;
|
||||||
|
|
@ -906,28 +1010,34 @@ async fn get_songs(
|
||||||
let char = chars.iter().find(|c| c.id == char_id);
|
let char = chars.iter().find(|c| c.id == char_id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let char = {
|
let char = {
|
||||||
let chars = state.characters.read().await;
|
let chars = state.characters.read().await;
|
||||||
chars.iter().find(|c| c.id == char_id)
|
chars
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?.clone()
|
.iter()
|
||||||
|
.find(|c| c.id == char_id)
|
||||||
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
if char.class != Class::Bard {
|
if char.class != Class::Bard {
|
||||||
return Err(axum::http::StatusCode::FORBIDDEN);
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
let available = songs::available_songs(char.level);
|
let available = songs::available_songs(char.level);
|
||||||
let songs_json: Vec<SongSummary> = available.iter().map(|s| {
|
let songs_json: Vec<SongSummary> = available
|
||||||
SongSummary {
|
.iter()
|
||||||
|
.map(|s| SongSummary {
|
||||||
id: s.id.to_string(),
|
id: s.id.to_string(),
|
||||||
name: s.name.to_string(),
|
name: s.name.to_string(),
|
||||||
description: s.description.to_string(),
|
description: s.description.to_string(),
|
||||||
required_level: s.required_level,
|
required_level: s.required_level,
|
||||||
combat_effect: s.combat_effect.to_string(),
|
combat_effect: s.combat_effect.to_string(),
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
Ok(Json(SongsResponse {
|
Ok(Json(SongsResponse {
|
||||||
active_song: char.active_song.clone(),
|
active_song: char.active_song.clone(),
|
||||||
available_songs: songs_json,
|
available_songs: songs_json,
|
||||||
|
|
@ -950,13 +1060,17 @@ async fn set_song(
|
||||||
let char = chars.iter().find(|c| c.id == char_id);
|
let char = chars.iter().find(|c| c.id == char_id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let updated = {
|
let updated = {
|
||||||
let mut chars = state.characters.write().await;
|
let mut chars = state.characters.write().await;
|
||||||
let char = chars.iter_mut().find(|c| c.id == char_id)
|
let char = chars
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.id == char_id)
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
if char.class != Class::Bard {
|
if char.class != Class::Bard {
|
||||||
|
|
@ -964,9 +1078,13 @@ async fn set_song(
|
||||||
}
|
}
|
||||||
|
|
||||||
match &input.song_id {
|
match &input.song_id {
|
||||||
None => { char.active_song = None; }
|
None => {
|
||||||
|
char.active_song = None;
|
||||||
|
}
|
||||||
Some(song_id) => {
|
Some(song_id) => {
|
||||||
let song = songs::BARD_SONGS.iter().find(|s| s.id == *song_id)
|
let song = songs::BARD_SONGS
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == *song_id)
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
if song.required_level > char.level {
|
if song.required_level > char.level {
|
||||||
return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
@ -981,7 +1099,10 @@ async fn set_song(
|
||||||
|
|
||||||
Ok(Json(SetSongResponse {
|
Ok(Json(SetSongResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: format!("Song set to {}", updated.active_song.as_deref().unwrap_or("none")),
|
message: format!(
|
||||||
|
"Song set to {}",
|
||||||
|
updated.active_song.as_deref().unwrap_or("none")
|
||||||
|
),
|
||||||
active_song: updated.active_song.clone(),
|
active_song: updated.active_song.clone(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -996,14 +1117,19 @@ async fn get_spells(
|
||||||
let char = chars.iter().find(|c| c.id == char_id);
|
let char = chars.iter().find(|c| c.id == char_id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let char = {
|
let char = {
|
||||||
let chars = state.characters.read().await;
|
let chars = state.characters.read().await;
|
||||||
chars.iter().find(|c| c.id == char_id)
|
chars
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?.clone()
|
.iter()
|
||||||
|
.find(|c| c.id == char_id)
|
||||||
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
let class_str = char.class.as_str().to_string();
|
let class_str = char.class.as_str().to_string();
|
||||||
if !spells::is_caster_class(&class_str) {
|
if !spells::is_caster_class(&class_str) {
|
||||||
|
|
@ -1011,7 +1137,9 @@ async fn get_spells(
|
||||||
}
|
}
|
||||||
let spell_level = (char.level / 3) + 1;
|
let spell_level = (char.level / 3) + 1;
|
||||||
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.iter().map(|s| {
|
let spells_json: Vec<serde_json::Value> = available
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"id": &s.id,
|
"id": &s.id,
|
||||||
"code": &s.code,
|
"code": &s.code,
|
||||||
|
|
@ -1027,7 +1155,8 @@ async fn get_spells(
|
||||||
"heal_max": s.heal_max,
|
"heal_max": s.heal_max,
|
||||||
"known": char.known_spells.contains(&s.id.to_string()),
|
"known": char.known_spells.contains(&s.id.to_string()),
|
||||||
})
|
})
|
||||||
}).collect();
|
})
|
||||||
|
.collect();
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"caster_class": class_str,
|
"caster_class": class_str,
|
||||||
"spell_level": spell_level,
|
"spell_level": spell_level,
|
||||||
|
|
@ -1051,7 +1180,6 @@ struct SpellRequest {
|
||||||
spell_id: Option<String>,
|
spell_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async fn level_up(
|
async fn level_up(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Extension(auth): Extension<AuthUser>,
|
Extension(auth): Extension<AuthUser>,
|
||||||
|
|
@ -1062,13 +1190,17 @@ async fn level_up(
|
||||||
let char = chars.iter().find(|c| c.id == char_id);
|
let char = chars.iter().find(|c| c.id == char_id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let updated = {
|
let updated = {
|
||||||
let mut chars = state.characters.write().await;
|
let mut chars = state.characters.write().await;
|
||||||
let char = chars.iter_mut().find(|c| c.id == char_id)
|
let char = chars
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.id == char_id)
|
||||||
.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(),
|
||||||
|
|
@ -1100,14 +1232,18 @@ async fn learn_spell_level(
|
||||||
let char = chars.iter().find(|c| c.id == char_id);
|
let char = chars.iter().find(|c| c.id == char_id);
|
||||||
match char {
|
match char {
|
||||||
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
None => return Err(axum::http::StatusCode::NOT_FOUND),
|
||||||
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
|
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
|
||||||
Some(_) => {},
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(spell_id) = input.spell_id {
|
if let Some(spell_id) = input.spell_id {
|
||||||
let updated = {
|
let updated = {
|
||||||
let mut chars = state.characters.write().await;
|
let mut chars = state.characters.write().await;
|
||||||
let char = chars.iter_mut().find(|c| c.id == char_id)
|
let char = chars
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.id == char_id)
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
if spell_id.is_empty() {
|
if spell_id.is_empty() {
|
||||||
char.active_spell = None;
|
char.active_spell = None;
|
||||||
|
|
@ -1131,11 +1267,14 @@ async fn learn_spell_level(
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
let spell_level = input.spell_level
|
let spell_level = input
|
||||||
|
.spell_level
|
||||||
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
|
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
let updated = {
|
let updated = {
|
||||||
let mut chars = state.characters.write().await;
|
let mut chars = state.characters.write().await;
|
||||||
let char = chars.iter_mut().find(|c| c.id == char_id)
|
let char = chars
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.id == char_id)
|
||||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
let class_str = char.class.as_str().to_string();
|
let class_str = char.class.as_str().to_string();
|
||||||
|
|
@ -1158,7 +1297,8 @@ async fn learn_spell_level(
|
||||||
}
|
}
|
||||||
|
|
||||||
let spell_list = spells::spells_for_class(&class_str, spell_level);
|
let spell_list = spells::spells_for_class(&class_str, spell_level);
|
||||||
let new_ids: Vec<String> = spell_list.iter()
|
let new_ids: Vec<String> = spell_list
|
||||||
|
.iter()
|
||||||
.filter(|s| !char.known_spells.contains(&s.id.to_string()))
|
.filter(|s| !char.known_spells.contains(&s.id.to_string()))
|
||||||
.map(|s| s.id.to_string())
|
.map(|s| s.id.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -1256,7 +1396,9 @@ async fn login(
|
||||||
Json(input): Json<LoginRequest>,
|
Json(input): Json<LoginRequest>,
|
||||||
) -> Result<Json<AuthResponse>, axum::http::StatusCode> {
|
) -> Result<Json<AuthResponse>, axum::http::StatusCode> {
|
||||||
let users = state.users.read().await;
|
let users = state.users.read().await;
|
||||||
let user = users.iter().find(|u| u.name == input.name)
|
let user = users
|
||||||
|
.iter()
|
||||||
|
.find(|u| u.name == input.name)
|
||||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
if !sb_auth::hash::verify_password(&input.password, &user.password_hash) {
|
if !sb_auth::hash::verify_password(&input.password, &user.password_hash) {
|
||||||
|
|
@ -1282,28 +1424,30 @@ async fn login(
|
||||||
pub fn seed_users() -> Arc<AppState> {
|
pub fn seed_users() -> Arc<AppState> {
|
||||||
let db_path = std::env::var("SB_DB_PATH");
|
let db_path = std::env::var("SB_DB_PATH");
|
||||||
let db = match db_path.as_deref() {
|
let db = match db_path.as_deref() {
|
||||||
Ok(path) if !path.is_empty() => {
|
Ok(path) if !path.is_empty() => match sb_db::Database::open(std::path::Path::new(path)) {
|
||||||
match sb_db::Database::open(std::path::Path::new(path)) {
|
|
||||||
Ok(db) => {
|
Ok(db) => {
|
||||||
tracing::info!("database loaded from {}", path);
|
tracing::info!("database loaded from {}", path);
|
||||||
Some(db)
|
Some(db)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("failed to open database at {}: {}, falling back to in-memory", path, e);
|
tracing::warn!(
|
||||||
|
"failed to open database at {}: {}, falling back to in-memory",
|
||||||
|
path,
|
||||||
|
e
|
||||||
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let admin_pass = std::env::var("SB_ADMIN_PASS").unwrap_or_else(|_| "admin".into());
|
let admin_pass = std::env::var("SB_ADMIN_PASS").unwrap_or_else(|_| "admin".into());
|
||||||
let player_pass = std::env::var("SB_PLAYER_PASS").unwrap_or_else(|_| "player".into());
|
let player_pass = std::env::var("SB_PLAYER_PASS").unwrap_or_else(|_| "player".into());
|
||||||
|
|
||||||
let admin_hash = sb_auth::hash::hash_password(&admin_pass)
|
let admin_hash =
|
||||||
.expect("failed to hash admin password");
|
sb_auth::hash::hash_password(&admin_pass).expect("failed to hash admin password");
|
||||||
let player_hash = sb_auth::hash::hash_password(&player_pass)
|
let player_hash =
|
||||||
.expect("failed to hash player password");
|
sb_auth::hash::hash_password(&player_pass).expect("failed to hash player password");
|
||||||
|
|
||||||
let (users, characters, shop_stock, next_id) = if let Some(ref db) = db {
|
let (users, characters, shop_stock, next_id) = if let Some(ref db) = db {
|
||||||
let users = db.load_users().unwrap_or_else(|e| {
|
let users = db.load_users().unwrap_or_else(|e| {
|
||||||
|
|
@ -1325,8 +1469,18 @@ pub fn seed_users() -> Arc<AppState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut initial_users = vec![
|
let mut initial_users = vec![
|
||||||
User { id: 1, name: "admin".into(), role: Role::Admin, password_hash: admin_hash },
|
User {
|
||||||
User { id: 2, name: "player".into(), role: Role::Player, password_hash: player_hash },
|
id: 1,
|
||||||
|
name: "admin".into(),
|
||||||
|
role: Role::Admin,
|
||||||
|
password_hash: admin_hash,
|
||||||
|
},
|
||||||
|
User {
|
||||||
|
id: 2,
|
||||||
|
name: "player".into(),
|
||||||
|
role: Role::Player,
|
||||||
|
password_hash: player_hash,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if let Some(ref db) = db {
|
if let Some(ref db) = db {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
pub mod handler;
|
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
|
pub mod handler;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
|
||||||
Argon2,
|
Argon2,
|
||||||
|
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
|
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
|
||||||
|
|
@ -17,5 +17,7 @@ pub fn verify_password(password: &str, hash: &str) -> bool {
|
||||||
Ok(h) => h,
|
Ok(h) => h,
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
};
|
};
|
||||||
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed)
|
||||||
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use chrono::{Utc, Duration};
|
|
||||||
|
|
||||||
fn jwt_secret() -> Vec<u8> {
|
fn jwt_secret() -> Vec<u8> {
|
||||||
std::env::var("JWT_SECRET")
|
std::env::var("JWT_SECRET")
|
||||||
|
|
@ -16,7 +16,11 @@ pub struct Claims {
|
||||||
pub exp: usize,
|
pub exp: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_token(user_id: u32, name: &str, role: &str) -> Result<String, jsonwebtoken::errors::Error> {
|
pub fn create_token(
|
||||||
|
user_id: u32,
|
||||||
|
name: &str,
|
||||||
|
role: &str,
|
||||||
|
) -> Result<String, jsonwebtoken::errors::Error> {
|
||||||
let exp = Utc::now()
|
let exp = Utc::now()
|
||||||
.checked_add_signed(Duration::hours(24))
|
.checked_add_signed(Duration::hours(24))
|
||||||
.expect("valid timestamp")
|
.expect("valid timestamp")
|
||||||
|
|
@ -39,9 +43,6 @@ pub fn create_token(user_id: u32, name: &str, role: &str) -> Result<String, json
|
||||||
pub fn verify_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
pub fn verify_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||||
let mut validation = Validation::default();
|
let mut validation = Validation::default();
|
||||||
validation.leeway = 60;
|
validation.leeway = 60;
|
||||||
jsonwebtoken::decode::<Claims>(
|
jsonwebtoken::decode::<Claims>(token, &DecodingKey::from_secret(&jwt_secret()), &validation)
|
||||||
token,
|
.map(|d| d.claims)
|
||||||
&DecodingKey::from_secret(&jwt_secret()),
|
|
||||||
&validation,
|
|
||||||
).map(|d| d.claims)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use sb_shared::{Character, CharacterStats, Class, Race};
|
|
||||||
use crate::spells;
|
use crate::spells;
|
||||||
|
use sb_shared::{Character, CharacterStats, Class, Race};
|
||||||
|
|
||||||
pub struct TrainingGround {
|
pub struct TrainingGround {
|
||||||
pub stat: &'static str,
|
pub stat: &'static str,
|
||||||
|
|
@ -8,11 +8,31 @@ pub struct TrainingGround {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const TRAINING_GROUNDS: &[TrainingGround] = &[
|
pub const TRAINING_GROUNDS: &[TrainingGround] = &[
|
||||||
TrainingGround { stat: "strength", name: "The Anvil", description: "Pound steel and lift stone under the guidance of master smiths." },
|
TrainingGround {
|
||||||
TrainingGround { stat: "dexterity", name: "The Archery Range", description: "Sharpen reflexes with bow, blade, and balance drills." },
|
stat: "strength",
|
||||||
TrainingGround { stat: "constitution", name: "The Endurance Pits", description: "Push your body past its limits in grueling trials of resilience." },
|
name: "The Anvil",
|
||||||
TrainingGround { stat: "intelligence", name: "The Sage's Study", description: "Study ancient tomes and practice arcane disciplines." },
|
description: "Pound steel and lift stone under the guidance of master smiths.",
|
||||||
TrainingGround { stat: "luck", name: "Fortune's Shrine", description: "Consult oracles and tempt fate in games of chance." },
|
},
|
||||||
|
TrainingGround {
|
||||||
|
stat: "dexterity",
|
||||||
|
name: "The Archery Range",
|
||||||
|
description: "Sharpen reflexes with bow, blade, and balance drills.",
|
||||||
|
},
|
||||||
|
TrainingGround {
|
||||||
|
stat: "constitution",
|
||||||
|
name: "The Endurance Pits",
|
||||||
|
description: "Push your body past its limits in grueling trials of resilience.",
|
||||||
|
},
|
||||||
|
TrainingGround {
|
||||||
|
stat: "intelligence",
|
||||||
|
name: "The Sage's Study",
|
||||||
|
description: "Study ancient tomes and practice arcane disciplines.",
|
||||||
|
},
|
||||||
|
TrainingGround {
|
||||||
|
stat: "luck",
|
||||||
|
name: "Fortune's Shrine",
|
||||||
|
description: "Consult oracles and tempt fate in games of chance.",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub struct RaceInfo {
|
pub struct RaceInfo {
|
||||||
|
|
@ -26,11 +46,51 @@ pub struct RaceInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const RACES: &[RaceInfo] = &[
|
pub const RACES: &[RaceInfo] = &[
|
||||||
RaceInfo { race: Race::Human, name: "Human", str_mod: 0, int_mod: 0, dex_mod: 0, con_mod: 0, luck_mod: 0 },
|
RaceInfo {
|
||||||
RaceInfo { race: Race::Elf, name: "Elf", str_mod: -2, int_mod: 4, dex_mod: 2, con_mod: -2, luck_mod: 0 },
|
race: Race::Human,
|
||||||
RaceInfo { race: Race::Dwarf, name: "Dwarf", str_mod: 2, int_mod: -2, dex_mod: -2, con_mod: 4, luck_mod: 0 },
|
name: "Human",
|
||||||
RaceInfo { race: Race::Hobbit, name: "Hobbit", str_mod: -4, int_mod: 0, dex_mod: 2, con_mod: -2, luck_mod: 4 },
|
str_mod: 0,
|
||||||
RaceInfo { race: Race::HalfElf, name: "Half-Elf", str_mod: 0, int_mod: 2, dex_mod: 2, con_mod: 0, luck_mod: 0 },
|
int_mod: 0,
|
||||||
|
dex_mod: 0,
|
||||||
|
con_mod: 0,
|
||||||
|
luck_mod: 0,
|
||||||
|
},
|
||||||
|
RaceInfo {
|
||||||
|
race: Race::Elf,
|
||||||
|
name: "Elf",
|
||||||
|
str_mod: -2,
|
||||||
|
int_mod: 4,
|
||||||
|
dex_mod: 2,
|
||||||
|
con_mod: -2,
|
||||||
|
luck_mod: 0,
|
||||||
|
},
|
||||||
|
RaceInfo {
|
||||||
|
race: Race::Dwarf,
|
||||||
|
name: "Dwarf",
|
||||||
|
str_mod: 2,
|
||||||
|
int_mod: -2,
|
||||||
|
dex_mod: -2,
|
||||||
|
con_mod: 4,
|
||||||
|
luck_mod: 0,
|
||||||
|
},
|
||||||
|
RaceInfo {
|
||||||
|
race: Race::Hobbit,
|
||||||
|
name: "Hobbit",
|
||||||
|
str_mod: -4,
|
||||||
|
int_mod: 0,
|
||||||
|
dex_mod: 2,
|
||||||
|
con_mod: -2,
|
||||||
|
luck_mod: 4,
|
||||||
|
},
|
||||||
|
RaceInfo {
|
||||||
|
race: Race::HalfElf,
|
||||||
|
name: "Half-Elf",
|
||||||
|
str_mod: 0,
|
||||||
|
int_mod: 2,
|
||||||
|
dex_mod: 2,
|
||||||
|
con_mod: 0,
|
||||||
|
luck_mod: 0,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub struct ClassInfo {
|
pub struct ClassInfo {
|
||||||
|
|
@ -43,16 +103,86 @@ pub struct ClassInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const CLASSES: &[ClassInfo] = &[
|
pub const CLASSES: &[ClassInfo] = &[
|
||||||
ClassInfo { class: Class::Warrior, name: "Warrior", primary_stat: "strength", secondary_stat: "constitution", base_hp: 20, hp_per_con: 3.0 },
|
ClassInfo {
|
||||||
ClassInfo { class: Class::Paladin, name: "Paladin", primary_stat: "strength", secondary_stat: "luck", base_hp: 18, hp_per_con: 2.5 },
|
class: Class::Warrior,
|
||||||
ClassInfo { class: Class::Rogue, name: "Rogue", primary_stat: "dexterity", secondary_stat: "luck", base_hp: 14, hp_per_con: 2.0 },
|
name: "Warrior",
|
||||||
ClassInfo { class: Class::Bard, name: "Bard", primary_stat: "dexterity", secondary_stat: "intelligence", base_hp: 14, hp_per_con: 2.0 },
|
primary_stat: "strength",
|
||||||
ClassInfo { class: Class::Hunter, name: "Hunter", primary_stat: "dexterity", secondary_stat: "strength", base_hp: 16, hp_per_con: 2.5 },
|
secondary_stat: "constitution",
|
||||||
ClassInfo { class: Class::Monk, name: "Monk", primary_stat: "dexterity", secondary_stat: "constitution", base_hp: 15, hp_per_con: 2.5 },
|
base_hp: 20,
|
||||||
ClassInfo { class: Class::Mage, name: "Mage", primary_stat: "intelligence", secondary_stat: "dexterity", base_hp: 11, hp_per_con: 1.8 },
|
hp_per_con: 3.0,
|
||||||
ClassInfo { class: Class::Wizard, name: "Wizard", primary_stat: "intelligence", secondary_stat: "constitution", base_hp: 10, hp_per_con: 1.5 },
|
},
|
||||||
ClassInfo { class: Class::Conjurer, name: "Conjurer", primary_stat: "intelligence", secondary_stat: "luck", base_hp: 10, hp_per_con: 1.5 },
|
ClassInfo {
|
||||||
ClassInfo { class: Class::Sorcerer, name: "Sorcerer", primary_stat: "intelligence", secondary_stat: "strength", base_hp: 12, hp_per_con: 2.0 },
|
class: Class::Paladin,
|
||||||
|
name: "Paladin",
|
||||||
|
primary_stat: "strength",
|
||||||
|
secondary_stat: "luck",
|
||||||
|
base_hp: 18,
|
||||||
|
hp_per_con: 2.5,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Rogue,
|
||||||
|
name: "Rogue",
|
||||||
|
primary_stat: "dexterity",
|
||||||
|
secondary_stat: "luck",
|
||||||
|
base_hp: 14,
|
||||||
|
hp_per_con: 2.0,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Bard,
|
||||||
|
name: "Bard",
|
||||||
|
primary_stat: "dexterity",
|
||||||
|
secondary_stat: "intelligence",
|
||||||
|
base_hp: 14,
|
||||||
|
hp_per_con: 2.0,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Hunter,
|
||||||
|
name: "Hunter",
|
||||||
|
primary_stat: "dexterity",
|
||||||
|
secondary_stat: "strength",
|
||||||
|
base_hp: 16,
|
||||||
|
hp_per_con: 2.5,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Monk,
|
||||||
|
name: "Monk",
|
||||||
|
primary_stat: "dexterity",
|
||||||
|
secondary_stat: "constitution",
|
||||||
|
base_hp: 15,
|
||||||
|
hp_per_con: 2.5,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Mage,
|
||||||
|
name: "Mage",
|
||||||
|
primary_stat: "intelligence",
|
||||||
|
secondary_stat: "dexterity",
|
||||||
|
base_hp: 11,
|
||||||
|
hp_per_con: 1.8,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Wizard,
|
||||||
|
name: "Wizard",
|
||||||
|
primary_stat: "intelligence",
|
||||||
|
secondary_stat: "constitution",
|
||||||
|
base_hp: 10,
|
||||||
|
hp_per_con: 1.5,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Conjurer,
|
||||||
|
name: "Conjurer",
|
||||||
|
primary_stat: "intelligence",
|
||||||
|
secondary_stat: "luck",
|
||||||
|
base_hp: 10,
|
||||||
|
hp_per_con: 1.5,
|
||||||
|
},
|
||||||
|
ClassInfo {
|
||||||
|
class: Class::Sorcerer,
|
||||||
|
name: "Sorcerer",
|
||||||
|
primary_stat: "intelligence",
|
||||||
|
secondary_stat: "strength",
|
||||||
|
base_hp: 12,
|
||||||
|
hp_per_con: 2.0,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn stat_bonus(stat: i64) -> i64 {
|
pub fn stat_bonus(stat: i64) -> i64 {
|
||||||
|
|
@ -65,7 +195,10 @@ fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats {
|
pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats {
|
||||||
let info = RACES.iter().find(|r| &r.race == race).expect("invalid race");
|
let info = RACES
|
||||||
|
.iter()
|
||||||
|
.find(|r| &r.race == race)
|
||||||
|
.expect("invalid race");
|
||||||
CharacterStats {
|
CharacterStats {
|
||||||
strength: roll_stat(rng, info.str_mod),
|
strength: roll_stat(rng, info.str_mod),
|
||||||
intelligence: roll_stat(rng, info.int_mod),
|
intelligence: roll_stat(rng, info.int_mod),
|
||||||
|
|
@ -76,17 +209,26 @@ pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn calc_max_hp(class: &Class, stats: &CharacterStats) -> i32 {
|
pub fn calc_max_hp(class: &Class, stats: &CharacterStats) -> i32 {
|
||||||
let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class");
|
let info = CLASSES
|
||||||
|
.iter()
|
||||||
|
.find(|c| &c.class == class)
|
||||||
|
.expect("invalid class");
|
||||||
(info.base_hp as f64 + stats.constitution as f64 * info.hp_per_con) as i32
|
(info.base_hp as f64 + stats.constitution as f64 * info.hp_per_con) as i32
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn calc_max_energy(class: &Class) -> i32 {
|
pub fn calc_max_energy(class: &Class) -> i32 {
|
||||||
let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class");
|
let info = CLASSES
|
||||||
|
.iter()
|
||||||
|
.find(|c| &c.class == class)
|
||||||
|
.expect("invalid class");
|
||||||
30 + info.base_hp / 3
|
30 + info.base_hp / 3
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn calc_max_nerve(class: &Class) -> i32 {
|
pub fn calc_max_nerve(class: &Class) -> i32 {
|
||||||
let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class");
|
let info = CLASSES
|
||||||
|
.iter()
|
||||||
|
.find(|c| &c.class == class)
|
||||||
|
.expect("invalid class");
|
||||||
100 + info.base_hp / 2
|
100 + info.base_hp / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,7 +246,9 @@ pub fn hp_regen_rate() -> i64 {
|
||||||
|
|
||||||
pub fn apply_regen(char: &mut Character, now_ms: i64) {
|
pub fn apply_regen(char: &mut Character, now_ms: i64) {
|
||||||
let elapsed = now_ms - char.last_regen;
|
let elapsed = now_ms - char.last_regen;
|
||||||
if elapsed <= 0 { return; }
|
if elapsed <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let energy_ticks = (elapsed / energy_regen_rate()) as i32;
|
let energy_ticks = (elapsed / energy_regen_rate()) as i32;
|
||||||
let nerve_ticks = (elapsed / nerve_regen_rate()) as i32;
|
let nerve_ticks = (elapsed / nerve_regen_rate()) as i32;
|
||||||
let hp_ticks = (elapsed / hp_regen_rate()) as i32;
|
let hp_ticks = (elapsed / hp_regen_rate()) as i32;
|
||||||
|
|
@ -140,7 +284,8 @@ pub fn create_character(
|
||||||
let max_nerve = calc_max_nerve(&class);
|
let max_nerve = calc_max_nerve(&class);
|
||||||
|
|
||||||
let known_spells: Vec<String> = if spells::is_caster_class(&class_str) {
|
let known_spells: Vec<String> = if spells::is_caster_class(&class_str) {
|
||||||
spells::spells_for_class(&class_str, 1).iter()
|
spells::spells_for_class(&class_str, 1)
|
||||||
|
.iter()
|
||||||
.filter(|s| s.spell_level <= 1)
|
.filter(|s| s.spell_level <= 1)
|
||||||
.map(|s| s.id.clone())
|
.map(|s| s.id.clone())
|
||||||
.collect()
|
.collect()
|
||||||
|
|
@ -177,7 +322,9 @@ pub fn create_character(
|
||||||
("ring".into(), None),
|
("ring".into(), None),
|
||||||
("trinket".into(), None),
|
("trinket".into(), None),
|
||||||
("instrument".into(), None),
|
("instrument".into(), None),
|
||||||
].into_iter().collect(),
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
inventory: Vec::new(),
|
inventory: Vec::new(),
|
||||||
active_song: None,
|
active_song: None,
|
||||||
known_spells,
|
known_spells,
|
||||||
|
|
@ -191,7 +338,9 @@ pub const MAX_LEVEL: i32 = 100;
|
||||||
pub const XP_FACTOR: i64 = 18;
|
pub const XP_FACTOR: i64 = 18;
|
||||||
|
|
||||||
pub fn xp_for_level(level: i32) -> i64 {
|
pub fn xp_for_level(level: i32) -> i64 {
|
||||||
if level <= 1 { return 0; }
|
if level <= 1 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
let n = (level - 1) as f64;
|
let n = (level - 1) as f64;
|
||||||
XP_FACTOR * (100.0 * n * n) as i64
|
XP_FACTOR * (100.0 * n * n) as i64
|
||||||
}
|
}
|
||||||
|
|
@ -227,11 +376,17 @@ pub fn level_up(char: &mut Character) -> Result<i32, &'static str> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_race_info(race: &Race) -> &'static RaceInfo {
|
pub fn find_race_info(race: &Race) -> &'static RaceInfo {
|
||||||
RACES.iter().find(|r| &r.race == race).expect("invalid race")
|
RACES
|
||||||
|
.iter()
|
||||||
|
.find(|r| &r.race == race)
|
||||||
|
.expect("invalid race")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_class_info(class: &Class) -> &'static ClassInfo {
|
pub fn find_class_info(class: &Class) -> &'static ClassInfo {
|
||||||
CLASSES.iter().find(|c| &c.class == class).expect("invalid class")
|
CLASSES
|
||||||
|
.iter()
|
||||||
|
.find(|c| &c.class == class)
|
||||||
|
.expect("invalid class")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -242,15 +397,35 @@ mod tests {
|
||||||
|
|
||||||
fn base_char() -> Character {
|
fn base_char() -> Character {
|
||||||
Character {
|
Character {
|
||||||
id: 1, user_id: 1, name: "Test".into(),
|
id: 1,
|
||||||
race: Race::Human, class: Class::Warrior,
|
user_id: 1,
|
||||||
level: 1, xp: 0,
|
name: "Test".into(),
|
||||||
stats: CharacterStats { strength: 20, intelligence: 20, dexterity: 20, constitution: 20, luck: 20 },
|
race: Race::Human,
|
||||||
hp: 80, max_hp: 80, energy: 36, max_energy: 36,
|
class: Class::Warrior,
|
||||||
nerve: 110, max_nerve: 110, gold: 0, last_regen: 0,
|
level: 1,
|
||||||
equipment: HashMap::new(), inventory: Vec::new(),
|
xp: 0,
|
||||||
active_song: None, known_spells: Vec::new(),
|
stats: CharacterStats {
|
||||||
active_spell: None, spell_points: 0, max_spell_points: 0,
|
strength: 20,
|
||||||
|
intelligence: 20,
|
||||||
|
dexterity: 20,
|
||||||
|
constitution: 20,
|
||||||
|
luck: 20,
|
||||||
|
},
|
||||||
|
hp: 80,
|
||||||
|
max_hp: 80,
|
||||||
|
energy: 36,
|
||||||
|
max_energy: 36,
|
||||||
|
nerve: 110,
|
||||||
|
max_nerve: 110,
|
||||||
|
gold: 0,
|
||||||
|
last_regen: 0,
|
||||||
|
equipment: HashMap::new(),
|
||||||
|
inventory: Vec::new(),
|
||||||
|
active_song: None,
|
||||||
|
known_spells: Vec::new(),
|
||||||
|
active_spell: None,
|
||||||
|
spell_points: 0,
|
||||||
|
max_spell_points: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -351,8 +526,20 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn calc_max_hp_depends_on_class_and_con() {
|
fn calc_max_hp_depends_on_class_and_con() {
|
||||||
let stats_high = CharacterStats { strength: 10, intelligence: 10, dexterity: 10, constitution: 30, luck: 10 };
|
let stats_high = CharacterStats {
|
||||||
let stats_low = CharacterStats { strength: 10, intelligence: 10, dexterity: 10, constitution: 10, luck: 10 };
|
strength: 10,
|
||||||
|
intelligence: 10,
|
||||||
|
dexterity: 10,
|
||||||
|
constitution: 30,
|
||||||
|
luck: 10,
|
||||||
|
};
|
||||||
|
let stats_low = CharacterStats {
|
||||||
|
strength: 10,
|
||||||
|
intelligence: 10,
|
||||||
|
dexterity: 10,
|
||||||
|
constitution: 10,
|
||||||
|
luck: 10,
|
||||||
|
};
|
||||||
let hp_high = calc_max_hp(&Class::Warrior, &stats_high);
|
let hp_high = calc_max_hp(&Class::Warrior, &stats_high);
|
||||||
let hp_low = calc_max_hp(&Class::Warrior, &stats_low);
|
let hp_low = calc_max_hp(&Class::Warrior, &stats_low);
|
||||||
assert!(hp_high > hp_low);
|
assert!(hp_high > hp_low);
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@ use std::sync::LazyLock;
|
||||||
const CRIT_TABLES_JSON: &str =
|
const CRIT_TABLES_JSON: &str =
|
||||||
include_str!("../../../../skara-brae-common/data/crit-tables/combat.json");
|
include_str!("../../../../skara-brae-common/data/crit-tables/combat.json");
|
||||||
|
|
||||||
static CRIT_TABLES: LazyLock<HashMap<String, HashMap<String, CritEntry>>> = LazyLock::new(|| {
|
static CRIT_TABLES: LazyLock<HashMap<String, HashMap<String, CritEntry>>> =
|
||||||
serde_json::from_str(CRIT_TABLES_JSON).expect("failed to parse crit tables")
|
LazyLock::new(|| serde_json::from_str(CRIT_TABLES_JSON).expect("failed to parse crit tables"));
|
||||||
});
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CombatStats {
|
pub struct CombatStats {
|
||||||
|
|
@ -93,7 +92,8 @@ pub fn resolve_attack(
|
||||||
&& let Some(crit_entry) = lookup_crit(&attacker.attack_type, &severity)
|
&& let Some(crit_entry) = lookup_crit(&attacker.attack_type, &severity)
|
||||||
{
|
{
|
||||||
let crit_damage = (damage as f64 * crit_entry.damage_multiplier) as i32;
|
let crit_damage = (damage as f64 * crit_entry.damage_multiplier) as i32;
|
||||||
let capped = if let Some(cap) = crit_damage_cap(attacker.level, defender.level, defender.hp) {
|
let capped =
|
||||||
|
if let Some(cap) = crit_damage_cap(attacker.level, defender.level, defender.hp) {
|
||||||
crit_damage.min(cap)
|
crit_damage.min(cap)
|
||||||
} else {
|
} else {
|
||||||
crit_damage
|
crit_damage
|
||||||
|
|
@ -155,7 +155,9 @@ pub fn resolve_combat(
|
||||||
gold_max: i32,
|
gold_max: i32,
|
||||||
rng: &mut impl Rng,
|
rng: &mut impl Rng,
|
||||||
) -> CombatLog {
|
) -> CombatLog {
|
||||||
resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, None, None, rng)
|
resolve_combat_full(
|
||||||
|
attacker, defender, xp_reward, gold_min, gold_max, None, None, rng,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_combat_with_song(
|
pub fn resolve_combat_with_song(
|
||||||
|
|
@ -167,7 +169,16 @@ pub fn resolve_combat_with_song(
|
||||||
song_effect: Option<&str>,
|
song_effect: Option<&str>,
|
||||||
rng: &mut impl Rng,
|
rng: &mut impl Rng,
|
||||||
) -> CombatLog {
|
) -> CombatLog {
|
||||||
resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, song_effect, None, rng)
|
resolve_combat_full(
|
||||||
|
attacker,
|
||||||
|
defender,
|
||||||
|
xp_reward,
|
||||||
|
gold_min,
|
||||||
|
gold_max,
|
||||||
|
song_effect,
|
||||||
|
None,
|
||||||
|
rng,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
|
@ -187,7 +198,11 @@ pub fn resolve_combat_full(
|
||||||
let mut rounds = Vec::new();
|
let mut rounds = Vec::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let mut round = resolve_attack(&mut rng, &apply_song_to_attacker(&attacker, song_effect), &apply_song_to_defender(&defender, song_effect));
|
let mut round = resolve_attack(
|
||||||
|
&mut rng,
|
||||||
|
&apply_song_to_attacker(&attacker, song_effect),
|
||||||
|
&apply_song_to_defender(&defender, song_effect),
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(sp) = spell
|
if let Some(sp) = spell
|
||||||
&& sp.combat_effect == "damage"
|
&& sp.combat_effect == "damage"
|
||||||
|
|
@ -211,7 +226,11 @@ pub fn resolve_combat_full(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut round = resolve_attack(&mut rng, &defender, &apply_song_to_defender(&make_temp_stats(&attacker, a_hp), song_effect));
|
let mut round = resolve_attack(
|
||||||
|
&mut rng,
|
||||||
|
&defender,
|
||||||
|
&apply_song_to_defender(&make_temp_stats(&attacker, a_hp), song_effect),
|
||||||
|
);
|
||||||
a_hp = (a_hp - round.damage).max(0);
|
a_hp = (a_hp - round.damage).max(0);
|
||||||
round.defender_hp = a_hp;
|
round.defender_hp = a_hp;
|
||||||
rounds.push(round);
|
rounds.push(round);
|
||||||
|
|
@ -232,7 +251,9 @@ pub fn resolve_combat_full(
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(sp) = spell
|
if let Some(sp) = spell
|
||||||
&& sp.combat_effect == "heal" && a_hp > 0 && a_hp < attacker.hp
|
&& sp.combat_effect == "heal"
|
||||||
|
&& a_hp > 0
|
||||||
|
&& a_hp < attacker.hp
|
||||||
{
|
{
|
||||||
let heal_amt = rng.random_range(sp.heal_min..=sp.heal_max);
|
let heal_amt = rng.random_range(sp.heal_min..=sp.heal_max);
|
||||||
a_hp = (a_hp + heal_amt).min(attacker.hp);
|
a_hp = (a_hp + heal_amt).min(attacker.hp);
|
||||||
|
|
@ -243,8 +264,12 @@ pub fn resolve_combat_full(
|
||||||
fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
|
fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
|
||||||
let mut s = stats.clone();
|
let mut s = stats.clone();
|
||||||
match song_effect {
|
match song_effect {
|
||||||
Some("accuracy") => { s.ob_base += 15; }
|
Some("accuracy") => {
|
||||||
Some("damage") => { s.ob_base += 20; }
|
s.ob_base += 15;
|
||||||
|
}
|
||||||
|
Some("damage") => {
|
||||||
|
s.ob_base += 20;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
s
|
s
|
||||||
|
|
@ -253,8 +278,12 @@ fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> Com
|
||||||
fn apply_song_to_defender(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
|
fn apply_song_to_defender(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats {
|
||||||
let mut s = stats.clone();
|
let mut s = stats.clone();
|
||||||
match song_effect {
|
match song_effect {
|
||||||
Some("defense") | Some("magic_resist") => { s.db_base += 10; }
|
Some("defense") | Some("magic_resist") => {
|
||||||
Some("weaken") => { s.ob_base = (s.ob_base as f64 * 0.7) as i32; }
|
s.db_base += 10;
|
||||||
|
}
|
||||||
|
Some("weaken") => {
|
||||||
|
s.ob_base = (s.ob_base as f64 * 0.7) as i32;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
s
|
s
|
||||||
|
|
@ -272,18 +301,30 @@ mod tests {
|
||||||
|
|
||||||
fn attacker() -> CombatStats {
|
fn attacker() -> CombatStats {
|
||||||
CombatStats {
|
CombatStats {
|
||||||
name: "Hero".into(), hp: 100, level: 1,
|
name: "Hero".into(),
|
||||||
strength: 50, dexterity: 40, constitution: 40, luck: 20,
|
hp: 100,
|
||||||
ob_base: 20, db_base: 10,
|
level: 1,
|
||||||
|
strength: 50,
|
||||||
|
dexterity: 40,
|
||||||
|
constitution: 40,
|
||||||
|
luck: 20,
|
||||||
|
ob_base: 20,
|
||||||
|
db_base: 10,
|
||||||
attack_type: AttackType::Slash,
|
attack_type: AttackType::Slash,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn defender() -> CombatStats {
|
fn defender() -> CombatStats {
|
||||||
CombatStats {
|
CombatStats {
|
||||||
name: "Goblin".into(), hp: 50, level: 1,
|
name: "Goblin".into(),
|
||||||
strength: 30, dexterity: 30, constitution: 30, luck: 10,
|
hp: 50,
|
||||||
ob_base: 10, db_base: 5,
|
level: 1,
|
||||||
|
strength: 30,
|
||||||
|
dexterity: 30,
|
||||||
|
constitution: 30,
|
||||||
|
luck: 10,
|
||||||
|
ob_base: 10,
|
||||||
|
db_base: 5,
|
||||||
attack_type: AttackType::Crush,
|
attack_type: AttackType::Crush,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -294,7 +335,11 @@ mod tests {
|
||||||
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
|
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
|
||||||
for _ in 0..1000 {
|
for _ in 0..1000 {
|
||||||
let roll = open_ended_roll(&mut rng);
|
let roll = open_ended_roll(&mut rng);
|
||||||
assert!(roll > -500 && roll < 500, "roll out of expected range: {}", roll);
|
assert!(
|
||||||
|
roll > -500 && roll < 500,
|
||||||
|
"roll out of expected range: {}",
|
||||||
|
roll
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -364,7 +409,10 @@ mod tests {
|
||||||
fn parse_attack_type_maps_known_types() {
|
fn parse_attack_type_maps_known_types() {
|
||||||
assert!(matches!(parse_attack_type("slash"), AttackType::Slash));
|
assert!(matches!(parse_attack_type("slash"), AttackType::Slash));
|
||||||
assert!(matches!(parse_attack_type("crush"), AttackType::Crush));
|
assert!(matches!(parse_attack_type("crush"), AttackType::Crush));
|
||||||
assert!(matches!(parse_attack_type("puncture"), AttackType::Puncture));
|
assert!(matches!(
|
||||||
|
parse_attack_type("puncture"),
|
||||||
|
AttackType::Puncture
|
||||||
|
));
|
||||||
assert!(matches!(parse_attack_type("unarmed"), AttackType::Crush));
|
assert!(matches!(parse_attack_type("unarmed"), AttackType::Crush));
|
||||||
assert!(matches!(parse_attack_type("unknown"), AttackType::Crush));
|
assert!(matches!(parse_attack_type("unknown"), AttackType::Crush));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const ENEMIES_JSON: &str =
|
const ENEMIES_JSON: &str = include_str!("../../../../skara-brae-common/data/enemies.json");
|
||||||
include_str!("../../../../skara-brae-common/data/enemies.json");
|
const ZONES_JSON: &str = include_str!("../../../../skara-brae-common/data/adventures/zones.json");
|
||||||
const ZONES_JSON: &str =
|
|
||||||
include_str!("../../../../skara-brae-common/data/adventures/zones.json");
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct EnemyDef {
|
pub struct EnemyDef {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
use sb_shared::Item;
|
use sb_shared::Item;
|
||||||
|
|
||||||
const WEAPONS_JSON: &str =
|
const WEAPONS_JSON: &str = include_str!("../../../../skara-brae-common/data/items/weapons.json");
|
||||||
include_str!("../../../../skara-brae-common/data/items/weapons.json");
|
const ARMOR_JSON: &str = include_str!("../../../../skara-brae-common/data/items/armor.json");
|
||||||
const ARMOR_JSON: &str =
|
|
||||||
include_str!("../../../../skara-brae-common/data/items/armor.json");
|
|
||||||
const ACCESSORIES_JSON: &str =
|
const ACCESSORIES_JSON: &str =
|
||||||
include_str!("../../../../skara-brae-common/data/items/accessories.json");
|
include_str!("../../../../skara-brae-common/data/items/accessories.json");
|
||||||
|
|
||||||
|
|
@ -14,8 +12,7 @@ pub fn load_all_items() -> Vec<Item> {
|
||||||
serde_json::from_str(WEAPONS_JSON).expect("failed to parse weapons.json");
|
serde_json::from_str(WEAPONS_JSON).expect("failed to parse weapons.json");
|
||||||
items.extend(weapons);
|
items.extend(weapons);
|
||||||
|
|
||||||
let armor: Vec<Item> =
|
let armor: Vec<Item> = serde_json::from_str(ARMOR_JSON).expect("failed to parse armor.json");
|
||||||
serde_json::from_str(ARMOR_JSON).expect("failed to parse armor.json");
|
|
||||||
items.extend(armor);
|
items.extend(armor);
|
||||||
|
|
||||||
let accessories: Vec<Item> =
|
let accessories: Vec<Item> =
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use sb_shared::{Character, Item, AttackType};
|
use crate::data::{EncounterEntry, EnemyDef};
|
||||||
use crate::data::{EnemyDef, EncounterEntry};
|
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
use sb_shared::{AttackType, Character, Item};
|
||||||
|
|
||||||
pub fn pick_enemy(
|
pub fn pick_enemy(
|
||||||
rng: &mut impl Rng,
|
rng: &mut impl Rng,
|
||||||
|
|
@ -13,17 +13,24 @@ pub fn pick_enemy(
|
||||||
for enc in encounters {
|
for enc in encounters {
|
||||||
remaining -= enc.weight;
|
remaining -= enc.weight;
|
||||||
if remaining < 0 {
|
if remaining < 0 {
|
||||||
return enemies.iter().find(|e| e.id == enc.enemy_id)
|
return enemies
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.id == enc.enemy_id)
|
||||||
.expect("encounter references valid enemy")
|
.expect("encounter references valid enemy")
|
||||||
.clone();
|
.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
enemies.iter().find(|e| e.id == encounters.last().unwrap().enemy_id)
|
enemies
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.id == encounters.last().unwrap().enemy_id)
|
||||||
.expect("encounter references valid enemy")
|
.expect("encounter references valid enemy")
|
||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn equipped_attack_type(equipment: &std::collections::HashMap<String, Option<String>>, items: &[Item]) -> AttackType {
|
pub fn equipped_attack_type(
|
||||||
|
equipment: &std::collections::HashMap<String, Option<String>>,
|
||||||
|
items: &[Item],
|
||||||
|
) -> AttackType {
|
||||||
if let Some(Some(weapon_id)) = equipment.get("main_hand")
|
if let Some(Some(weapon_id)) = equipment.get("main_hand")
|
||||||
&& let Some(weapon) = items.iter().find(|i| &i.id == weapon_id)
|
&& let Some(weapon) = items.iter().find(|i| &i.id == weapon_id)
|
||||||
&& let Some(ref at) = weapon.attack_type
|
&& let Some(ref at) = weapon.attack_type
|
||||||
|
|
@ -33,7 +40,10 @@ pub fn equipped_attack_type(equipment: &std::collections::HashMap<String, Option
|
||||||
AttackType::Crush
|
AttackType::Crush
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn equipped_damage(equipment: &std::collections::HashMap<String, Option<String>>, items: &[Item]) -> i32 {
|
pub fn equipped_damage(
|
||||||
|
equipment: &std::collections::HashMap<String, Option<String>>,
|
||||||
|
items: &[Item],
|
||||||
|
) -> i32 {
|
||||||
let mut total = 0i32;
|
let mut total = 0i32;
|
||||||
for slot in &["main_hand", "off_hand"] {
|
for slot in &["main_hand", "off_hand"] {
|
||||||
if let Some(Some(item_id)) = equipment.get(*slot)
|
if let Some(Some(item_id)) = equipment.get(*slot)
|
||||||
|
|
@ -46,7 +56,10 @@ pub fn equipped_damage(equipment: &std::collections::HashMap<String, Option<Stri
|
||||||
total
|
total
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn total_defense(equipment: &std::collections::HashMap<String, Option<String>>, items: &[Item]) -> i32 {
|
pub fn total_defense(
|
||||||
|
equipment: &std::collections::HashMap<String, Option<String>>,
|
||||||
|
items: &[Item],
|
||||||
|
) -> i32 {
|
||||||
let mut total = 0i32;
|
let mut total = 0i32;
|
||||||
for (_, item_id) in equipment.iter() {
|
for (_, item_id) in equipment.iter() {
|
||||||
if let Some(id) = item_id
|
if let Some(id) = item_id
|
||||||
|
|
@ -60,7 +73,10 @@ pub fn total_defense(equipment: &std::collections::HashMap<String, Option<String
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<(), &'static str> {
|
pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<(), &'static str> {
|
||||||
let inv_pos = char.inventory.iter().position(|id| id == item_id)
|
let inv_pos = char
|
||||||
|
.inventory
|
||||||
|
.iter()
|
||||||
|
.position(|id| id == item_id)
|
||||||
.ok_or("item not in inventory")?;
|
.ok_or("item not in inventory")?;
|
||||||
|
|
||||||
char.inventory.remove(inv_pos);
|
char.inventory.remove(inv_pos);
|
||||||
|
|
@ -73,7 +89,9 @@ pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, &'static str> {
|
pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, &'static str> {
|
||||||
let prev = char.equipment.insert(slot.to_string(), None)
|
let prev = char
|
||||||
|
.equipment
|
||||||
|
.insert(slot.to_string(), None)
|
||||||
.flatten()
|
.flatten()
|
||||||
.ok_or("nothing equipped in slot")?;
|
.ok_or("nothing equipped in slot")?;
|
||||||
|
|
||||||
|
|
@ -125,42 +143,70 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str>
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use sb_shared::{Character, CharacterStats, Class, Race, Item, Quality, ItemType};
|
use sb_shared::{Character, CharacterStats, Class, Item, ItemType, Quality, Race};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
fn base_char() -> Character {
|
fn base_char() -> Character {
|
||||||
Character {
|
Character {
|
||||||
id: 1, user_id: 1, name: "Test".into(),
|
id: 1,
|
||||||
race: Race::Human, class: Class::Warrior,
|
user_id: 1,
|
||||||
level: 1, xp: 0,
|
name: "Test".into(),
|
||||||
stats: CharacterStats { strength: 20, intelligence: 20, dexterity: 20, constitution: 20, luck: 20 },
|
race: Race::Human,
|
||||||
hp: 80, max_hp: 80, energy: 10, max_energy: 36,
|
class: Class::Warrior,
|
||||||
nerve: 110, max_nerve: 110, gold: 1000, last_regen: 0,
|
level: 1,
|
||||||
equipment: HashMap::new(), inventory: Vec::new(),
|
xp: 0,
|
||||||
active_song: None, known_spells: Vec::new(),
|
stats: CharacterStats {
|
||||||
active_spell: None, spell_points: 0, max_spell_points: 0,
|
strength: 20,
|
||||||
|
intelligence: 20,
|
||||||
|
dexterity: 20,
|
||||||
|
constitution: 20,
|
||||||
|
luck: 20,
|
||||||
|
},
|
||||||
|
hp: 80,
|
||||||
|
max_hp: 80,
|
||||||
|
energy: 10,
|
||||||
|
max_energy: 36,
|
||||||
|
nerve: 110,
|
||||||
|
max_nerve: 110,
|
||||||
|
gold: 1000,
|
||||||
|
last_regen: 0,
|
||||||
|
equipment: HashMap::new(),
|
||||||
|
inventory: Vec::new(),
|
||||||
|
active_song: None,
|
||||||
|
known_spells: Vec::new(),
|
||||||
|
active_spell: None,
|
||||||
|
spell_points: 0,
|
||||||
|
max_spell_points: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sword() -> Item {
|
fn sword() -> Item {
|
||||||
Item {
|
Item {
|
||||||
id: "sword1".into(), name: "Iron Sword".into(),
|
id: "sword1".into(),
|
||||||
r#type: ItemType::Weapon, quality: Quality::Common,
|
name: "Iron Sword".into(),
|
||||||
|
r#type: ItemType::Weapon,
|
||||||
|
quality: Quality::Common,
|
||||||
slot: "main_hand".into(),
|
slot: "main_hand".into(),
|
||||||
stats: HashMap::from([("damage".into(), 15i64)]),
|
stats: HashMap::from([("damage".into(), 15i64)]),
|
||||||
description: String::new(), attack_type: Some(AttackType::Slash),
|
description: String::new(),
|
||||||
value: 100, always_in_shop: true,
|
attack_type: Some(AttackType::Slash),
|
||||||
|
value: 100,
|
||||||
|
always_in_shop: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shield() -> Item {
|
fn shield() -> Item {
|
||||||
Item {
|
Item {
|
||||||
id: "shield1".into(), name: "Wood Shield".into(),
|
id: "shield1".into(),
|
||||||
r#type: ItemType::Armor, quality: Quality::Common,
|
name: "Wood Shield".into(),
|
||||||
|
r#type: ItemType::Armor,
|
||||||
|
quality: Quality::Common,
|
||||||
slot: "off_hand".into(),
|
slot: "off_hand".into(),
|
||||||
stats: HashMap::from([("defense".into(), 10i64)]),
|
stats: HashMap::from([("defense".into(), 10i64)]),
|
||||||
description: String::new(), attack_type: None,
|
description: String::new(),
|
||||||
value: 50, always_in_shop: true,
|
attack_type: None,
|
||||||
|
value: 50,
|
||||||
|
always_in_shop: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,16 +257,23 @@ mod tests {
|
||||||
c.inventory.push("sword1".into());
|
c.inventory.push("sword1".into());
|
||||||
equip_item(&mut c, &sword(), "sword1").unwrap();
|
equip_item(&mut c, &sword(), "sword1").unwrap();
|
||||||
assert!(!c.inventory.contains(&"sword1".to_string()));
|
assert!(!c.inventory.contains(&"sword1".to_string()));
|
||||||
assert_eq!(c.equipment.get("main_hand").cloned(), Some(Some("sword1".into())));
|
assert_eq!(
|
||||||
|
c.equipment.get("main_hand").cloned(),
|
||||||
|
Some(Some("sword1".into()))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn equip_item_swaps_previous() {
|
fn equip_item_swaps_previous() {
|
||||||
let mut c = base_char();
|
let mut c = base_char();
|
||||||
c.equipment.insert("main_hand".into(), Some("old_sword".into()));
|
c.equipment
|
||||||
|
.insert("main_hand".into(), Some("old_sword".into()));
|
||||||
c.inventory.push("sword1".into());
|
c.inventory.push("sword1".into());
|
||||||
equip_item(&mut c, &sword(), "sword1").unwrap();
|
equip_item(&mut c, &sword(), "sword1").unwrap();
|
||||||
assert_eq!(c.equipment.get("main_hand").cloned(), Some(Some("sword1".into())));
|
assert_eq!(
|
||||||
|
c.equipment.get("main_hand").cloned(),
|
||||||
|
Some(Some("sword1".into()))
|
||||||
|
);
|
||||||
assert!(c.inventory.contains(&"old_sword".to_string()));
|
assert!(c.inventory.contains(&"old_sword".to_string()));
|
||||||
assert!(!c.inventory.contains(&"sword1".to_string()));
|
assert!(!c.inventory.contains(&"sword1".to_string()));
|
||||||
}
|
}
|
||||||
|
|
@ -228,13 +281,17 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn equip_item_fails_if_not_in_inventory() {
|
fn equip_item_fails_if_not_in_inventory() {
|
||||||
let mut c = base_char();
|
let mut c = base_char();
|
||||||
assert_eq!(equip_item(&mut c, &sword(), "sword1"), Err("item not in inventory"));
|
assert_eq!(
|
||||||
|
equip_item(&mut c, &sword(), "sword1"),
|
||||||
|
Err("item not in inventory")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unequip_item_moves_to_inventory() {
|
fn unequip_item_moves_to_inventory() {
|
||||||
let mut c = base_char();
|
let mut c = base_char();
|
||||||
c.equipment.insert("main_hand".into(), Some("sword1".into()));
|
c.equipment
|
||||||
|
.insert("main_hand".into(), Some("sword1".into()));
|
||||||
let result = unequip_item(&mut c, "main_hand").unwrap();
|
let result = unequip_item(&mut c, "main_hand").unwrap();
|
||||||
assert_eq!(result, "sword1");
|
assert_eq!(result, "sword1");
|
||||||
assert!(c.inventory.contains(&"sword1".to_string()));
|
assert!(c.inventory.contains(&"sword1".to_string()));
|
||||||
|
|
@ -244,7 +301,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn unequip_item_fails_empty_slot() {
|
fn unequip_item_fails_empty_slot() {
|
||||||
let mut c = base_char();
|
let mut c = base_char();
|
||||||
assert_eq!(unequip_item(&mut c, "main_hand"), Err("nothing equipped in slot"));
|
assert_eq!(
|
||||||
|
unequip_item(&mut c, "main_hand"),
|
||||||
|
Err("nothing equipped in slot")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -270,42 +330,77 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn equipped_attack_type_returns_weapon_type() {
|
fn equipped_attack_type_returns_weapon_type() {
|
||||||
let items = vec![sword()];
|
let items = vec![sword()];
|
||||||
let eq: HashMap<String, Option<String>> = HashMap::from([
|
let eq: HashMap<String, Option<String>> =
|
||||||
("main_hand".into(), Some("sword1".into())),
|
HashMap::from([("main_hand".into(), Some("sword1".into()))]);
|
||||||
]);
|
assert!(matches!(
|
||||||
assert!(matches!(equipped_attack_type(&eq, &items), AttackType::Slash));
|
equipped_attack_type(&eq, &items),
|
||||||
|
AttackType::Slash
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn equipped_attack_type_defaults_to_crush() {
|
fn equipped_attack_type_defaults_to_crush() {
|
||||||
let items: Vec<Item> = vec![];
|
let items: Vec<Item> = vec![];
|
||||||
let eq: HashMap<String, Option<String>> = HashMap::new();
|
let eq: HashMap<String, Option<String>> = HashMap::new();
|
||||||
assert!(matches!(equipped_attack_type(&eq, &items), AttackType::Crush));
|
assert!(matches!(
|
||||||
|
equipped_attack_type(&eq, &items),
|
||||||
|
AttackType::Crush
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pick_enemy_respects_weights() {
|
fn pick_enemy_respects_weights() {
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
let encounters = vec![
|
let encounters = vec![
|
||||||
crate::data::EncounterEntry { enemy_id: "kobold".into(), weight: 3 },
|
crate::data::EncounterEntry {
|
||||||
crate::data::EncounterEntry { enemy_id: "rat".into(), weight: 1 },
|
enemy_id: "kobold".into(),
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
crate::data::EncounterEntry {
|
||||||
|
enemy_id: "rat".into(),
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
let enemies = vec![
|
let enemies = vec![
|
||||||
crate::data::EnemyDef {
|
crate::data::EnemyDef {
|
||||||
id: "kobold".into(), name: "Kobold".into(),
|
id: "kobold".into(),
|
||||||
hp: 20, level: 1, damage: 5, defense: 2,
|
name: "Kobold".into(),
|
||||||
|
hp: 20,
|
||||||
|
level: 1,
|
||||||
|
damage: 5,
|
||||||
|
defense: 2,
|
||||||
attack_type: "crush".into(),
|
attack_type: "crush".into(),
|
||||||
xp: 10, gold_min: 1, gold_max: 5,
|
xp: 10,
|
||||||
stats: crate::data::EnemyStats { strength: 15, intelligence: 10, dexterity: 15, constitution: 15, luck: 5 },
|
gold_min: 1,
|
||||||
|
gold_max: 5,
|
||||||
|
stats: crate::data::EnemyStats {
|
||||||
|
strength: 15,
|
||||||
|
intelligence: 10,
|
||||||
|
dexterity: 15,
|
||||||
|
constitution: 15,
|
||||||
|
luck: 5,
|
||||||
|
},
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
loot: vec![],
|
loot: vec![],
|
||||||
},
|
},
|
||||||
crate::data::EnemyDef {
|
crate::data::EnemyDef {
|
||||||
id: "rat".into(), name: "Giant Rat".into(),
|
id: "rat".into(),
|
||||||
hp: 10, level: 1, damage: 3, defense: 1,
|
name: "Giant Rat".into(),
|
||||||
|
hp: 10,
|
||||||
|
level: 1,
|
||||||
|
damage: 3,
|
||||||
|
defense: 1,
|
||||||
attack_type: "puncture".into(),
|
attack_type: "puncture".into(),
|
||||||
xp: 5, gold_min: 0, gold_max: 2,
|
xp: 5,
|
||||||
stats: crate::data::EnemyStats { strength: 5, intelligence: 5, dexterity: 15, constitution: 5, luck: 5 },
|
gold_min: 0,
|
||||||
|
gold_max: 2,
|
||||||
|
stats: crate::data::EnemyStats {
|
||||||
|
strength: 5,
|
||||||
|
intelligence: 5,
|
||||||
|
dexterity: 15,
|
||||||
|
constitution: 5,
|
||||||
|
luck: 5,
|
||||||
|
},
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
loot: vec![],
|
loot: vec![],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -55,5 +55,8 @@ pub const BARD_SONGS: &[SongDef] = &[
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn available_songs(level: i32) -> Vec<&'static SongDef> {
|
pub fn available_songs(level: i32) -> Vec<&'static SongDef> {
|
||||||
BARD_SONGS.iter().filter(|s| s.required_level <= level).collect()
|
BARD_SONGS
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.required_level <= level)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@ pub struct SpellDef {
|
||||||
pub heal_max: i32,
|
pub heal_max: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPELLS_JSON: &str =
|
const SPELLS_JSON: &str = include_str!("../../../../skara-brae-common/data/spells/spells.json");
|
||||||
include_str!("../../../../skara-brae-common/data/spells/spells.json");
|
|
||||||
|
|
||||||
pub fn load_spells() -> Vec<SpellDef> {
|
pub fn load_spells() -> Vec<SpellDef> {
|
||||||
serde_json::from_str(SPELLS_JSON).expect("failed to parse spells.json")
|
serde_json::from_str(SPELLS_JSON).expect("failed to parse spells.json")
|
||||||
|
|
@ -31,9 +30,10 @@ pub fn class_for_spells(class: &str) -> &str {
|
||||||
|
|
||||||
pub fn spells_for_class(class: &str, spell_level: i32) -> Vec<&'static SpellDef> {
|
pub fn spells_for_class(class: &str, spell_level: i32) -> Vec<&'static SpellDef> {
|
||||||
let spell_class = class_for_spells(class);
|
let spell_class = class_for_spells(class);
|
||||||
ALL_SPELLS.iter().filter(|s| {
|
ALL_SPELLS
|
||||||
s.caster_class == spell_class && s.spell_level <= spell_level
|
.iter()
|
||||||
}).collect()
|
.filter(|s| s.caster_class == spell_class && s.spell_level <= spell_level)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_spell(id: &str) -> Option<&'static SpellDef> {
|
pub fn find_spell(id: &str) -> Option<&'static SpellDef> {
|
||||||
|
|
@ -63,7 +63,10 @@ pub fn max_spell_points(class: &str, level: i32) -> i32 {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_caster_class(class: &str) -> bool {
|
pub fn is_caster_class(class: &str) -> bool {
|
||||||
matches!(class_for_spells(class), "conjurer" | "magician" | "sorcerer" | "wizard")
|
matches!(
|
||||||
|
class_for_spells(class),
|
||||||
|
"conjurer" | "magician" | "sorcerer" | "wizard"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn caster_classes() -> &'static [&'static str] {
|
pub fn caster_classes() -> &'static [&'static str] {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use sb_shared::{Character, CharacterStats, Class, Race, User, Role};
|
use sb_shared::{Character, CharacterStats, Class, Race, Role, User};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
@ -11,7 +11,9 @@ pub struct Database {
|
||||||
impl Database {
|
impl Database {
|
||||||
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
let db = Self { conn: Mutex::new(conn) };
|
let db = Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
};
|
||||||
db.run_migrations()?;
|
db.run_migrations()?;
|
||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +61,7 @@ impl Database {
|
||||||
quantity INTEGER NOT NULL DEFAULT 0
|
quantity INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_characters_user ON characters(user_id);
|
CREATE INDEX IF NOT EXISTS idx_characters_user ON characters(user_id);
|
||||||
"
|
",
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -114,10 +116,15 @@ impl Database {
|
||||||
let inventory_json: String = row.get(22)?;
|
let inventory_json: String = row.get(22)?;
|
||||||
let known_spells_json: String = row.get(24)?;
|
let known_spells_json: String = row.get(24)?;
|
||||||
|
|
||||||
let equipment_raw: HashMap<String, String> = serde_json::from_str(&equipment_json).unwrap_or_default();
|
let equipment_raw: HashMap<String, String> =
|
||||||
let equipment: HashMap<String, Option<String>> = equipment_raw.into_iter().map(|(k, v)| (k, Some(v))).collect();
|
serde_json::from_str(&equipment_json).unwrap_or_default();
|
||||||
|
let equipment: HashMap<String, Option<String>> = equipment_raw
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k, Some(v)))
|
||||||
|
.collect();
|
||||||
let inventory: Vec<String> = serde_json::from_str(&inventory_json).unwrap_or_default();
|
let inventory: Vec<String> = serde_json::from_str(&inventory_json).unwrap_or_default();
|
||||||
let known_spells: Vec<String> = serde_json::from_str(&known_spells_json).unwrap_or_default();
|
let known_spells: Vec<String> =
|
||||||
|
serde_json::from_str(&known_spells_json).unwrap_or_default();
|
||||||
|
|
||||||
let race_str: String = row.get(3)?;
|
let race_str: String = row.get(3)?;
|
||||||
let class_str: String = row.get(4)?;
|
let class_str: String = row.get(4)?;
|
||||||
|
|
@ -177,15 +184,32 @@ impl Database {
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
|
||||||
?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27)",
|
?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
char.id, char.user_id, char.name,
|
char.id,
|
||||||
char.race.as_str(), char.class.as_str(),
|
char.user_id,
|
||||||
char.level, char.xp, char.gold,
|
char.name,
|
||||||
char.hp, char.max_hp, char.energy, char.max_energy,
|
char.race.as_str(),
|
||||||
char.nerve, char.max_nerve, char.spell_points, char.max_spell_points,
|
char.class.as_str(),
|
||||||
char.stats.strength, char.stats.intelligence, char.stats.dexterity,
|
char.level,
|
||||||
char.stats.constitution, char.stats.luck,
|
char.xp,
|
||||||
equipment_json, inventory_json,
|
char.gold,
|
||||||
char.active_song, known_spells_json, char.active_spell,
|
char.hp,
|
||||||
|
char.max_hp,
|
||||||
|
char.energy,
|
||||||
|
char.max_energy,
|
||||||
|
char.nerve,
|
||||||
|
char.max_nerve,
|
||||||
|
char.spell_points,
|
||||||
|
char.max_spell_points,
|
||||||
|
char.stats.strength,
|
||||||
|
char.stats.intelligence,
|
||||||
|
char.stats.dexterity,
|
||||||
|
char.stats.constitution,
|
||||||
|
char.stats.luck,
|
||||||
|
equipment_json,
|
||||||
|
inventory_json,
|
||||||
|
char.active_song,
|
||||||
|
known_spells_json,
|
||||||
|
char.active_spell,
|
||||||
char.last_regen,
|
char.last_regen,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -217,11 +241,9 @@ impl Database {
|
||||||
|
|
||||||
pub fn next_character_id(&self) -> Result<u32, rusqlite::Error> {
|
pub fn next_character_id(&self) -> Result<u32, rusqlite::Error> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let id: u32 = conn.query_row(
|
let id: u32 = conn.query_row("SELECT COALESCE(MAX(id), 0) FROM characters", [], |row| {
|
||||||
"SELECT COALESCE(MAX(id), 0) FROM characters",
|
row.get(0)
|
||||||
[],
|
})?;
|
||||||
|row| row.get(0),
|
|
||||||
)?;
|
|
||||||
Ok(id + 1)
|
Ok(id + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,12 @@ pub struct Character {
|
||||||
pub max_spell_points: i32,
|
pub max_spell_points: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_sp() -> i32 { 0 }
|
fn default_sp() -> i32 {
|
||||||
fn default_max_sp() -> i32 { 0 }
|
0
|
||||||
|
}
|
||||||
|
fn default_max_sp() -> i32 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct CreateCharacter {
|
pub struct CreateCharacter {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use crate::character::Character;
|
use crate::character::Character;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CritEntry {
|
pub struct CritEntry {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue