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 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 src/server/core.rs (limited to 'src/server/core.rs') 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}"), + } +} -- cgit v1.2.3