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:
parent
7a75a523f0
commit
6410e0a86d
39 changed files with 3450 additions and 48 deletions
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue