Implement auth, persistence, typed responses, and injectable RNG

Auth: JWT tokens (sb-auth) with argon2 password hashing, login/register
endpoints, Bearer token middleware protecting all routes except auth.
Authorization checks ensure users can only access their own characters.

Persistence: SQLite via rusqlite (sb-db) with write-through on all
mutations. Optional via SB_DB_PATH env var; falls back to in-memory.

Typed responses: ~20 response DTOs in sb-shared replacing ad-hoc JSON.

Combat: Injectable RNG via &mut impl Rng for deterministic tests.

Clean Architecture: Combat types and response DTOs in sb-shared; domain
services in sb-core; auth in sb-auth; persistence in sb-db.
This commit is contained in:
Jan-Peter von Hunnius 2026-08-09 16:51:52 +02:00
parent 7a75a523f0
commit 6410e0a86d
39 changed files with 3450 additions and 48 deletions

View file

@ -0,0 +1,142 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CharacterStats {
pub strength: i64,
pub intelligence: i64,
pub dexterity: i64,
pub constitution: i64,
pub luck: i64,
}
impl CharacterStats {
pub fn total(&self) -> i64 {
self.strength + self.intelligence + self.dexterity + self.constitution + self.luck
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Race {
Human,
Elf,
Dwarf,
Hobbit,
HalfElf,
}
impl Race {
pub fn as_str(&self) -> &'static str {
match self {
Race::Human => "human",
Race::Elf => "elf",
Race::Dwarf => "dwarf",
Race::Hobbit => "hobbit",
Race::HalfElf => "half_elf",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s {
"human" => Race::Human,
"elf" => Race::Elf,
"dwarf" => Race::Dwarf,
"hobbit" => Race::Hobbit,
"half_elf" => Race::HalfElf,
_ => Race::Human,
}
}
}
impl Class {
pub fn as_str(&self) -> &'static str {
match self {
Class::Warrior => "warrior",
Class::Paladin => "paladin",
Class::Rogue => "rogue",
Class::Bard => "bard",
Class::Hunter => "hunter",
Class::Monk => "monk",
Class::Mage => "mage",
Class::Wizard => "wizard",
Class::Conjurer => "conjurer",
Class::Sorcerer => "sorcerer",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s {
"warrior" => Class::Warrior,
"paladin" => Class::Paladin,
"rogue" => Class::Rogue,
"bard" => Class::Bard,
"hunter" => Class::Hunter,
"monk" => Class::Monk,
"mage" => Class::Mage,
"wizard" => Class::Wizard,
"conjurer" => Class::Conjurer,
"sorcerer" => Class::Sorcerer,
_ => Class::Warrior,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Class {
Warrior,
Paladin,
Rogue,
Bard,
Hunter,
Monk,
Mage,
Wizard,
Conjurer,
Sorcerer,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Character {
pub id: u32,
pub user_id: u32,
pub name: String,
pub race: Race,
pub class: Class,
pub level: i32,
pub xp: i64,
pub stats: CharacterStats,
pub hp: i32,
pub max_hp: i32,
pub energy: i32,
pub max_energy: i32,
pub nerve: i32,
pub max_nerve: i32,
pub gold: i32,
pub last_regen: i64,
pub equipment: HashMap<String, Option<String>>,
pub inventory: Vec<String>,
#[serde(default)]
pub active_song: Option<String>,
#[serde(default)]
pub known_spells: Vec<String>,
#[serde(default)]
pub active_spell: Option<String>,
#[serde(default = "default_sp")]
pub spell_points: i32,
#[serde(default = "default_max_sp")]
pub max_spell_points: i32,
}
fn default_sp() -> i32 { 0 }
fn default_max_sp() -> i32 { 0 }
#[derive(Debug, Clone, Deserialize)]
pub struct CreateCharacter {
pub name: String,
pub race: Race,
pub class: Class,
}

View file

@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Item {
pub id: String,
pub name: String,
pub r#type: ItemType,
pub quality: Quality,
pub slot: String,
pub stats: HashMap<String, i64>,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub attack_type: Option<AttackType>,
pub value: i32,
#[serde(default)]
pub always_in_shop: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ItemType {
Weapon,
Armor,
Accessory,
Consumable,
Material,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AttackType {
Slash,
Crush,
Puncture,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum Quality {
Common,
Uncommon,
Rare,
Epic,
Legendary,
}

View file

@ -1,3 +1,10 @@
pub mod character;
pub mod errors;
pub mod item;
pub mod responses;
pub mod user;
pub use errors::AppError;
pub use character::{Character, CharacterStats, Class, CreateCharacter, Race};
pub use item::{AttackType, Item, ItemType, Quality};
pub use responses::*;
pub use user::{Role, User};

View file

@ -0,0 +1,201 @@
use serde::{Deserialize, Serialize};
use crate::character::Character;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CritEntry {
pub name: String,
pub damage_multiplier: f64,
pub stun_rounds: u8,
pub bleed_per_round: i32,
pub bleed_rounds: u8,
pub effects: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CritResult {
pub severity: String,
pub crit: CritEntry,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CombatRound {
pub attacker: String,
pub defender: String,
pub raw_roll: i32,
pub ob: i32,
pub db: i32,
pub result: i32,
pub damage: i32,
pub crit: Option<CritResult>,
pub defender_hp: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CombatLog {
pub rounds: Vec<CombatRound>,
pub winner: String,
pub loser: String,
pub xp_gained: i32,
pub gold_gained: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiInfoResponse {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RacesResponse {
pub races: Vec<RaceInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RaceInfo {
pub race: String,
pub name: String,
pub str_mod: i64,
pub int_mod: i64,
pub dex_mod: i64,
pub con_mod: i64,
pub luck_mod: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassesResponse {
pub classes: Vec<ClassInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassInfo {
pub class: String,
pub name: String,
pub primary_stat: String,
pub secondary_stat: String,
pub base_hp: i32,
pub hp_per_con: f64,
pub is_caster: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnemiesResponse {
pub enemies: Vec<EnemySummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnemySummary {
pub id: String,
pub name: String,
pub level: i32,
pub hp: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdventuresResponse {
pub zones: Vec<ZoneSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneSummary {
pub id: String,
pub name: String,
pub description: String,
pub min_level: i32,
pub nerve_cost: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GymResponse {
pub energy: i32,
pub max_energy: i32,
pub gold: i32,
pub stats: Vec<TrainingStatResponse>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingStatResponse {
pub stat: String,
pub name: String,
pub description: String,
pub value: i64,
pub cost: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainStatResponse {
pub success: bool,
pub message: String,
pub character: Option<Character>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShopResponse {
pub gold: i32,
pub inventory: Vec<InventoryItemResponse>,
pub shop_stock: Vec<ShopItemResponse>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryItemResponse {
pub id: String,
pub name: String,
pub item_type: String,
pub quality: String,
pub slot: Option<String>,
pub stats: std::collections::HashMap<String, i64>,
pub description: String,
pub attack_type: Option<String>,
pub value: i32,
pub equipped: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShopItemResponse {
pub id: String,
pub name: String,
pub item_type: String,
pub quality: String,
pub slot: Option<String>,
pub stats: std::collections::HashMap<String, i64>,
pub description: String,
pub attack_type: Option<String>,
pub value: i32,
pub stock: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShopActionResponse {
pub success: bool,
pub message: String,
pub gold: i32,
pub inventory: Vec<InventoryItemResponse>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SongsResponse {
pub active_song: Option<String>,
pub available_songs: Vec<SongSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SongSummary {
pub id: String,
pub name: String,
pub description: String,
pub required_level: i32,
pub combat_effect: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetSongResponse {
pub success: bool,
pub message: String,
pub active_song: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LevelUpResponse {
pub success: bool,
pub message: String,
pub character: Option<Character>,
}

View file

@ -0,0 +1,17 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: u32,
pub name: String,
pub role: Role,
#[serde(skip_serializing)]
pub password_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Admin,
Player,
}