diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-28 22:44:41 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-28 22:44:41 +0100 |
| commit | 63c2e2fc3ea299aee31b66d2bdd6ecd871df223a (patch) | |
| tree | 9c85ab7a4de17125055257dc055fa3e53dc1e8cf /src/server/core.rs | |
| parent | v0.2.6: preserve buffered lines and add request read timeouts (diff) | |
| download | zagan-bet-63c2e2fc3ea299aee31b66d2bdd6ecd871df223a.tar.gz zagan-bet-63c2e2fc3ea299aee31b66d2bdd6ecd871df223a.zip | |
v0.3.0: add server core, bcrypt migration and plays audit
Diffstat (limited to '')
| -rw-r--r-- | src/server/core.rs | 303 |
1 files changed, 303 insertions, 0 deletions
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 | } | ||
