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

@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid", "migrate"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sb-shared = { path = "../sb-shared" }

View file

@ -1,4 +1,227 @@
#![allow(dead_code)]
use rusqlite::Connection;
use sb_shared::{Character, CharacterStats, Class, Race, User, Role};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
pub mod models;
pub mod queries;
pub struct Database {
conn: Mutex<Connection>,
}
impl Database {
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?;
let db = Self { conn: Mutex::new(conn) };
db.run_migrations()?;
Ok(db)
}
fn run_migrations(&self) -> Result<(), rusqlite::Error> {
let conn = self.conn.lock().unwrap();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
role TEXT NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS characters (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
race TEXT NOT NULL,
class TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 1,
xp INTEGER NOT NULL DEFAULT 0,
gold INTEGER NOT NULL DEFAULT 0,
hp INTEGER NOT NULL DEFAULT 0,
max_hp INTEGER NOT NULL DEFAULT 0,
energy INTEGER NOT NULL DEFAULT 0,
max_energy INTEGER NOT NULL DEFAULT 0,
nerve INTEGER NOT NULL DEFAULT 0,
max_nerve INTEGER NOT NULL DEFAULT 0,
spell_points INTEGER NOT NULL DEFAULT 0,
max_spell_points INTEGER NOT NULL DEFAULT 0,
str_stat INTEGER NOT NULL DEFAULT 10,
int_stat INTEGER NOT NULL DEFAULT 10,
dex_stat INTEGER NOT NULL DEFAULT 10,
con_stat INTEGER NOT NULL DEFAULT 10,
luck_stat INTEGER NOT NULL DEFAULT 10,
equipment TEXT NOT NULL DEFAULT '{}',
inventory TEXT NOT NULL DEFAULT '[]',
active_song TEXT,
known_spells TEXT NOT NULL DEFAULT '[]',
active_spell TEXT,
last_regen INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS shop_stock (
item_id TEXT PRIMARY KEY,
quantity INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_characters_user ON characters(user_id);
"
)?;
Ok(())
}
pub fn load_users(&self) -> Result<Vec<User>, rusqlite::Error> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, name, role, password_hash FROM users")?;
let rows = stmt.query_map([], |row| {
let role_str: String = row.get(2)?;
let role = match role_str.as_str() {
"admin" => Role::Admin,
_ => Role::Player,
};
Ok(User {
id: row.get::<_, u32>(0)?,
name: row.get(1)?,
role,
password_hash: row.get(3)?,
})
})?;
let mut users = Vec::new();
for row in rows {
users.push(row?);
}
Ok(users)
}
pub fn save_user(&self, user: &User) -> Result<(), rusqlite::Error> {
let conn = self.conn.lock().unwrap();
let role_str = match &user.role {
Role::Admin => "admin",
Role::Player => "player",
};
conn.execute(
"INSERT OR REPLACE INTO users (id, name, role, password_hash) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![user.id, user.name, role_str, user.password_hash],
)?;
Ok(())
}
pub fn load_characters(&self) -> Result<Vec<Character>, rusqlite::Error> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, user_id, name, race, class, level, xp, gold, hp, max_hp, energy, max_energy,
nerve, max_nerve, spell_points, max_spell_points,
str_stat, int_stat, dex_stat, con_stat, luck_stat,
equipment, inventory, active_song, known_spells, active_spell, last_regen
FROM characters"
)?;
let rows = stmt.query_map([], |row| {
let equipment_json: String = row.get(21)?;
let inventory_json: String = row.get(22)?;
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: 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 known_spells: Vec<String> = serde_json::from_str(&known_spells_json).unwrap_or_default();
let race_str: String = row.get(3)?;
let class_str: String = row.get(4)?;
let race = Race::from_str(&race_str);
let class = Class::from_str(&class_str);
Ok(Character {
id: row.get::<_, u32>(0)?,
user_id: row.get::<_, u32>(1)?,
name: row.get(2)?,
race,
class,
stats: CharacterStats {
strength: row.get(16)?,
intelligence: row.get(17)?,
dexterity: row.get(18)?,
constitution: row.get(19)?,
luck: row.get(20)?,
},
level: row.get::<_, i32>(5)?,
xp: row.get::<_, i64>(6)?,
gold: row.get::<_, i32>(7)?,
hp: row.get::<_, i32>(8)?,
max_hp: row.get::<_, i32>(9)?,
energy: row.get::<_, i32>(10)?,
max_energy: row.get::<_, i32>(11)?,
nerve: row.get::<_, i32>(12)?,
max_nerve: row.get::<_, i32>(13)?,
spell_points: row.get::<_, i32>(14)?,
max_spell_points: row.get::<_, i32>(15)?,
equipment,
inventory,
active_song: row.get(23)?,
known_spells,
active_spell: row.get(25)?,
last_regen: row.get(26)?,
})
})?;
let mut characters = Vec::new();
for row in rows {
characters.push(row?);
}
Ok(characters)
}
pub fn save_character(&self, char: &Character) -> Result<(), rusqlite::Error> {
let conn = self.conn.lock().unwrap();
let equipment_json = serde_json::to_string(&char.equipment).unwrap_or_default();
let inventory_json = serde_json::to_string(&char.inventory).unwrap_or_default();
let known_spells_json = serde_json::to_string(&char.known_spells).unwrap_or_default();
conn.execute(
"INSERT OR REPLACE INTO characters (id, user_id, name, race, class, level, xp, gold,
hp, max_hp, energy, max_energy, nerve, max_nerve, spell_points, max_spell_points,
str_stat, int_stat, dex_stat, con_stat, luck_stat,
equipment, inventory, active_song, known_spells, active_spell, last_regen)
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)",
rusqlite::params![
char.id, char.user_id, char.name,
char.race.as_str(), char.class.as_str(),
char.level, char.xp, char.gold,
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,
],
)?;
Ok(())
}
pub fn load_shop_stock(&self) -> Result<HashMap<String, u32>, rusqlite::Error> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT item_id, quantity FROM shop_stock")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
})?;
let mut stock = HashMap::new();
for row in rows {
let (id, qty) = row?;
stock.insert(id, qty);
}
Ok(stock)
}
pub fn save_shop_stock(&self, item_id: &str, quantity: u32) -> Result<(), rusqlite::Error> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO shop_stock (item_id, quantity) VALUES (?1, ?2)",
rusqlite::params![item_id, quantity],
)?;
Ok(())
}
pub fn next_character_id(&self) -> Result<u32, rusqlite::Error> {
let conn = self.conn.lock().unwrap();
let id: u32 = conn.query_row(
"SELECT COALESCE(MAX(id), 0) FROM characters",
[],
|row| row.get(0),
)?;
Ok(id + 1)
}
}