diff options
| author | salmonik <salmonik@tuta.io> | 2026-08-24 17:36:19 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-08-24 17:36:19 +0200 |
| commit | 828eb472701af16b7bd913111273b62d22ea3627 (patch) | |
| tree | 48ab3876cc1e2b7420609c7c1b016c939c9b84d1 /src/bin | |
| parent | TOFU host trust: SPKI fingerprint pinning, tests, forget-host (diff) | |
| download | zagan-bet-828eb472701af16b7bd913111273b62d22ea3627.tar.gz zagan-bet-828eb472701af16b7bd913111273b62d22ea3627.zip | |
Fix critical game bugs: payout, sessions, balance type, render_lines; add AGENTS.md and regression tests
Diffstat (limited to 'src/bin')
| -rw-r--r-- | src/bin/client/games/slots.rs | 6 | ||||
| -rw-r--r-- | src/bin/server/games/slots.rs | 21 | ||||
| -rw-r--r-- | src/bin/server/handler.rs | 98 |
3 files changed, 66 insertions, 59 deletions
diff --git a/src/bin/client/games/slots.rs b/src/bin/client/games/slots.rs index 2f9d63e..d664dbc 100644 --- a/src/bin/client/games/slots.rs +++ b/src/bin/client/games/slots.rs | |||
| @@ -38,8 +38,10 @@ impl Game for SlotsGame { | |||
| 38 | output | 38 | output |
| 39 | } | 39 | } |
| 40 | 40 | ||
| 41 | fn render_lines(&self, state: &GameState) -> usize { | 41 | fn render_lines(&self, _state: &GameState) -> usize { |
| 42 | self.render(state).lines().count() | 42 | // Fixed count matching the render() layout: |
| 43 | // blank, header, bet, reels, blank, prompt, blank, controls | ||
| 44 | 8 | ||
| 43 | } | 45 | } |
| 44 | 46 | ||
| 45 | fn get_help(&self) -> String { | 47 | fn get_help(&self) -> String { |
diff --git a/src/bin/server/games/slots.rs b/src/bin/server/games/slots.rs index d84e21f..3d285ef 100644 --- a/src/bin/server/games/slots.rs +++ b/src/bin/server/games/slots.rs | |||
| @@ -36,23 +36,25 @@ impl SlotsGame { | |||
| 36 | .collect() | 36 | .collect() |
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | fn calculate_payout(&self, reels: &[char], bet: u64) -> u64 { | 39 | pub fn calculate_payout(&self, reels: &[char], bet: u64) -> u64 { |
| 40 | let reel_string: String = reels.iter().collect(); | 40 | let reel_string: String = reels.iter().collect(); |
| 41 | 41 | ||
| 42 | if let Some(&multiplier) = self.payouts.get(&reel_string) { | 42 | if let Some(&multiplier) = self.payouts.get(&reel_string) { |
| 43 | return bet * multiplier; | 43 | return bet * multiplier; |
| 44 | } | 44 | } |
| 45 | 45 | ||
| 46 | for i in 0..2 { | 46 | // Check all three pairs including non-adjacent (e.g. [C,D,C]) |
| 47 | if reels[i] == reels[i + 1] { | 47 | let pairs: [(usize, usize); 3] = [(0, 1), (1, 2), (0, 2)]; |
| 48 | let two_of_kind = format!("{}{}", reels[i], reels[i]); | 48 | let mut best: u64 = 0; |
| 49 | for &(a, b) in &pairs { | ||
| 50 | if reels[a] == reels[b] { | ||
| 51 | let two_of_kind = format!("{}{}", reels[a], reels[a]); | ||
| 49 | if let Some(&multiplier) = self.payouts.get(&two_of_kind) { | 52 | if let Some(&multiplier) = self.payouts.get(&two_of_kind) { |
| 50 | return bet * multiplier; | 53 | best = best.max(bet * multiplier); |
| 51 | } | 54 | } |
| 52 | } | 55 | } |
| 53 | } | 56 | } |
| 54 | 57 | best | |
| 55 | 0 | ||
| 56 | } | 58 | } |
| 57 | } | 59 | } |
| 58 | 60 | ||
| @@ -108,11 +110,8 @@ impl Game for SlotsGame { | |||
| 108 | new_data.insert("reels".to_string(), reel_string.clone()); | 110 | new_data.insert("reels".to_string(), reel_string.clone()); |
| 109 | new_data.insert("status".to_string(), "spun".to_string()); | 111 | new_data.insert("status".to_string(), "spun".to_string()); |
| 110 | 112 | ||
| 111 | let winnings = self.calculate_payout(&reels, state.bet_amount); | ||
| 112 | new_data.insert("winnings".to_string(), winnings.to_string()); | ||
| 113 | |||
| 114 | let is_jackpot = reels[0] == reels[1] && reels[1] == reels[2]; | 113 | let is_jackpot = reels[0] == reels[1] && reels[1] == reels[2]; |
| 115 | let is_two = reels[0] == reels[1] || reels[1] == reels[2]; | 114 | let is_two = reels[0] == reels[1] || reels[1] == reels[2] || reels[0] == reels[2]; |
| 116 | new_data.insert( | 115 | new_data.insert( |
| 117 | "win_type".to_string(), | 116 | "win_type".to_string(), |
| 118 | if is_jackpot { | 117 | if is_jackpot { |
diff --git a/src/bin/server/handler.rs b/src/bin/server/handler.rs index 48e9d3a..5406e85 100644 --- a/src/bin/server/handler.rs +++ b/src/bin/server/handler.rs | |||
| @@ -1,20 +1,25 @@ | |||
| 1 | use crate::database; | 1 | use crate::database; |
| 2 | use crate::games::registry::get_game_registry; | 2 | use crate::games::registry::get_game_registry; |
| 3 | use crate::games::traits::GameError; | 3 | use crate::games::session::SessionManager; |
| 4 | use crate::games::traits::{GameError, GamePhase}; | ||
| 4 | use crate::tls::TlsStream; | 5 | use crate::tls::TlsStream; |
| 5 | use once_cell::sync::Lazy; | 6 | use once_cell::sync::Lazy; |
| 7 | use parking_lot::Mutex; | ||
| 6 | use rusqlite::Connection; | 8 | use rusqlite::Connection; |
| 7 | use std::io::{BufRead, BufReader, Write}; | 9 | use std::io::{BufRead, BufReader, Write}; |
| 8 | use std::net::TcpStream; | 10 | use std::net::TcpStream; |
| 9 | use std::sync::Mutex; | ||
| 10 | 11 | ||
| 11 | static DB_CONN: Lazy<Mutex<Connection>> = Lazy::new(|| { | 12 | static DB_CONN: Lazy<Mutex<Connection>> = Lazy::new(|| { |
| 12 | let conn = Connection::open(std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string())).unwrap(); | 13 | let conn = Connection::open( |
| 14 | std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string()), | ||
| 15 | ) | ||
| 16 | .unwrap(); | ||
| 13 | database::init_db(&conn); | 17 | database::init_db(&conn); |
| 14 | Mutex::new(conn) | 18 | Mutex::new(conn) |
| 15 | }); | 19 | }); |
| 16 | 20 | ||
| 17 | #[allow(dead_code)] | 21 | static SESSIONS: Lazy<SessionManager> = Lazy::new(SessionManager::new); |
| 22 | |||
| 18 | pub fn handle_client(stream: TcpStream) { | 23 | pub fn handle_client(stream: TcpStream) { |
| 19 | let client_addr = stream.peer_addr().unwrap().to_string(); | 24 | let client_addr = stream.peer_addr().unwrap().to_string(); |
| 20 | let mut user = "guest".to_string(); | 25 | let mut user = "guest".to_string(); |
| @@ -82,7 +87,7 @@ fn process_message(msg: &str, user: &mut String) -> String { | |||
| 82 | } else { | 87 | } else { |
| 83 | let username = parts[1]; | 88 | let username = parts[1]; |
| 84 | let hashed_password = parts[2]; | 89 | let hashed_password = parts[2]; |
| 85 | let db = DB_CONN.lock().unwrap(); | 90 | let db = DB_CONN.lock(); |
| 86 | match database::add_user(&db, username, hashed_password) { | 91 | match database::add_user(&db, username, hashed_password) { |
| 87 | Ok(_) => format!("REGISTERED {}", username), | 92 | Ok(_) => format!("REGISTERED {}", username), |
| 88 | Err(_) => "ERROR User already exists".to_string(), | 93 | Err(_) => "ERROR User already exists".to_string(), |
| @@ -95,7 +100,7 @@ fn process_message(msg: &str, user: &mut String) -> String { | |||
| 95 | } else { | 100 | } else { |
| 96 | let username = parts[1]; | 101 | let username = parts[1]; |
| 97 | let sent_hash = parts[2]; | 102 | let sent_hash = parts[2]; |
| 98 | let db = DB_CONN.lock().unwrap(); | 103 | let db = DB_CONN.lock(); |
| 99 | if let Some(stored_hash) = database::get_hashed_password(&db, username) { | 104 | if let Some(stored_hash) = database::get_hashed_password(&db, username) { |
| 100 | if sent_hash == stored_hash { | 105 | if sent_hash == stored_hash { |
| 101 | *user = username.to_string(); | 106 | *user = username.to_string(); |
| @@ -113,7 +118,7 @@ fn process_message(msg: &str, user: &mut String) -> String { | |||
| 113 | if *user == "guest" { | 118 | if *user == "guest" { |
| 114 | "ERROR Not logged in".to_string() | 119 | "ERROR Not logged in".to_string() |
| 115 | } else { | 120 | } else { |
| 116 | let db = DB_CONN.lock().unwrap(); | 121 | let db = DB_CONN.lock(); |
| 117 | if let (Some(id), Some(balance)) = ( | 122 | if let (Some(id), Some(balance)) = ( |
| 118 | database::get_account_id(&db, user), | 123 | database::get_account_id(&db, user), |
| 119 | database::get_balance(&db, user), | 124 | database::get_balance(&db, user), |
| @@ -133,16 +138,13 @@ fn handle_play_start(user: &str, game_type: &str, bet_amount_str: &str) -> Strin | |||
| 133 | return "ERROR Not logged in".to_string(); | 138 | return "ERROR Not logged in".to_string(); |
| 134 | } | 139 | } |
| 135 | 140 | ||
| 136 | let bet_amount: u64 = match bet_amount_str.parse() { | 141 | let bet_amount: i64 = match bet_amount_str.parse() { |
| 137 | Ok(amount) => amount, | 142 | Ok(amount) => amount, |
| 138 | Err(_) => return "ERROR Invalid bet amount".to_string(), | 143 | Err(_) => return "ERROR Invalid bet amount".to_string(), |
| 139 | }; | 144 | }; |
| 140 | 145 | if bet_amount <= 0 { | |
| 141 | let db = DB_CONN.lock().unwrap(); | 146 | return "ERROR Invalid bet amount".to_string(); |
| 142 | let current_balance = match database::get_balance(&db, user) { | 147 | } |
| 143 | Some(balance) => balance as u64, | ||
| 144 | None => return "ERROR Could not retrieve balance".to_string(), | ||
| 145 | }; | ||
| 146 | 148 | ||
| 147 | let registry = get_game_registry(); | 149 | let registry = get_game_registry(); |
| 148 | let game = match registry.get_game(game_type) { | 150 | let game = match registry.get_game(game_type) { |
| @@ -150,7 +152,15 @@ fn handle_play_start(user: &str, game_type: &str, bet_amount_str: &str) -> Strin | |||
| 150 | None => return format!("ERROR Unknown game type: {}", game_type), | 152 | None => return format!("ERROR Unknown game type: {}", game_type), |
| 151 | }; | 153 | }; |
| 152 | 154 | ||
| 153 | if let Err(e) = game.validate_bet(bet_amount, current_balance) { | 155 | let db = DB_CONN.lock(); |
| 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) { | ||
| 154 | return match e { | 164 | return match e { |
| 155 | GameError::InsufficientBalance => "ERROR Insufficient balance".to_string(), | 165 | GameError::InsufficientBalance => "ERROR Insufficient balance".to_string(), |
| 156 | GameError::InvalidBet => "ERROR Invalid bet amount".to_string(), | 166 | GameError::InvalidBet => "ERROR Invalid bet amount".to_string(), |
| @@ -158,44 +168,32 @@ fn handle_play_start(user: &str, game_type: &str, bet_amount_str: &str) -> Strin | |||
| 158 | }; | 168 | }; |
| 159 | } | 169 | } |
| 160 | 170 | ||
| 161 | let game_state = game.prepare_state(user, bet_amount); | 171 | // Prepare and spin. |
| 162 | 172 | let game_state = game.prepare_state(user, bet_amount as u64); | |
| 163 | drop(db); | ||
| 164 | let db = DB_CONN.lock().unwrap(); | ||
| 165 | if let Err(_) = db.execute( | ||
| 166 | "UPDATE users SET balance = balance - ?1 WHERE username = ?2", | ||
| 167 | (bet_amount as i64, user), | ||
| 168 | ) { | ||
| 169 | return "ERROR Failed to deduct bet".to_string(); | ||
| 170 | } | ||
| 171 | 173 | ||
| 172 | let new_state = match game.process_action(&game_state, "spin", &[]) { | 174 | let new_state = match game.process_action(&game_state, "spin", &[]) { |
| 173 | Ok(state) => state, | 175 | Ok(state) => state, |
| 174 | Err(e) => { | 176 | Err(e) => return format!("ERROR Failed to spin: {:?}", e), |
| 175 | let _ = db.execute( | ||
| 176 | "UPDATE users SET balance = balance + ?1 WHERE username = ?2", | ||
| 177 | (bet_amount as i64, user), | ||
| 178 | ); | ||
| 179 | return format!("ERROR Failed to spin: {:?}", e); | ||
| 180 | } | ||
| 181 | }; | 177 | }; |
| 182 | 178 | ||
| 183 | let result = match game.calculate_result(&new_state) { | 179 | let result = match game.calculate_result(&new_state) { |
| 184 | Ok(result) => result, | 180 | Ok(result) => result, |
| 185 | Err(e) => { | 181 | Err(e) => return format!("ERROR Failed to calculate result: {:?}", e), |
| 186 | let _ = db.execute( | ||
| 187 | "UPDATE users SET balance = balance + ?1 WHERE username = ?2", | ||
| 188 | (bet_amount as i64, user), | ||
| 189 | ); | ||
| 190 | return format!("ERROR Failed to calculate result: {:?}", e); | ||
| 191 | } | ||
| 192 | }; | 182 | }; |
| 193 | 183 | ||
| 194 | let total_return = result.bet_amount + result.winnings; | 184 | // Create session before touching balance. |
| 195 | if let Err(_) = db.execute( | 185 | let session_id = SESSIONS.create_session(user, game_type.to_string(), new_state.clone()); |
| 196 | "UPDATE users SET balance = balance + ?1 WHERE username = ?2", | 186 | |
| 197 | (total_return as i64, user), | 187 | // All balance mutations in a single transaction. |
| 198 | ) { | 188 | let total_return = bet_amount + 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}"); | ||
| 199 | return "ERROR Failed to update balance".to_string(); | 197 | return "ERROR Failed to update balance".to_string(); |
| 200 | } | 198 | } |
| 201 | 199 | ||
| @@ -208,8 +206,8 @@ fn handle_play_start(user: &str, game_type: &str, bet_amount_str: &str) -> Strin | |||
| 208 | let win_type = new_state.data.get("win_type").cloned().unwrap_or_default(); | 206 | let win_type = new_state.data.get("win_type").cloned().unwrap_or_default(); |
| 209 | 207 | ||
| 210 | format!( | 208 | format!( |
| 211 | "GAME_RESULT {} {} {} {} {} {}", | 209 | "GAME_RESULT {session_id} {} {} {new_balance} {reels} {win_type}", |
| 212 | result.session_id, result.winnings, result.bet_amount, new_balance, reels, win_type | 210 | result.winnings, result.bet_amount, |
| 213 | ) | 211 | ) |
| 214 | } | 212 | } |
| 215 | 213 | ||
| @@ -242,5 +240,13 @@ pub fn handle_client_tls(mut stream: TlsStream) { | |||
| 242 | } | 240 | } |
| 243 | println!("[{} @ {}] Sent: {}", user, client_addr, response); | 241 | println!("[{} @ {}] Sent: {}", user, client_addr, response); |
| 244 | } | 242 | } |
| 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 | } | ||
| 250 | |||
| 245 | println!("[{} @ {}] TLS Client disconnected", user, client_addr); | 251 | println!("[{} @ {}] TLS Client disconnected", user, client_addr); |
| 246 | } | 252 | } |
