diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/server/handler.rs | 252 | ||||
| -rw-r--r-- | src/server/core.rs | 303 | ||||
| -rw-r--r-- | src/server/database.rs | 123 | ||||
| -rw-r--r-- | src/server/games/registry.rs | 22 | ||||
| -rw-r--r-- | src/server/games/session.rs | 8 | ||||
| -rw-r--r-- | src/server/games/slots.rs | 23 | ||||
| -rw-r--r-- | src/server/mod.rs | 1 |
7 files changed, 480 insertions, 252 deletions
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 @@ | |||
| 1 | use zagan_bet::server::database; | ||
| 2 | use zagan_bet::server::games::registry::get_game_registry; | ||
| 3 | use zagan_bet::server::games::session::SessionManager; | ||
| 4 | use zagan_bet::server::games::traits::GameError; | ||
| 5 | use crate::tls::TlsStream; | 1 | use crate::tls::TlsStream; |
| 6 | use once_cell::sync::Lazy; | 2 | use zagan_bet::server::core::{ConnectionState, ServerCore}; |
| 7 | use parking_lot::Mutex; | ||
| 8 | use rusqlite::Connection; | ||
| 9 | use std::io::{BufRead, BufReader, Write}; | ||
| 10 | use std::net::TcpStream; | ||
| 11 | 3 | ||
| 12 | static DB_CONN: Lazy<Mutex<Connection>> = Lazy::new(|| { | 4 | pub fn handle_client_tls(mut stream: TlsStream) { |
| 13 | let conn = Connection::open( | 5 | let client_addr = match stream.peer_addr() { |
| 14 | std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string()), | 6 | Ok(addr) => addr.to_string(), |
| 15 | ) | 7 | Err(error) => { |
| 16 | .unwrap(); | 8 | eprintln!("Could not get peer address: {error}"); |
| 17 | database::init_db(&conn); | 9 | return; |
| 18 | Mutex::new(conn) | ||
| 19 | }); | ||
| 20 | |||
| 21 | static SESSIONS: Lazy<SessionManager> = Lazy::new(SessionManager::new); | ||
| 22 | |||
| 23 | pub fn handle_client(stream: TcpStream) { | ||
| 24 | let client_addr = stream.peer_addr().unwrap().to_string(); | ||
| 25 | let mut user = "guest".to_string(); | ||
| 26 | println!("[{} @ {}] Client connected", user, client_addr); | ||
| 27 | let reader_stream = stream.try_clone().unwrap(); | ||
| 28 | let reader = BufReader::new(reader_stream); | ||
| 29 | let mut writer = stream; | ||
| 30 | for line in reader.lines() { | ||
| 31 | let line = line.unwrap(); | ||
| 32 | println!("[{} @ {}] Received: {}", user, client_addr, line); | ||
| 33 | let response = process_message(&line, &mut user); | ||
| 34 | writeln!(writer, "{}", response).unwrap(); | ||
| 35 | println!("[{} @ {}] Sent: {}", user, client_addr, response); | ||
| 36 | } | ||
| 37 | println!("[{} @ {}] Client disconnected", user, client_addr); | ||
| 38 | } | ||
| 39 | |||
| 40 | fn process_message(msg: &str, user: &mut String) -> String { | ||
| 41 | let parts: Vec<&str> = msg.splitn(5, ' ').collect(); | ||
| 42 | match parts[0] { | ||
| 43 | "GAME_LIST" => { | ||
| 44 | let registry = get_game_registry(); | ||
| 45 | let games: Vec<String> = registry.list_games(); | ||
| 46 | if games.is_empty() { | ||
| 47 | "ERROR No games available".to_string() | ||
| 48 | } else { | ||
| 49 | let game_info: Vec<String> = games | ||
| 50 | .iter() | ||
| 51 | .filter_map(|gt| { | ||
| 52 | registry | ||
| 53 | .get_game(gt) | ||
| 54 | .map(|g| format!("{}:{}", gt, g.get_name())) | ||
| 55 | }) | ||
| 56 | .collect(); | ||
| 57 | format!("GAME_LIST {}", game_info.join(",")) | ||
| 58 | } | ||
| 59 | } | ||
| 60 | "GAME_INFO" => { | ||
| 61 | if parts.len() != 2 { | ||
| 62 | "ERROR Invalid GAME_INFO format. Usage: GAME_INFO <game_type>".to_string() | ||
| 63 | } else { | ||
| 64 | let registry = get_game_registry(); | ||
| 65 | match registry.get_game(parts[1]) { | ||
| 66 | Some(game) => format!( | ||
| 67 | "GAME_INFO {} {} {}", | ||
| 68 | parts[1], | ||
| 69 | game.get_name(), | ||
| 70 | game.get_description() | ||
| 71 | ), | ||
| 72 | None => format!("ERROR Unknown game type: {}", parts[1]), | ||
| 73 | } | ||
| 74 | } | ||
| 75 | } | ||
| 76 | "PLAY_START" => { | ||
| 77 | if parts.len() != 3 { | ||
| 78 | "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount>" | ||
| 79 | .to_string() | ||
| 80 | } else { | ||
| 81 | handle_play_start(user, parts[1], parts[2]) | ||
| 82 | } | ||
| 83 | } | ||
| 84 | "REGISTER" => { | ||
| 85 | if parts.len() != 3 { | ||
| 86 | "ERROR Invalid REGISTER format".to_string() | ||
| 87 | } else { | ||
| 88 | let username = parts[1]; | ||
| 89 | let hashed_password = parts[2]; | ||
| 90 | let db = DB_CONN.lock(); | ||
| 91 | match database::add_user(&db, username, hashed_password) { | ||
| 92 | Ok(_) => format!("REGISTERED {}", username), | ||
| 93 | Err(_) => "ERROR User already exists".to_string(), | ||
| 94 | } | ||
| 95 | } | ||
| 96 | } | ||
| 97 | "AUTH" => { | ||
| 98 | if parts.len() != 3 { | ||
| 99 | "ERROR Invalid AUTH format".to_string() | ||
| 100 | } else { | ||
| 101 | let username = parts[1]; | ||
| 102 | let sent_hash = parts[2]; | ||
| 103 | let db = DB_CONN.lock(); | ||
| 104 | if let Some(stored_hash) = database::get_hashed_password(&db, username) { | ||
| 105 | if sent_hash == stored_hash { | ||
| 106 | *user = username.to_string(); | ||
| 107 | format!("OK {}", username) | ||
| 108 | } else { | ||
| 109 | "ERROR Invalid credentials".to_string() | ||
| 110 | } | ||
| 111 | } else { | ||
| 112 | "ERROR User not found".to_string() | ||
| 113 | } | ||
| 114 | } | ||
| 115 | } | ||
| 116 | "PING" => "PONG".to_string(), | ||
| 117 | "INFO" => { | ||
| 118 | if *user == "guest" { | ||
| 119 | "ERROR Not logged in".to_string() | ||
| 120 | } else { | ||
| 121 | let db = DB_CONN.lock(); | ||
| 122 | if let (Some(id), Some(balance)) = ( | ||
| 123 | database::get_account_id(&db, user), | ||
| 124 | database::get_balance(&db, user), | ||
| 125 | ) { | ||
| 126 | format!("INFO {} {} {}", user, id, balance) | ||
| 127 | } else { | ||
| 128 | "ERROR Error retrieving user info".to_string() | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | 10 | } |
| 132 | _ => msg.to_string(), | ||
| 133 | } | ||
| 134 | } | ||
| 135 | |||
| 136 | fn handle_play_start(user: &str, game_type: &str, bet_amount_str: &str) -> String { | ||
| 137 | if user == "guest" { | ||
| 138 | return "ERROR Not logged in".to_string(); | ||
| 139 | } | ||
| 140 | |||
| 141 | let bet_amount: i64 = match bet_amount_str.parse() { | ||
| 142 | Ok(amount) => amount, | ||
| 143 | Err(_) => return "ERROR Invalid bet amount".to_string(), | ||
| 144 | }; | 11 | }; |
| 145 | if bet_amount <= 0 { | ||
| 146 | return "ERROR Invalid bet amount".to_string(); | ||
| 147 | } | ||
| 148 | 12 | ||
| 149 | let registry = get_game_registry(); | 13 | let db_path = std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string()); |
| 150 | let game = match registry.get_game(game_type) { | 14 | let mut core = match ServerCore::open(&db_path) { |
| 151 | Some(game) => game, | 15 | Ok(core) => core, |
| 152 | None => return format!("ERROR Unknown game type: {}", game_type), | 16 | Err(error) => { |
| 153 | }; | 17 | eprintln!("Failed to open database {db_path}: {error}"); |
| 154 | 18 | return; | |
| 155 | let db = DB_CONN.lock(); | 19 | } |
| 156 | |||
| 157 | // Validate balance inside a read. | ||
| 158 | let current_balance = match database::get_balance(&db, user) { | ||
| 159 | Some(balance) => balance, | ||
| 160 | None => return "ERROR Could not retrieve balance".to_string(), | ||
| 161 | }; | ||
| 162 | |||
| 163 | if let Err(e) = game.validate_bet(bet_amount as u64, current_balance as u64) { | ||
| 164 | return match e { | ||
| 165 | GameError::InsufficientBalance => "ERROR Insufficient balance".to_string(), | ||
| 166 | GameError::InvalidBet => "ERROR Invalid bet amount".to_string(), | ||
| 167 | _ => "ERROR Bet validation failed".to_string(), | ||
| 168 | }; | ||
| 169 | } | ||
| 170 | |||
| 171 | // Prepare and spin. | ||
| 172 | let game_state = game.prepare_state(user, bet_amount as u64); | ||
| 173 | |||
| 174 | let new_state = match game.process_action(&game_state, "spin", &[]) { | ||
| 175 | Ok(state) => state, | ||
| 176 | Err(e) => return format!("ERROR Failed to spin: {:?}", e), | ||
| 177 | }; | ||
| 178 | |||
| 179 | let result = match game.calculate_result(&new_state) { | ||
| 180 | Ok(result) => result, | ||
| 181 | Err(e) => return format!("ERROR Failed to calculate result: {:?}", e), | ||
| 182 | }; | ||
| 183 | |||
| 184 | // Create session before touching balance. | ||
| 185 | let session_id = SESSIONS.create_session(user, game_type.to_string(), new_state.clone()); | ||
| 186 | |||
| 187 | // All balance mutations in a single transaction. | ||
| 188 | let total_return = result.winnings as i64; | ||
| 189 | let tx_result = db.execute_batch(&format!( | ||
| 190 | "UPDATE users SET balance = balance - {bet_amount} WHERE username = '{user}'; | ||
| 191 | UPDATE users SET balance = balance + {total_return} WHERE username = '{user}';" | ||
| 192 | )); | ||
| 193 | |||
| 194 | if let Err(e) = tx_result { | ||
| 195 | let _ = SESSIONS.remove_session(&session_id); | ||
| 196 | eprintln!("Balance transaction failed for {user}: {e}"); | ||
| 197 | return "ERROR Failed to update balance".to_string(); | ||
| 198 | } | ||
| 199 | |||
| 200 | let new_balance = match database::get_balance(&db, user) { | ||
| 201 | Some(balance) => balance, | ||
| 202 | None => return "ERROR Could not retrieve new balance".to_string(), | ||
| 203 | }; | 20 | }; |
| 204 | 21 | ||
| 205 | let reels = new_state.data.get("reels").cloned().unwrap_or_default(); | 22 | let mut state = ConnectionState::default(); |
| 206 | let win_type = new_state.data.get("win_type").cloned().unwrap_or_default(); | 23 | println!("[TLS Client connected] {client_addr}"); |
| 207 | |||
| 208 | format!( | ||
| 209 | "GAME_RESULT {session_id} {} {} {new_balance} {reels} {win_type}", | ||
| 210 | result.winnings, result.bet_amount, | ||
| 211 | ) | ||
| 212 | } | ||
| 213 | |||
| 214 | pub fn handle_client_tls(mut stream: TlsStream) { | ||
| 215 | let client_addr = stream.peer_addr().unwrap().to_string(); | ||
| 216 | let mut user = "guest".to_string(); | ||
| 217 | println!("[{} @ {}] TLS Client connected", user, client_addr); | ||
| 218 | 24 | ||
| 219 | loop { | 25 | loop { |
| 220 | let mut line = String::new(); | 26 | let mut line = String::new(); |
| 221 | match stream.read_line(&mut line) { | 27 | match stream.read_line(&mut line) { |
| 222 | Ok(0) => break, | 28 | Ok(0) => break, |
| 223 | Ok(_) => {} | 29 | Ok(_) => {} |
| 224 | Err(e) => { | 30 | Err(error) => { |
| 225 | eprintln!("Read error: {}", e); | 31 | eprintln!("Read error: {error}"); |
| 226 | break; | 32 | break; |
| 227 | } | 33 | } |
| 228 | } | 34 | } |
| @@ -232,21 +38,17 @@ pub fn handle_client_tls(mut stream: TlsStream) { | |||
| 232 | continue; | 38 | continue; |
| 233 | } | 39 | } |
| 234 | 40 | ||
| 235 | println!("[{} @ {}] Received: {}", user, client_addr, line); | 41 | let user = state.user().unwrap_or("guest").to_string(); |
| 236 | let response = process_message(line, &mut user); | 42 | println!("[{user} @ {client_addr}] Received: {line}"); |
| 237 | if let Err(e) = stream.write_line(&response) { | 43 | let response = core.handle_message(&mut state, line); |
| 238 | eprintln!("Write error: {}", e); | 44 | if let Err(error) = stream.write_line(&response) { |
| 45 | eprintln!("Write error: {error}"); | ||
| 239 | break; | 46 | break; |
| 240 | } | 47 | } |
| 241 | println!("[{} @ {}] Sent: {}", user, client_addr, response); | 48 | let user = state.user().unwrap_or("guest"); |
| 242 | } | 49 | println!("[{user} @ {client_addr}] Sent: {response}"); |
| 243 | |||
| 244 | // Clean up any active game sessions for this user on disconnect. | ||
| 245 | if user != "guest" { | ||
| 246 | for session in SESSIONS.get_user_sessions(&user) { | ||
| 247 | let _ = SESSIONS.remove_session(&session.session_id); | ||
| 248 | } | ||
| 249 | } | 50 | } |
| 250 | 51 | ||
| 251 | println!("[{} @ {}] TLS Client disconnected", user, client_addr); | 52 | core.disconnect(&state); |
| 53 | println!("[TLS Client disconnected] {client_addr}"); | ||
| 252 | } | 54 | } |
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 @@ | |||
| 1 | use bcrypt::{hash, verify, DEFAULT_COST}; | ||
| 2 | use rusqlite::Connection; | ||
| 3 | |||
| 4 | use crate::server::database; | ||
| 5 | use crate::server::games::registry::GameRegistry; | ||
| 6 | use crate::server::games::session::SessionManager; | ||
| 7 | use crate::server::games::traits::GameError; | ||
| 8 | |||
| 9 | #[derive(Debug, Default)] | ||
| 10 | pub struct ConnectionState { | ||
| 11 | user: Option<String>, | ||
| 12 | } | ||
| 13 | |||
| 14 | impl ConnectionState { | ||
| 15 | pub fn user(&self) -> Option<&str> { | ||
| 16 | self.user.as_deref() | ||
| 17 | } | ||
| 18 | } | ||
| 19 | |||
| 20 | pub struct ServerCore { | ||
| 21 | conn: Connection, | ||
| 22 | sessions: SessionManager, | ||
| 23 | registry: GameRegistry, | ||
| 24 | bcrypt_cost: u32, | ||
| 25 | } | ||
| 26 | |||
| 27 | impl ServerCore { | ||
| 28 | pub fn new(conn: Connection) -> Self { | ||
| 29 | Self::with_registry(conn, GameRegistry::with_defaults(), DEFAULT_COST) | ||
| 30 | } | ||
| 31 | |||
| 32 | pub fn with_registry( | ||
| 33 | conn: Connection, | ||
| 34 | registry: GameRegistry, | ||
| 35 | bcrypt_cost: u32, | ||
| 36 | ) -> Self { | ||
| 37 | Self { | ||
| 38 | conn, | ||
| 39 | sessions: SessionManager::new(), | ||
| 40 | registry, | ||
| 41 | bcrypt_cost, | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | pub fn open(path: &str) -> rusqlite::Result<Self> { | ||
| 46 | let conn = Connection::open(path)?; | ||
| 47 | database::init_db(&conn); | ||
| 48 | Ok(Self::new(conn)) | ||
| 49 | } | ||
| 50 | |||
| 51 | pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String { | ||
| 52 | let parts: Vec<&str> = msg.splitn(5, ' ').collect(); | ||
| 53 | match parts[0] { | ||
| 54 | "GAME_LIST" => { | ||
| 55 | let mut games = self.registry.list_games(); | ||
| 56 | games.sort(); | ||
| 57 | let info: Vec<String> = games | ||
| 58 | .iter() | ||
| 59 | .filter_map(|game_type| { | ||
| 60 | self.registry | ||
| 61 | .get_game(game_type) | ||
| 62 | .map(|game| format!("{}:{}", game_type, game.get_name())) | ||
| 63 | }) | ||
| 64 | .collect(); | ||
| 65 | if info.is_empty() { | ||
| 66 | "ERROR No games available".to_string() | ||
| 67 | } else { | ||
| 68 | format!("GAME_LIST {}", info.join(",")) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | "GAME_INFO" => { | ||
| 72 | if parts.len() != 2 { | ||
| 73 | "ERROR Invalid GAME_INFO format. Usage: GAME_INFO <game_type>" | ||
| 74 | .to_string() | ||
| 75 | } else { | ||
| 76 | match self.registry.get_game(parts[1]) { | ||
| 77 | Some(game) => format!( | ||
| 78 | "GAME_INFO {} {} {}", | ||
| 79 | parts[1], | ||
| 80 | game.get_name(), | ||
| 81 | game.get_description() | ||
| 82 | ), | ||
| 83 | None => format!("ERROR Unknown game type: {}", parts[1]), | ||
| 84 | } | ||
| 85 | } | ||
| 86 | } | ||
| 87 | "PLAY_START" => { | ||
| 88 | if parts.len() != 3 { | ||
| 89 | "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount>" | ||
| 90 | .to_string() | ||
| 91 | } else { | ||
| 92 | self.handle_play_start(state, parts[1], parts[2]) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | "REGISTER" => { | ||
| 96 | if parts.len() != 3 { | ||
| 97 | "ERROR Invalid REGISTER format".to_string() | ||
| 98 | } else { | ||
| 99 | self.handle_register(parts[1], parts[2]) | ||
| 100 | } | ||
| 101 | } | ||
| 102 | "AUTH" => { | ||
| 103 | if parts.len() != 3 { | ||
| 104 | "ERROR Invalid AUTH format".to_string() | ||
| 105 | } else { | ||
| 106 | self.handle_auth(state, parts[1], parts[2]) | ||
| 107 | } | ||
| 108 | } | ||
| 109 | "PING" => "PONG".to_string(), | ||
| 110 | "INFO" => self.handle_info(state), | ||
| 111 | _ => msg.to_string(), | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | fn handle_register(&mut self, username: &str, credential: &str) -> String { | ||
| 116 | if database::get_hashed_password(&self.conn, username).is_some() { | ||
| 117 | return "ERROR User already exists".to_string(); | ||
| 118 | } | ||
| 119 | |||
| 120 | let hashed = match hash(credential, self.bcrypt_cost) { | ||
| 121 | Ok(hashed) => hashed, | ||
| 122 | Err(error) => { | ||
| 123 | eprintln!("bcrypt failed for {username}: {error}"); | ||
| 124 | return "ERROR Could not hash password".to_string(); | ||
| 125 | } | ||
| 126 | }; | ||
| 127 | |||
| 128 | match database::add_user(&self.conn, username, &hashed) { | ||
| 129 | Ok(()) => format!("REGISTERED {}", username), | ||
| 130 | Err(_) => "ERROR User already exists".to_string(), | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 134 | fn handle_auth( | ||
| 135 | &mut self, | ||
| 136 | state: &mut ConnectionState, | ||
| 137 | username: &str, | ||
| 138 | credential: &str, | ||
| 139 | ) -> String { | ||
| 140 | let Some(stored) = database::get_hashed_password(&self.conn, username) else { | ||
| 141 | return "ERROR User not found".to_string(); | ||
| 142 | }; | ||
| 143 | |||
| 144 | let valid = if stored.starts_with("$2") { | ||
| 145 | verify(credential, &stored).unwrap_or(false) | ||
| 146 | } else if stored == credential { | ||
| 147 | // Legacy unsalted SHA-256 row: accept once, then upgrade in place. | ||
| 148 | match hash(credential, self.bcrypt_cost) { | ||
| 149 | Ok(upgraded) => { | ||
| 150 | if let Err(error) = | ||
| 151 | database::set_hashed_password(&self.conn, username, &upgraded) | ||
| 152 | { | ||
| 153 | eprintln!("failed to upgrade password for {username}: {error}"); | ||
| 154 | } | ||
| 155 | } | ||
| 156 | Err(error) => eprintln!("bcrypt failed for {username}: {error}"), | ||
| 157 | } | ||
| 158 | true | ||
| 159 | } else { | ||
| 160 | false | ||
| 161 | }; | ||
| 162 | |||
| 163 | if valid { | ||
| 164 | state.user = Some(username.to_string()); | ||
| 165 | format!("OK {}", username) | ||
| 166 | } else { | ||
| 167 | "ERROR Invalid credentials".to_string() | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | fn handle_info(&self, state: &ConnectionState) -> String { | ||
| 172 | let Some(user) = state.user.as_deref() else { | ||
| 173 | return "ERROR Not logged in".to_string(); | ||
| 174 | }; | ||
| 175 | |||
| 176 | match ( | ||
| 177 | database::get_account_id(&self.conn, user), | ||
| 178 | database::get_balance(&self.conn, user), | ||
| 179 | ) { | ||
| 180 | (Some(id), Some(balance)) => format!("INFO {} {} {}", user, id, balance), | ||
| 181 | _ => "ERROR Error retrieving user info".to_string(), | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 185 | fn handle_play_start( | ||
| 186 | &mut self, | ||
| 187 | state: &ConnectionState, | ||
| 188 | game_type: &str, | ||
| 189 | bet_amount_str: &str, | ||
| 190 | ) -> String { | ||
| 191 | let Some(user) = state.user.as_deref() else { | ||
| 192 | return "ERROR Not logged in".to_string(); | ||
| 193 | }; | ||
| 194 | |||
| 195 | let bet_amount: i64 = match bet_amount_str.parse() { | ||
| 196 | Ok(amount) => amount, | ||
| 197 | Err(_) => return "ERROR Invalid bet amount".to_string(), | ||
| 198 | }; | ||
| 199 | if bet_amount <= 0 { | ||
| 200 | return "ERROR Invalid bet amount".to_string(); | ||
| 201 | } | ||
| 202 | |||
| 203 | let Some(game) = self.registry.get_game(game_type) else { | ||
| 204 | return format!("ERROR Unknown game type: {}", game_type); | ||
| 205 | }; | ||
| 206 | |||
| 207 | let Some(balance) = database::get_balance(&self.conn, user) else { | ||
| 208 | return "ERROR Could not retrieve balance".to_string(); | ||
| 209 | }; | ||
| 210 | if balance < 0 { | ||
| 211 | return "ERROR Invalid balance".to_string(); | ||
| 212 | } | ||
| 213 | |||
| 214 | if let Err(error) = game.validate_bet(bet_amount as u64, balance as u64) { | ||
| 215 | return game_error(error); | ||
| 216 | } | ||
| 217 | |||
| 218 | let game_state = game.prepare_state(user, bet_amount as u64); | ||
| 219 | let new_state = match game.process_action(&game_state, "spin", &[]) { | ||
| 220 | Ok(state) => state, | ||
| 221 | Err(error) => return game_error(error), | ||
| 222 | }; | ||
| 223 | let result = match game.calculate_result(&new_state) { | ||
| 224 | Ok(result) => result, | ||
| 225 | Err(error) => return game_error(error), | ||
| 226 | }; | ||
| 227 | |||
| 228 | // The session is the audit guard for the deduction and is created | ||
| 229 | // before the balance changes. The durable audit row is `plays`. | ||
| 230 | let session_id = | ||
| 231 | self.sessions | ||
| 232 | .create_session(user, game_type.to_string(), new_state.clone()); | ||
| 233 | |||
| 234 | let winnings = match i64::try_from(result.winnings) { | ||
| 235 | Ok(winnings) => winnings, | ||
| 236 | Err(_) => { | ||
| 237 | let _ = self.sessions.remove_session(&session_id); | ||
| 238 | return "ERROR Winnings out of range".to_string(); | ||
| 239 | } | ||
| 240 | }; | ||
| 241 | |||
| 242 | let reels = new_state.data.get("reels").cloned().unwrap_or_default(); | ||
| 243 | let win_type = new_state | ||
| 244 | .data | ||
| 245 | .get("win_type") | ||
| 246 | .cloned() | ||
| 247 | .unwrap_or_default(); | ||
| 248 | |||
| 249 | match database::apply_spin( | ||
| 250 | &mut self.conn, | ||
| 251 | &session_id, | ||
| 252 | user, | ||
| 253 | game_type, | ||
| 254 | bet_amount, | ||
| 255 | winnings, | ||
| 256 | &reels, | ||
| 257 | &win_type, | ||
| 258 | ) { | ||
| 259 | Ok(new_balance) => { | ||
| 260 | let _ = self.sessions.remove_session(&session_id); | ||
| 261 | format!( | ||
| 262 | "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type}" | ||
| 263 | ) | ||
| 264 | } | ||
| 265 | Err(error) => { | ||
| 266 | let _ = self.sessions.remove_session(&session_id); | ||
| 267 | eprintln!("Balance transaction failed for {user}: {error}"); | ||
| 268 | "ERROR Failed to update balance".to_string() | ||
| 269 | } | ||
| 270 | } | ||
| 271 | } | ||
| 272 | |||
| 273 | pub fn disconnect(&mut self, state: &ConnectionState) { | ||
| 274 | if let Some(user) = state.user.as_deref() { | ||
| 275 | for session in self.sessions.get_user_sessions(user) { | ||
| 276 | let _ = self.sessions.remove_session(&session.session_id); | ||
| 277 | } | ||
| 278 | } | ||
| 279 | } | ||
| 280 | |||
| 281 | pub fn balance(&self, username: &str) -> Option<i64> { | ||
| 282 | database::get_balance(&self.conn, username) | ||
| 283 | } | ||
| 284 | |||
| 285 | pub fn play_count(&self, username: &str) -> i64 { | ||
| 286 | database::count_plays(&self.conn, username) | ||
| 287 | } | ||
| 288 | |||
| 289 | pub fn session_count(&self) -> usize { | ||
| 290 | self.sessions.len() | ||
| 291 | } | ||
| 292 | } | ||
| 293 | |||
| 294 | fn game_error(error: GameError) -> String { | ||
| 295 | match error { | ||
| 296 | GameError::InsufficientBalance => "ERROR Insufficient balance".to_string(), | ||
| 297 | GameError::InvalidBet => "ERROR Invalid bet amount".to_string(), | ||
| 298 | GameError::InvalidAction(message) => format!("ERROR {message}"), | ||
| 299 | GameError::GameOver => "ERROR Game over".to_string(), | ||
| 300 | GameError::SessionNotFound => "ERROR Session not found".to_string(), | ||
| 301 | GameError::InternalError(message) => format!("ERROR {message}"), | ||
| 302 | } | ||
| 303 | } | ||
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 @@ | |||
| 1 | use rusqlite::Connection; | 1 | use rusqlite::{params, Connection}; |
| 2 | 2 | ||
| 3 | pub fn init_db(conn: &Connection) { | 3 | pub fn init_db(conn: &Connection) { |
| 4 | conn.execute( | 4 | conn.execute( |
| @@ -17,6 +17,21 @@ pub fn init_db(conn: &Connection) { | |||
| 17 | "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000", | 17 | "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000", |
| 18 | [], | 18 | [], |
| 19 | ); | 19 | ); |
| 20 | conn.execute( | ||
| 21 | "CREATE TABLE IF NOT EXISTS plays ( | ||
| 22 | session_id TEXT PRIMARY KEY, | ||
| 23 | username TEXT NOT NULL, | ||
| 24 | game_type TEXT NOT NULL, | ||
| 25 | bet_amount INTEGER NOT NULL, | ||
| 26 | winnings INTEGER NOT NULL, | ||
| 27 | reels TEXT NOT NULL DEFAULT '', | ||
| 28 | win_type TEXT NOT NULL DEFAULT '', | ||
| 29 | balance_after INTEGER NOT NULL, | ||
| 30 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | ||
| 31 | )", | ||
| 32 | [], | ||
| 33 | ) | ||
| 34 | .unwrap(); | ||
| 20 | } | 35 | } |
| 21 | 36 | ||
| 22 | pub fn add_user( | 37 | pub fn add_user( |
| @@ -24,17 +39,28 @@ pub fn add_user( | |||
| 24 | username: &str, | 39 | username: &str, |
| 25 | hashed_password: &str, | 40 | hashed_password: &str, |
| 26 | ) -> Result<(), Box<dyn std::error::Error>> { | 41 | ) -> Result<(), Box<dyn std::error::Error>> { |
| 27 | loop { | 42 | if get_hashed_password(conn, username).is_some() { |
| 43 | return Err("user already exists".into()); | ||
| 44 | } | ||
| 45 | |||
| 46 | for _ in 0..10 { | ||
| 28 | let account_id: u32 = rand::random::<u32>(); | 47 | let account_id: u32 = rand::random::<u32>(); |
| 29 | match conn.execute( | 48 | match conn.execute( |
| 30 | "INSERT INTO users (username, hashed_password, account_id, balance) VALUES (?1, ?2, ?3, ?4)", | 49 | "INSERT INTO users (username, hashed_password, account_id, balance) |
| 31 | [username, hashed_password, &account_id.to_string(), "1000"], | 50 | VALUES (?1, ?2, ?3, ?4)", |
| 51 | params![username, hashed_password, account_id as i64, 1000i64], | ||
| 32 | ) { | 52 | ) { |
| 33 | Ok(_) => break, | 53 | Ok(_) => return Ok(()), |
| 34 | Err(_) => continue, // Retry if account_id conflict | 54 | Err(rusqlite::Error::SqliteFailure(error, _)) |
| 55 | if error.code == rusqlite::ErrorCode::ConstraintViolation => | ||
| 56 | { | ||
| 57 | continue; | ||
| 58 | } | ||
| 59 | Err(error) => return Err(Box::new(error)), | ||
| 35 | } | 60 | } |
| 36 | } | 61 | } |
| 37 | Ok(()) | 62 | |
| 63 | Err("could not allocate a unique account id".into()) | ||
| 38 | } | 64 | } |
| 39 | 65 | ||
| 40 | pub fn get_hashed_password(conn: &Connection, username: &str) -> Option<String> { | 66 | pub fn get_hashed_password(conn: &Connection, username: &str) -> Option<String> { |
| @@ -49,13 +75,25 @@ pub fn get_hashed_password(conn: &Connection, username: &str) -> Option<String> | |||
| 49 | } | 75 | } |
| 50 | } | 76 | } |
| 51 | 77 | ||
| 52 | pub fn get_account_id(conn: &Connection, username: &str) -> Option<u32> { | 78 | pub fn set_hashed_password( |
| 79 | conn: &Connection, | ||
| 80 | username: &str, | ||
| 81 | hashed_password: &str, | ||
| 82 | ) -> rusqlite::Result<()> { | ||
| 83 | conn.execute( | ||
| 84 | "UPDATE users SET hashed_password = ?1 WHERE username = ?2", | ||
| 85 | params![hashed_password, username], | ||
| 86 | )?; | ||
| 87 | Ok(()) | ||
| 88 | } | ||
| 89 | |||
| 90 | pub fn get_account_id(conn: &Connection, username: &str) -> Option<i64> { | ||
| 53 | let mut stmt = conn | 91 | let mut stmt = conn |
| 54 | .prepare("SELECT account_id FROM users WHERE username = ?1") | 92 | .prepare("SELECT account_id FROM users WHERE username = ?1") |
| 55 | .unwrap(); | 93 | .unwrap(); |
| 56 | let mut rows = stmt.query([username]).unwrap(); | 94 | let mut rows = stmt.query([username]).unwrap(); |
| 57 | if let Ok(Some(row)) = rows.next() { | 95 | if let Ok(Some(row)) = rows.next() { |
| 58 | Some(row.get::<_, u32>(0).ok()?) | 96 | Some(row.get::<_, i64>(0).ok()?) |
| 59 | } else { | 97 | } else { |
| 60 | None | 98 | None |
| 61 | } | 99 | } |
| @@ -72,3 +110,70 @@ pub fn get_balance(conn: &Connection, username: &str) -> Option<i64> { | |||
| 72 | None | 110 | None |
| 73 | } | 111 | } |
| 74 | } | 112 | } |
| 113 | |||
| 114 | /// Settle one spin and record its audit row in a single transaction. | ||
| 115 | /// | ||
| 116 | /// Returns the balance after the spin. All SQL is parameterized: usernames | ||
| 117 | /// are data, never part of the statement text. | ||
| 118 | #[allow(clippy::too_many_arguments)] | ||
| 119 | pub fn apply_spin( | ||
| 120 | conn: &mut Connection, | ||
| 121 | session_id: &str, | ||
| 122 | username: &str, | ||
| 123 | game_type: &str, | ||
| 124 | bet_amount: i64, | ||
| 125 | winnings: i64, | ||
| 126 | reels: &str, | ||
| 127 | win_type: &str, | ||
| 128 | ) -> rusqlite::Result<i64> { | ||
| 129 | let tx = conn.transaction()?; | ||
| 130 | |||
| 131 | let deducted = tx.execute( | ||
| 132 | "UPDATE users SET balance = balance - ?1 | ||
| 133 | WHERE username = ?2 AND balance >= ?1", | ||
| 134 | params![bet_amount, username], | ||
| 135 | )?; | ||
| 136 | if deducted != 1 { | ||
| 137 | return Err(rusqlite::Error::QueryReturnedNoRows); | ||
| 138 | } | ||
| 139 | |||
| 140 | tx.execute( | ||
| 141 | "UPDATE users SET balance = balance + ?1 WHERE username = ?2", | ||
| 142 | params![winnings, username], | ||
| 143 | )?; | ||
| 144 | |||
| 145 | let balance_after: i64 = tx.query_row( | ||
| 146 | "SELECT balance FROM users WHERE username = ?1", | ||
| 147 | [username], | ||
| 148 | |row| row.get(0), | ||
| 149 | )?; | ||
| 150 | |||
| 151 | tx.execute( | ||
| 152 | "INSERT INTO plays | ||
| 153 | (session_id, username, game_type, bet_amount, winnings, reels, | ||
| 154 | win_type, balance_after) | ||
| 155 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", | ||
| 156 | params![ | ||
| 157 | session_id, | ||
| 158 | username, | ||
| 159 | game_type, | ||
| 160 | bet_amount, | ||
| 161 | winnings, | ||
| 162 | reels, | ||
| 163 | win_type, | ||
| 164 | balance_after | ||
| 165 | ], | ||
| 166 | )?; | ||
| 167 | |||
| 168 | tx.commit()?; | ||
| 169 | Ok(balance_after) | ||
| 170 | } | ||
| 171 | |||
| 172 | pub fn count_plays(conn: &Connection, username: &str) -> i64 { | ||
| 173 | conn.query_row( | ||
| 174 | "SELECT COUNT(*) FROM plays WHERE username = ?1", | ||
| 175 | [username], | ||
| 176 | |row| row.get(0), | ||
| 177 | ) | ||
| 178 | .unwrap_or(0) | ||
| 179 | } | ||
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 @@ | |||
| 1 | #![allow(dead_code)] | 1 | #![allow(dead_code)] |
| 2 | 2 | ||
| 3 | use super::slots::SlotsGame; | ||
| 3 | use super::traits::Game; | 4 | use super::traits::Game; |
| 4 | use std::collections::HashMap; | 5 | use std::collections::HashMap; |
| 5 | use std::sync::{Arc, Mutex}; | 6 | use std::sync::{Arc, Mutex}; |
| @@ -17,6 +18,12 @@ impl GameRegistry { | |||
| 17 | } | 18 | } |
| 18 | } | 19 | } |
| 19 | 20 | ||
| 21 | pub fn with_defaults() -> Self { | ||
| 22 | let registry = Self::new(); | ||
| 23 | registry.register_game(SlotsGame::new()); | ||
| 24 | registry | ||
| 25 | } | ||
| 26 | |||
| 20 | pub fn register_game<G: Game + Send + Sync + 'static>(&self, game: G) { | 27 | pub fn register_game<G: Game + Send + Sync + 'static>(&self, game: G) { |
| 21 | let game_type = game.game_type().to_string(); | 28 | let game_type = game.game_type().to_string(); |
| 22 | let mut games = self.games.lock().unwrap(); | 29 | let mut games = self.games.lock().unwrap(); |
| @@ -38,18 +45,3 @@ impl GameRegistry { | |||
| 38 | games.contains_key(game_type) | 45 | games.contains_key(game_type) |
| 39 | } | 46 | } |
| 40 | } | 47 | } |
| 41 | |||
| 42 | // Global registry instance | ||
| 43 | use super::slots::SlotsGame; | ||
| 44 | use once_cell::sync::Lazy; | ||
| 45 | |||
| 46 | static GAME_REGISTRY: Lazy<GameRegistry> = Lazy::new(|| { | ||
| 47 | let registry = GameRegistry::new(); | ||
| 48 | // Register games here | ||
| 49 | registry.register_game(SlotsGame::new()); | ||
| 50 | registry | ||
| 51 | }); | ||
| 52 | |||
| 53 | pub fn get_game_registry() -> &'static GameRegistry { | ||
| 54 | &GAME_REGISTRY | ||
| 55 | } | ||
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 { | |||
| 74 | .cloned() | 74 | .cloned() |
| 75 | .collect() | 75 | .collect() |
| 76 | } | 76 | } |
| 77 | |||
| 78 | pub fn len(&self) -> usize { | ||
| 79 | self.sessions.lock().unwrap().len() | ||
| 80 | } | ||
| 81 | |||
| 82 | pub fn is_empty(&self) -> bool { | ||
| 83 | self.len() == 0 | ||
| 84 | } | ||
| 77 | } | 85 | } |
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; | |||
| 5 | pub struct SlotsGame { | 5 | pub struct SlotsGame { |
| 6 | symbols: Vec<char>, | 6 | symbols: Vec<char>, |
| 7 | payouts: HashMap<String, u64>, | 7 | payouts: HashMap<String, u64>, |
| 8 | /// Deterministic reels for tests and demos. | ||
| 9 | fixed_reels: Option<Vec<char>>, | ||
| 8 | } | 10 | } |
| 9 | 11 | ||
| 10 | impl SlotsGame { | 12 | impl SlotsGame { |
| @@ -27,10 +29,25 @@ impl SlotsGame { | |||
| 27 | payouts.insert("DD".to_string(), 2); | 29 | payouts.insert("DD".to_string(), 2); |
| 28 | payouts.insert("SS".to_string(), 3); | 30 | payouts.insert("SS".to_string(), 3); |
| 29 | 31 | ||
| 30 | Self { symbols, payouts } | 32 | Self { |
| 33 | symbols, | ||
| 34 | payouts, | ||
| 35 | fixed_reels: None, | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | /// Build a slots game that always spins the given three symbols. | ||
| 40 | pub fn with_reels(reels: &str) -> Self { | ||
| 41 | let mut game = Self::new(); | ||
| 42 | game.fixed_reels = Some(reels.chars().take(3).collect()); | ||
| 43 | game | ||
| 31 | } | 44 | } |
| 32 | 45 | ||
| 33 | fn spin_reels(&self) -> Vec<char> { | 46 | fn spin_reels(&self) -> Vec<char> { |
| 47 | if let Some(reels) = &self.fixed_reels { | ||
| 48 | return reels.clone(); | ||
| 49 | } | ||
| 50 | |||
| 34 | let mut rng = rand::thread_rng(); | 51 | let mut rng = rand::thread_rng(); |
| 35 | (0..3) | 52 | (0..3) |
| 36 | .map(|_| self.symbols[rng.gen_range(0..self.symbols.len())]) | 53 | .map(|_| self.symbols[rng.gen_range(0..self.symbols.len())]) |
| @@ -41,7 +58,7 @@ impl SlotsGame { | |||
| 41 | let reel_string: String = reels.iter().collect(); | 58 | let reel_string: String = reels.iter().collect(); |
| 42 | 59 | ||
| 43 | if let Some(&multiplier) = self.payouts.get(&reel_string) { | 60 | if let Some(&multiplier) = self.payouts.get(&reel_string) { |
| 44 | return bet * multiplier; | 61 | return bet.saturating_mul(multiplier); |
| 45 | } | 62 | } |
| 46 | 63 | ||
| 47 | // Check all three pairs including non-adjacent (e.g. [C,D,C]) | 64 | // Check all three pairs including non-adjacent (e.g. [C,D,C]) |
| @@ -51,7 +68,7 @@ impl SlotsGame { | |||
| 51 | if reels[a] == reels[b] { | 68 | if reels[a] == reels[b] { |
| 52 | let two_of_kind = format!("{}{}", reels[a], reels[a]); | 69 | let two_of_kind = format!("{}{}", reels[a], reels[a]); |
| 53 | if let Some(&multiplier) = self.payouts.get(&two_of_kind) { | 70 | if let Some(&multiplier) = self.payouts.get(&two_of_kind) { |
| 54 | best = best.max(bet * multiplier); | 71 | best = best.max(bet.saturating_mul(multiplier)); |
| 55 | } | 72 | } |
| 56 | } | 73 | } |
| 57 | } | 74 | } |
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 @@ | |||
| 1 | pub mod core; | ||
| 1 | pub mod database; | 2 | pub mod database; |
| 2 | pub mod games; | 3 | pub mod games; |
