From 63c2e2fc3ea299aee31b66d2bdd6ecd871df223a Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 22:44:41 +0100 Subject: v0.3.0: add server core, bcrypt migration and plays audit --- src/server/core.rs | 303 +++++++++++++++++++++++++++++++++++++++++++ src/server/database.rs | 123 ++++++++++++++++-- src/server/games/registry.rs | 22 +--- src/server/games/session.rs | 8 ++ src/server/games/slots.rs | 23 +++- src/server/mod.rs | 1 + 6 files changed, 453 insertions(+), 27 deletions(-) create mode 100644 src/server/core.rs (limited to 'src/server') diff --git a/src/server/core.rs b/src/server/core.rs new file mode 100644 index 0000000..84ad4d2 --- /dev/null +++ b/src/server/core.rs @@ -0,0 +1,303 @@ +use bcrypt::{hash, verify, DEFAULT_COST}; +use rusqlite::Connection; + +use crate::server::database; +use crate::server::games::registry::GameRegistry; +use crate::server::games::session::SessionManager; +use crate::server::games::traits::GameError; + +#[derive(Debug, Default)] +pub struct ConnectionState { + user: Option, +} + +impl ConnectionState { + pub fn user(&self) -> Option<&str> { + self.user.as_deref() + } +} + +pub struct ServerCore { + conn: Connection, + sessions: SessionManager, + registry: GameRegistry, + bcrypt_cost: u32, +} + +impl ServerCore { + pub fn new(conn: Connection) -> Self { + Self::with_registry(conn, GameRegistry::with_defaults(), DEFAULT_COST) + } + + pub fn with_registry( + conn: Connection, + registry: GameRegistry, + bcrypt_cost: u32, + ) -> Self { + Self { + conn, + sessions: SessionManager::new(), + registry, + bcrypt_cost, + } + } + + pub fn open(path: &str) -> rusqlite::Result { + let conn = Connection::open(path)?; + database::init_db(&conn); + Ok(Self::new(conn)) + } + + pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String { + let parts: Vec<&str> = msg.splitn(5, ' ').collect(); + match parts[0] { + "GAME_LIST" => { + let mut games = self.registry.list_games(); + games.sort(); + let info: Vec = games + .iter() + .filter_map(|game_type| { + self.registry + .get_game(game_type) + .map(|game| format!("{}:{}", game_type, game.get_name())) + }) + .collect(); + if info.is_empty() { + "ERROR No games available".to_string() + } else { + format!("GAME_LIST {}", info.join(",")) + } + } + "GAME_INFO" => { + if parts.len() != 2 { + "ERROR Invalid GAME_INFO format. Usage: GAME_INFO " + .to_string() + } else { + match self.registry.get_game(parts[1]) { + Some(game) => format!( + "GAME_INFO {} {} {}", + parts[1], + game.get_name(), + game.get_description() + ), + None => format!("ERROR Unknown game type: {}", parts[1]), + } + } + } + "PLAY_START" => { + if parts.len() != 3 { + "ERROR Invalid PLAY_START format. Usage: PLAY_START " + .to_string() + } else { + self.handle_play_start(state, parts[1], parts[2]) + } + } + "REGISTER" => { + if parts.len() != 3 { + "ERROR Invalid REGISTER format".to_string() + } else { + self.handle_register(parts[1], parts[2]) + } + } + "AUTH" => { + if parts.len() != 3 { + "ERROR Invalid AUTH format".to_string() + } else { + self.handle_auth(state, parts[1], parts[2]) + } + } + "PING" => "PONG".to_string(), + "INFO" => self.handle_info(state), + _ => msg.to_string(), + } + } + + fn handle_register(&mut self, username: &str, credential: &str) -> String { + if database::get_hashed_password(&self.conn, username).is_some() { + return "ERROR User already exists".to_string(); + } + + let hashed = match hash(credential, self.bcrypt_cost) { + Ok(hashed) => hashed, + Err(error) => { + eprintln!("bcrypt failed for {username}: {error}"); + return "ERROR Could not hash password".to_string(); + } + }; + + match database::add_user(&self.conn, username, &hashed) { + Ok(()) => format!("REGISTERED {}", username), + Err(_) => "ERROR User already exists".to_string(), + } + } + + fn handle_auth( + &mut self, + state: &mut ConnectionState, + username: &str, + credential: &str, + ) -> String { + let Some(stored) = database::get_hashed_password(&self.conn, username) else { + return "ERROR User not found".to_string(); + }; + + let valid = if stored.starts_with("$2") { + verify(credential, &stored).unwrap_or(false) + } else if stored == credential { + // Legacy unsalted SHA-256 row: accept once, then upgrade in place. + match hash(credential, self.bcrypt_cost) { + Ok(upgraded) => { + if let Err(error) = + database::set_hashed_password(&self.conn, username, &upgraded) + { + eprintln!("failed to upgrade password for {username}: {error}"); + } + } + Err(error) => eprintln!("bcrypt failed for {username}: {error}"), + } + true + } else { + false + }; + + if valid { + state.user = Some(username.to_string()); + format!("OK {}", username) + } else { + "ERROR Invalid credentials".to_string() + } + } + + fn handle_info(&self, state: &ConnectionState) -> String { + let Some(user) = state.user.as_deref() else { + return "ERROR Not logged in".to_string(); + }; + + match ( + database::get_account_id(&self.conn, user), + database::get_balance(&self.conn, user), + ) { + (Some(id), Some(balance)) => format!("INFO {} {} {}", user, id, balance), + _ => "ERROR Error retrieving user info".to_string(), + } + } + + fn handle_play_start( + &mut self, + state: &ConnectionState, + game_type: &str, + bet_amount_str: &str, + ) -> String { + let Some(user) = state.user.as_deref() else { + return "ERROR Not logged in".to_string(); + }; + + let bet_amount: i64 = match bet_amount_str.parse() { + Ok(amount) => amount, + Err(_) => return "ERROR Invalid bet amount".to_string(), + }; + if bet_amount <= 0 { + return "ERROR Invalid bet amount".to_string(); + } + + let Some(game) = self.registry.get_game(game_type) else { + return format!("ERROR Unknown game type: {}", game_type); + }; + + let Some(balance) = database::get_balance(&self.conn, user) else { + return "ERROR Could not retrieve balance".to_string(); + }; + if balance < 0 { + return "ERROR Invalid balance".to_string(); + } + + if let Err(error) = game.validate_bet(bet_amount as u64, balance as u64) { + return game_error(error); + } + + let game_state = game.prepare_state(user, bet_amount as u64); + let new_state = match game.process_action(&game_state, "spin", &[]) { + Ok(state) => state, + Err(error) => return game_error(error), + }; + let result = match game.calculate_result(&new_state) { + Ok(result) => result, + Err(error) => return game_error(error), + }; + + // The session is the audit guard for the deduction and is created + // before the balance changes. The durable audit row is `plays`. + let session_id = + self.sessions + .create_session(user, game_type.to_string(), new_state.clone()); + + let winnings = match i64::try_from(result.winnings) { + Ok(winnings) => winnings, + Err(_) => { + let _ = self.sessions.remove_session(&session_id); + return "ERROR Winnings out of range".to_string(); + } + }; + + let reels = new_state.data.get("reels").cloned().unwrap_or_default(); + let win_type = new_state + .data + .get("win_type") + .cloned() + .unwrap_or_default(); + + match database::apply_spin( + &mut self.conn, + &session_id, + user, + game_type, + bet_amount, + winnings, + &reels, + &win_type, + ) { + Ok(new_balance) => { + let _ = self.sessions.remove_session(&session_id); + format!( + "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type}" + ) + } + Err(error) => { + let _ = self.sessions.remove_session(&session_id); + eprintln!("Balance transaction failed for {user}: {error}"); + "ERROR Failed to update balance".to_string() + } + } + } + + pub fn disconnect(&mut self, state: &ConnectionState) { + if let Some(user) = state.user.as_deref() { + for session in self.sessions.get_user_sessions(user) { + let _ = self.sessions.remove_session(&session.session_id); + } + } + } + + pub fn balance(&self, username: &str) -> Option { + database::get_balance(&self.conn, username) + } + + pub fn play_count(&self, username: &str) -> i64 { + database::count_plays(&self.conn, username) + } + + pub fn session_count(&self) -> usize { + self.sessions.len() + } +} + +fn game_error(error: GameError) -> String { + match error { + GameError::InsufficientBalance => "ERROR Insufficient balance".to_string(), + GameError::InvalidBet => "ERROR Invalid bet amount".to_string(), + GameError::InvalidAction(message) => format!("ERROR {message}"), + GameError::GameOver => "ERROR Game over".to_string(), + GameError::SessionNotFound => "ERROR Session not found".to_string(), + GameError::InternalError(message) => format!("ERROR {message}"), + } +} diff --git a/src/server/database.rs b/src/server/database.rs index c73aec5..f6c8872 100644 --- a/src/server/database.rs +++ b/src/server/database.rs @@ -1,4 +1,4 @@ -use rusqlite::Connection; +use rusqlite::{params, Connection}; pub fn init_db(conn: &Connection) { conn.execute( @@ -17,6 +17,21 @@ pub fn init_db(conn: &Connection) { "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000", [], ); + conn.execute( + "CREATE TABLE IF NOT EXISTS plays ( + session_id TEXT PRIMARY KEY, + username TEXT NOT NULL, + game_type TEXT NOT NULL, + bet_amount INTEGER NOT NULL, + winnings INTEGER NOT NULL, + reels TEXT NOT NULL DEFAULT '', + win_type TEXT NOT NULL DEFAULT '', + balance_after INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )", + [], + ) + .unwrap(); } pub fn add_user( @@ -24,17 +39,28 @@ pub fn add_user( username: &str, hashed_password: &str, ) -> Result<(), Box> { - loop { + if get_hashed_password(conn, username).is_some() { + return Err("user already exists".into()); + } + + for _ in 0..10 { let account_id: u32 = rand::random::(); match conn.execute( - "INSERT INTO users (username, hashed_password, account_id, balance) VALUES (?1, ?2, ?3, ?4)", - [username, hashed_password, &account_id.to_string(), "1000"], + "INSERT INTO users (username, hashed_password, account_id, balance) + VALUES (?1, ?2, ?3, ?4)", + params![username, hashed_password, account_id as i64, 1000i64], ) { - Ok(_) => break, - Err(_) => continue, // Retry if account_id conflict + Ok(_) => return Ok(()), + Err(rusqlite::Error::SqliteFailure(error, _)) + if error.code == rusqlite::ErrorCode::ConstraintViolation => + { + continue; + } + Err(error) => return Err(Box::new(error)), } } - Ok(()) + + Err("could not allocate a unique account id".into()) } pub fn get_hashed_password(conn: &Connection, username: &str) -> Option { @@ -49,13 +75,25 @@ pub fn get_hashed_password(conn: &Connection, username: &str) -> Option } } -pub fn get_account_id(conn: &Connection, username: &str) -> Option { +pub fn set_hashed_password( + conn: &Connection, + username: &str, + hashed_password: &str, +) -> rusqlite::Result<()> { + conn.execute( + "UPDATE users SET hashed_password = ?1 WHERE username = ?2", + params![hashed_password, username], + )?; + Ok(()) +} + +pub fn get_account_id(conn: &Connection, username: &str) -> Option { let mut stmt = conn .prepare("SELECT account_id FROM users WHERE username = ?1") .unwrap(); let mut rows = stmt.query([username]).unwrap(); if let Ok(Some(row)) = rows.next() { - Some(row.get::<_, u32>(0).ok()?) + Some(row.get::<_, i64>(0).ok()?) } else { None } @@ -72,3 +110,70 @@ pub fn get_balance(conn: &Connection, username: &str) -> Option { None } } + +/// Settle one spin and record its audit row in a single transaction. +/// +/// Returns the balance after the spin. All SQL is parameterized: usernames +/// are data, never part of the statement text. +#[allow(clippy::too_many_arguments)] +pub fn apply_spin( + conn: &mut Connection, + session_id: &str, + username: &str, + game_type: &str, + bet_amount: i64, + winnings: i64, + reels: &str, + win_type: &str, +) -> rusqlite::Result { + let tx = conn.transaction()?; + + let deducted = tx.execute( + "UPDATE users SET balance = balance - ?1 + WHERE username = ?2 AND balance >= ?1", + params![bet_amount, username], + )?; + if deducted != 1 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + + tx.execute( + "UPDATE users SET balance = balance + ?1 WHERE username = ?2", + params![winnings, username], + )?; + + let balance_after: i64 = tx.query_row( + "SELECT balance FROM users WHERE username = ?1", + [username], + |row| row.get(0), + )?; + + tx.execute( + "INSERT INTO plays + (session_id, username, game_type, bet_amount, winnings, reels, + win_type, balance_after) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + session_id, + username, + game_type, + bet_amount, + winnings, + reels, + win_type, + balance_after + ], + )?; + + tx.commit()?; + Ok(balance_after) +} + +pub fn count_plays(conn: &Connection, username: &str) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM plays WHERE username = ?1", + [username], + |row| row.get(0), + ) + .unwrap_or(0) +} diff --git a/src/server/games/registry.rs b/src/server/games/registry.rs index 3b8c391..b7ed9d3 100644 --- a/src/server/games/registry.rs +++ b/src/server/games/registry.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +use super::slots::SlotsGame; use super::traits::Game; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -17,6 +18,12 @@ impl GameRegistry { } } + pub fn with_defaults() -> Self { + let registry = Self::new(); + registry.register_game(SlotsGame::new()); + registry + } + pub fn register_game(&self, game: G) { let game_type = game.game_type().to_string(); let mut games = self.games.lock().unwrap(); @@ -38,18 +45,3 @@ impl GameRegistry { games.contains_key(game_type) } } - -// Global registry instance -use super::slots::SlotsGame; -use once_cell::sync::Lazy; - -static GAME_REGISTRY: Lazy = Lazy::new(|| { - let registry = GameRegistry::new(); - // Register games here - registry.register_game(SlotsGame::new()); - registry -}); - -pub fn get_game_registry() -> &'static GameRegistry { - &GAME_REGISTRY -} diff --git a/src/server/games/session.rs b/src/server/games/session.rs index 97ceaf3..a8410a9 100644 --- a/src/server/games/session.rs +++ b/src/server/games/session.rs @@ -74,4 +74,12 @@ impl SessionManager { .cloned() .collect() } + + pub fn len(&self) -> usize { + self.sessions.lock().unwrap().len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } } diff --git a/src/server/games/slots.rs b/src/server/games/slots.rs index 0a9797d..02595b1 100644 --- a/src/server/games/slots.rs +++ b/src/server/games/slots.rs @@ -5,6 +5,8 @@ use std::collections::HashMap; pub struct SlotsGame { symbols: Vec, payouts: HashMap, + /// Deterministic reels for tests and demos. + fixed_reels: Option>, } impl SlotsGame { @@ -27,10 +29,25 @@ impl SlotsGame { payouts.insert("DD".to_string(), 2); payouts.insert("SS".to_string(), 3); - Self { symbols, payouts } + Self { + symbols, + payouts, + fixed_reels: None, + } + } + + /// Build a slots game that always spins the given three symbols. + pub fn with_reels(reels: &str) -> Self { + let mut game = Self::new(); + game.fixed_reels = Some(reels.chars().take(3).collect()); + game } fn spin_reels(&self) -> Vec { + if let Some(reels) = &self.fixed_reels { + return reels.clone(); + } + let mut rng = rand::thread_rng(); (0..3) .map(|_| self.symbols[rng.gen_range(0..self.symbols.len())]) @@ -41,7 +58,7 @@ impl SlotsGame { let reel_string: String = reels.iter().collect(); if let Some(&multiplier) = self.payouts.get(&reel_string) { - return bet * multiplier; + return bet.saturating_mul(multiplier); } // Check all three pairs including non-adjacent (e.g. [C,D,C]) @@ -51,7 +68,7 @@ impl SlotsGame { if reels[a] == reels[b] { let two_of_kind = format!("{}{}", reels[a], reels[a]); if let Some(&multiplier) = self.payouts.get(&two_of_kind) { - best = best.max(bet * multiplier); + best = best.max(bet.saturating_mul(multiplier)); } } } diff --git a/src/server/mod.rs b/src/server/mod.rs index 5b38158..6e80f78 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,2 +1,3 @@ +pub mod core; pub mod database; pub mod games; -- cgit v1.2.3