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 --- Cargo.toml | 2 +- src/bin/server/handler.rs | 252 ++++------------------------------- 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 + tests/server_core.rs | 246 +++++++++++++++++++++++++++++++++++ tests/slots.rs | 7 + 10 files changed, 734 insertions(+), 253 deletions(-) create mode 100644 src/server/core.rs create mode 100644 tests/server_core.rs diff --git a/Cargo.toml b/Cargo.toml index 359d16f..6fd0b3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "0.2.6" +version = "0.3.0" edition = "2024" [dependencies] diff --git a/src/bin/server/handler.rs b/src/bin/server/handler.rs index 06ed5cb..86b09cb 100644 --- a/src/bin/server/handler.rs +++ b/src/bin/server/handler.rs @@ -1,228 +1,34 @@ -use zagan_bet::server::database; -use zagan_bet::server::games::registry::get_game_registry; -use zagan_bet::server::games::session::SessionManager; -use zagan_bet::server::games::traits::GameError; use crate::tls::TlsStream; -use once_cell::sync::Lazy; -use parking_lot::Mutex; -use rusqlite::Connection; -use std::io::{BufRead, BufReader, Write}; -use std::net::TcpStream; +use zagan_bet::server::core::{ConnectionState, ServerCore}; -static DB_CONN: Lazy> = Lazy::new(|| { - let conn = Connection::open( - std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string()), - ) - .unwrap(); - database::init_db(&conn); - Mutex::new(conn) -}); - -static SESSIONS: Lazy = Lazy::new(SessionManager::new); - -pub fn handle_client(stream: TcpStream) { - let client_addr = stream.peer_addr().unwrap().to_string(); - let mut user = "guest".to_string(); - println!("[{} @ {}] Client connected", user, client_addr); - let reader_stream = stream.try_clone().unwrap(); - let reader = BufReader::new(reader_stream); - let mut writer = stream; - for line in reader.lines() { - let line = line.unwrap(); - println!("[{} @ {}] Received: {}", user, client_addr, line); - let response = process_message(&line, &mut user); - writeln!(writer, "{}", response).unwrap(); - println!("[{} @ {}] Sent: {}", user, client_addr, response); - } - println!("[{} @ {}] Client disconnected", user, client_addr); -} - -fn process_message(msg: &str, user: &mut String) -> String { - let parts: Vec<&str> = msg.splitn(5, ' ').collect(); - match parts[0] { - "GAME_LIST" => { - let registry = get_game_registry(); - let games: Vec = registry.list_games(); - if games.is_empty() { - "ERROR No games available".to_string() - } else { - let game_info: Vec = games - .iter() - .filter_map(|gt| { - registry - .get_game(gt) - .map(|g| format!("{}:{}", gt, g.get_name())) - }) - .collect(); - format!("GAME_LIST {}", game_info.join(",")) - } - } - "GAME_INFO" => { - if parts.len() != 2 { - "ERROR Invalid GAME_INFO format. Usage: GAME_INFO ".to_string() - } else { - let registry = get_game_registry(); - match 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 { - handle_play_start(user, parts[1], parts[2]) - } - } - "REGISTER" => { - if parts.len() != 3 { - "ERROR Invalid REGISTER format".to_string() - } else { - let username = parts[1]; - let hashed_password = parts[2]; - let db = DB_CONN.lock(); - match database::add_user(&db, username, hashed_password) { - Ok(_) => format!("REGISTERED {}", username), - Err(_) => "ERROR User already exists".to_string(), - } - } - } - "AUTH" => { - if parts.len() != 3 { - "ERROR Invalid AUTH format".to_string() - } else { - let username = parts[1]; - let sent_hash = parts[2]; - let db = DB_CONN.lock(); - if let Some(stored_hash) = database::get_hashed_password(&db, username) { - if sent_hash == stored_hash { - *user = username.to_string(); - format!("OK {}", username) - } else { - "ERROR Invalid credentials".to_string() - } - } else { - "ERROR User not found".to_string() - } - } - } - "PING" => "PONG".to_string(), - "INFO" => { - if *user == "guest" { - "ERROR Not logged in".to_string() - } else { - let db = DB_CONN.lock(); - if let (Some(id), Some(balance)) = ( - database::get_account_id(&db, user), - database::get_balance(&db, user), - ) { - format!("INFO {} {} {}", user, id, balance) - } else { - "ERROR Error retrieving user info".to_string() - } - } +pub fn handle_client_tls(mut stream: TlsStream) { + let client_addr = match stream.peer_addr() { + Ok(addr) => addr.to_string(), + Err(error) => { + eprintln!("Could not get peer address: {error}"); + return; } - _ => msg.to_string(), - } -} - -fn handle_play_start(user: &str, game_type: &str, bet_amount_str: &str) -> String { - if user == "guest" { - 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 registry = get_game_registry(); - let game = match registry.get_game(game_type) { - Some(game) => game, - None => return format!("ERROR Unknown game type: {}", game_type), - }; - - let db = DB_CONN.lock(); - - // Validate balance inside a read. - let current_balance = match database::get_balance(&db, user) { - Some(balance) => balance, - None => return "ERROR Could not retrieve balance".to_string(), - }; - - if let Err(e) = game.validate_bet(bet_amount as u64, current_balance as u64) { - return match e { - GameError::InsufficientBalance => "ERROR Insufficient balance".to_string(), - GameError::InvalidBet => "ERROR Invalid bet amount".to_string(), - _ => "ERROR Bet validation failed".to_string(), - }; - } - - // Prepare and spin. - 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(e) => return format!("ERROR Failed to spin: {:?}", e), - }; - - let result = match game.calculate_result(&new_state) { - Ok(result) => result, - Err(e) => return format!("ERROR Failed to calculate result: {:?}", e), - }; - - // Create session before touching balance. - let session_id = SESSIONS.create_session(user, game_type.to_string(), new_state.clone()); - - // All balance mutations in a single transaction. - let total_return = result.winnings as i64; - let tx_result = db.execute_batch(&format!( - "UPDATE users SET balance = balance - {bet_amount} WHERE username = '{user}'; - UPDATE users SET balance = balance + {total_return} WHERE username = '{user}';" - )); - - if let Err(e) = tx_result { - let _ = SESSIONS.remove_session(&session_id); - eprintln!("Balance transaction failed for {user}: {e}"); - return "ERROR Failed to update balance".to_string(); - } - - let new_balance = match database::get_balance(&db, user) { - Some(balance) => balance, - None => return "ERROR Could not retrieve new balance".to_string(), + let db_path = std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string()); + let mut core = match ServerCore::open(&db_path) { + Ok(core) => core, + Err(error) => { + eprintln!("Failed to open database {db_path}: {error}"); + return; + } }; - let reels = new_state.data.get("reels").cloned().unwrap_or_default(); - let win_type = new_state.data.get("win_type").cloned().unwrap_or_default(); - - format!( - "GAME_RESULT {session_id} {} {} {new_balance} {reels} {win_type}", - result.winnings, result.bet_amount, - ) -} - -pub fn handle_client_tls(mut stream: TlsStream) { - let client_addr = stream.peer_addr().unwrap().to_string(); - let mut user = "guest".to_string(); - println!("[{} @ {}] TLS Client connected", user, client_addr); + let mut state = ConnectionState::default(); + println!("[TLS Client connected] {client_addr}"); loop { let mut line = String::new(); match stream.read_line(&mut line) { Ok(0) => break, Ok(_) => {} - Err(e) => { - eprintln!("Read error: {}", e); + Err(error) => { + eprintln!("Read error: {error}"); break; } } @@ -232,21 +38,17 @@ pub fn handle_client_tls(mut stream: TlsStream) { continue; } - println!("[{} @ {}] Received: {}", user, client_addr, line); - let response = process_message(line, &mut user); - if let Err(e) = stream.write_line(&response) { - eprintln!("Write error: {}", e); + let user = state.user().unwrap_or("guest").to_string(); + println!("[{user} @ {client_addr}] Received: {line}"); + let response = core.handle_message(&mut state, line); + if let Err(error) = stream.write_line(&response) { + eprintln!("Write error: {error}"); break; } - println!("[{} @ {}] Sent: {}", user, client_addr, response); - } - - // Clean up any active game sessions for this user on disconnect. - if user != "guest" { - for session in SESSIONS.get_user_sessions(&user) { - let _ = SESSIONS.remove_session(&session.session_id); - } + let user = state.user().unwrap_or("guest"); + println!("[{user} @ {client_addr}] Sent: {response}"); } - println!("[{} @ {}] TLS Client disconnected", user, client_addr); + core.disconnect(&state); + println!("[TLS Client disconnected] {client_addr}"); } 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; diff --git a/tests/server_core.rs b/tests/server_core.rs new file mode 100644 index 0000000..fe2b3d3 --- /dev/null +++ b/tests/server_core.rs @@ -0,0 +1,246 @@ +use std::path::PathBuf; + +use rusqlite::Connection; +use zagan_bet::server::core::{ConnectionState, ServerCore}; +use zagan_bet::server::database; +use zagan_bet::server::games::registry::GameRegistry; +use zagan_bet::server::games::slots::SlotsGame; + +fn setup(name: &str, reels: &str) -> (ServerCore, String) { + let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(format!("server-core-{name}.db")) + .to_string_lossy() + .into_owned(); + let _ = std::fs::remove_file(&path); + + let conn = Connection::open(&path).unwrap(); + database::init_db(&conn); + + let registry = GameRegistry::new(); + registry.register_game(SlotsGame::with_reels(reels)); + + // Low bcrypt cost keeps the tests fast. + (ServerCore::with_registry(conn, registry, 4), path) +} + +fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { + assert_eq!( + core.handle_message(state, &format!("REGISTER {user} credential")), + format!("REGISTERED {user}") + ); +} + +fn login(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { + assert_eq!( + core.handle_message(state, &format!("AUTH {user} credential")), + format!("OK {user}") + ); +} + +#[test] +fn register_stores_a_bcrypt_hash_and_auth_accepts_it() { + let (mut core, path) = setup("register", "CLO"); + let mut state = ConnectionState::default(); + + register(&mut core, &mut state, "alice"); + + let conn = Connection::open(&path).unwrap(); + let stored = database::get_hashed_password(&conn, "alice").unwrap(); + assert!(stored.starts_with("$2"), "not a bcrypt hash: {stored}"); + assert_ne!(stored, "credential"); + assert!(bcrypt::verify("credential", &stored).unwrap()); + + assert_eq!( + core.handle_message(&mut state, "AUTH alice credential"), + "OK alice" + ); + assert_eq!(state.user(), Some("alice")); +} + +#[test] +fn the_same_credential_gets_a_different_salt() { + let (mut core, path) = setup("salt", "CLO"); + let mut state = ConnectionState::default(); + + register(&mut core, &mut state, "alice"); + register(&mut core, &mut state, "bob"); + + let conn = Connection::open(&path).unwrap(); + let alice = database::get_hashed_password(&conn, "alice").unwrap(); + let bob = database::get_hashed_password(&conn, "bob").unwrap(); + assert_ne!(alice, bob); +} + +#[test] +fn legacy_sha256_rows_are_upgraded_on_login() { + let (mut core, path) = setup("legacy", "CLO"); + let conn = Connection::open(&path).unwrap(); + database::add_user(&conn, "legacy", "deadbeef").unwrap(); + + let mut state = ConnectionState::default(); + assert_eq!( + core.handle_message(&mut state, "AUTH legacy deadbeef"), + "OK legacy" + ); + + let stored = database::get_hashed_password(&conn, "legacy").unwrap(); + assert!(stored.starts_with("$2"), "legacy hash was not upgraded"); + assert!(bcrypt::verify("deadbeef", &stored).unwrap()); + + let mut other = ConnectionState::default(); + assert_eq!( + core.handle_message(&mut other, "AUTH legacy wrong"), + "ERROR Invalid credentials" + ); +} + +#[test] +fn duplicate_registration_returns_an_error() { + let (mut core, _path) = setup("duplicate", "CLO"); + let mut state = ConnectionState::default(); + + register(&mut core, &mut state, "alice"); + assert_eq!( + core.handle_message(&mut state, "REGISTER alice credential"), + "ERROR User already exists" + ); +} + +#[test] +fn a_malicious_username_cannot_change_another_balance() { + let (mut core, _path) = setup("injection", "CLO"); + let mut admin = ConnectionState::default(); + register(&mut core, &mut admin, "victim"); + + // No spaces, so it survives the three-part REGISTER/AUTH parser. + let evil = "x';UPDATE/**/users/**/SET/**/balance=999999;--"; + register(&mut core, &mut admin, evil); + + let mut evil_state = ConnectionState::default(); + login(&mut core, &mut evil_state, evil); + + let response = core.handle_message(&mut evil_state, "PLAY_START slots 10"); + assert!( + response.starts_with("GAME_RESULT"), + "spin failed: {response}" + ); + + assert_eq!(core.balance("victim"), Some(1000)); + assert_eq!(core.balance(evil), Some(990)); + assert_eq!(core.play_count(evil), 1); +} + +#[test] +fn a_failed_audit_insert_rolls_back_the_balance_change() { + let (mut core, path) = setup("rollback", "CLO"); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TRIGGER fail_plays BEFORE INSERT ON plays + BEGIN SELECT RAISE(ABORT, 'boom'); END;", + ) + .unwrap(); + + assert_eq!( + core.handle_message(&mut state, "PLAY_START slots 10"), + "ERROR Failed to update balance" + ); + assert_eq!(core.balance("alice"), Some(1000)); + assert_eq!(core.play_count("alice"), 0); + assert_eq!(core.session_count(), 0); +} + +#[test] +fn spins_write_audit_rows_and_leave_no_sessions() { + let (mut core, _path) = setup("audit", "CLO"); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + for _ in 0..3 { + let response = core.handle_message(&mut state, "PLAY_START slots 10"); + assert!( + response.starts_with("GAME_RESULT"), + "spin failed: {response}" + ); + assert_eq!(core.session_count(), 0); + } + + assert_eq!(core.balance("alice"), Some(970)); + assert_eq!(core.play_count("alice"), 3); +} + +#[test] +fn a_winning_spin_updates_the_balance_and_the_audit_row() { + let (mut core, _path) = setup("win", "CCC"); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let response = core.handle_message(&mut state, "PLAY_START slots 10"); + assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}"); + assert!(response.contains(" 20 10 1010 "), "response: {response}"); + + assert_eq!(core.balance("alice"), Some(1010)); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); +} + +#[test] +fn insufficient_balance_does_not_write_an_audit_row() { + let (mut core, path) = setup("insufficient", "CLO"); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let conn = Connection::open(&path).unwrap(); + conn.execute("UPDATE users SET balance = 5 WHERE username = 'alice'", []) + .unwrap(); + + assert_eq!( + core.handle_message(&mut state, "PLAY_START slots 10"), + "ERROR Insufficient balance" + ); + assert_eq!(core.balance("alice"), Some(5)); + assert_eq!(core.play_count("alice"), 0); + assert_eq!(core.session_count(), 0); +} + +#[test] +fn a_negative_balance_is_rejected_without_wrapping() { + let (mut core, path) = setup("negative", "CLO"); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let conn = Connection::open(&path).unwrap(); + conn.execute( + "UPDATE users SET balance = -5 WHERE username = 'alice'", + [], + ) + .unwrap(); + + assert_eq!( + core.handle_message(&mut state, "PLAY_START slots 10"), + "ERROR Invalid balance" + ); + assert_eq!(core.balance("alice"), Some(-5)); + assert_eq!(core.play_count("alice"), 0); +} + +#[test] +fn unknown_games_are_rejected_before_a_session_is_created() { + let (mut core, _path) = setup("unknown", "CLO"); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + assert_eq!( + core.handle_message(&mut state, "PLAY_START nope 10"), + "ERROR Unknown game type: nope" + ); + assert_eq!(core.session_count(), 0); +} diff --git a/tests/slots.rs b/tests/slots.rs index 12348c9..df4af28 100644 --- a/tests/slots.rs +++ b/tests/slots.rs @@ -48,6 +48,13 @@ fn no_match_returns_zero() { assert_eq!(game.calculate_payout(&['G', 'D', 'S'], 50), 0); } +#[test] +fn an_enormous_bet_saturates_instead_of_overflowing() { + let game = make_game(); + assert_eq!(game.calculate_payout(&['S', 'S', 'S'], u64::MAX), u64::MAX); + assert_eq!(game.calculate_payout(&['S', 'L', 'S'], u64::MAX), u64::MAX); +} + /// process_action produces a finished state with correct reels and win_type. #[test] fn process_action_spin_sets_finished() { -- cgit v1.2.3