diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/client/connection.rs | 5 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 123 | ||||
| -rw-r--r-- | src/bin/client/tls.rs | 58 | ||||
| -rw-r--r-- | src/bin/server/handler.rs | 55 | ||||
| -rw-r--r-- | src/bin/server/tls.rs | 9 | ||||
| -rw-r--r-- | src/client/core.rs | 190 | ||||
| -rw-r--r-- | src/client/games/crash.rs | 91 | ||||
| -rw-r--r-- | src/client/games/manager.rs | 90 | ||||
| -rw-r--r-- | src/client/games/mod.rs | 1 | ||||
| -rw-r--r-- | src/client/games/response.rs | 21 | ||||
| -rw-r--r-- | src/crash.rs | 87 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/server/core.rs | 240 | ||||
| -rw-r--r-- | src/server/games/crash.rs | 216 | ||||
| -rw-r--r-- | src/server/games/mod.rs | 1 | ||||
| -rw-r--r-- | src/server/games/registry.rs | 2 |
16 files changed, 1150 insertions, 40 deletions
diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs index 6459de5..363a2a9 100644 --- a/src/bin/client/connection.rs +++ b/src/bin/client/connection.rs | |||
| @@ -125,6 +125,11 @@ impl TlsStream { | |||
| 125 | self.inner.write_line(msg) | 125 | self.inner.write_line(msg) |
| 126 | } | 126 | } |
| 127 | 127 | ||
| 128 | /// One line if the socket already has one, without blocking. | ||
| 129 | pub fn read_available_line(&mut self) -> std::io::Result<Option<String>> { | ||
| 130 | self.inner.read_available_line() | ||
| 131 | } | ||
| 132 | |||
| 128 | pub fn set_read_timeout( | 133 | pub fn set_read_timeout( |
| 129 | &self, | 134 | &self, |
| 130 | duration: Option<std::time::Duration>, | 135 | duration: Option<std::time::Duration>, |
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index f85af47..7fa462a 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs | |||
| @@ -102,29 +102,76 @@ impl ClientState { | |||
| 102 | self.apply_prompt(); | 102 | self.apply_prompt(); |
| 103 | 103 | ||
| 104 | loop { | 104 | loop { |
| 105 | let event = read().unwrap(); | 105 | let mut quit = false; |
| 106 | match event { | 106 | |
| 107 | Event::Key(key) => { | 107 | if crossterm::event::poll(std::time::Duration::from_millis(25)).unwrap_or(false) { |
| 108 | if key.kind != KeyEventKind::Press { | 108 | match read().unwrap() { |
| 109 | continue; | 109 | Event::Key(key) => { |
| 110 | } | 110 | if key.kind == KeyEventKind::Press { |
| 111 | let Some(key) = map_key(key) else { | 111 | if let Some(key) = map_key(key) { |
| 112 | continue; | 112 | let effects = self.core.handle_key(key); |
| 113 | }; | 113 | quit = self.apply_effects(effects); |
| 114 | let effects = self.core.handle_key(key); | 114 | self.apply_prompt(); |
| 115 | if self.apply_effects(effects) { | 115 | } |
| 116 | break; | 116 | } |
| 117 | } | 117 | } |
| 118 | self.apply_prompt(); | 118 | Event::Resize(_, _) => self.apply_prompt(), |
| 119 | _ => {} | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | if !quit && self.core.expects_push() { | ||
| 124 | quit = self.pump_async(); | ||
| 125 | } | ||
| 126 | |||
| 127 | if !quit { | ||
| 128 | let effects = self.core.tick(); | ||
| 129 | if !effects.is_empty() { | ||
| 130 | quit = self.apply_effects(effects); | ||
| 119 | } | 131 | } |
| 120 | Event::Resize(_, _) => self.apply_prompt(), | 132 | } |
| 121 | _ => {} | 133 | |
| 134 | if quit { | ||
| 135 | break; | ||
| 122 | } | 136 | } |
| 123 | } | 137 | } |
| 124 | 138 | ||
| 125 | restore_terminal(&self.cleanup_done); | 139 | restore_terminal(&self.cleanup_done); |
| 126 | } | 140 | } |
| 127 | 141 | ||
| 142 | /// Read any server-initiated lines that are already waiting and apply them. | ||
| 143 | /// Returns true when an effect asked the program to quit. | ||
| 144 | fn pump_async(&mut self) -> bool { | ||
| 145 | loop { | ||
| 146 | let Some(mut stream) = self.stream.take() else { | ||
| 147 | return false; | ||
| 148 | }; | ||
| 149 | let line = match stream.read_available_line() { | ||
| 150 | Ok(line) => { | ||
| 151 | self.stream = Some(stream); | ||
| 152 | line | ||
| 153 | } | ||
| 154 | Err(_) => { | ||
| 155 | self.core.set_connected(false); | ||
| 156 | self.stream = None; | ||
| 157 | return false; | ||
| 158 | } | ||
| 159 | }; | ||
| 160 | |||
| 161 | let Some(line) = line else { | ||
| 162 | return false; | ||
| 163 | }; | ||
| 164 | |||
| 165 | let effects = match self.core.handle_async_line(&line) { | ||
| 166 | Some(effects) => effects, | ||
| 167 | None => self.core.handle_reply(&line), | ||
| 168 | }; | ||
| 169 | if self.apply_effects(effects) { | ||
| 170 | return true; | ||
| 171 | } | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 128 | fn apply_effects(&mut self, effects: Vec<Effect>) -> bool { | 175 | fn apply_effects(&mut self, effects: Vec<Effect>) -> bool { |
| 129 | for effect in effects { | 176 | for effect in effects { |
| 130 | match effect { | 177 | match effect { |
| @@ -250,6 +297,9 @@ impl ClientState { | |||
| 250 | let roulette = lines | 297 | let roulette = lines |
| 251 | .first() | 298 | .first() |
| 252 | .is_some_and(|first| first.contains("ROULETTE")); | 299 | .is_some_and(|first| first.contains("ROULETTE")); |
| 300 | let crash = lines | ||
| 301 | .first() | ||
| 302 | .is_some_and(|first| first.contains("CRASH")); | ||
| 253 | let rendered: Vec<String> = lines | 303 | let rendered: Vec<String> = lines |
| 254 | .iter() | 304 | .iter() |
| 255 | .map(|line| { | 305 | .map(|line| { |
| @@ -263,6 +313,18 @@ impl ClientState { | |||
| 263 | } else { | 313 | } else { |
| 264 | style_roulette_numbers(line) | 314 | style_roulette_numbers(line) |
| 265 | } | 315 | } |
| 316 | } else if crash { | ||
| 317 | if line.contains("CRASHED") { | ||
| 318 | line.as_str().bold().red().to_string() | ||
| 319 | } else if line.starts_with("Cashed out") { | ||
| 320 | line.as_str().bold().green().to_string() | ||
| 321 | } else if line.starts_with("Multiplier:") { | ||
| 322 | line.as_str().yellow().to_string() | ||
| 323 | } else if line.starts_with("ERROR:") { | ||
| 324 | line.as_str().red().to_string() | ||
| 325 | } else { | ||
| 326 | line.clone() | ||
| 327 | } | ||
| 266 | } else if line.contains('{') { | 328 | } else if line.contains('{') { |
| 267 | style_winning_cells(line) | 329 | style_winning_cells(line) |
| 268 | } else if line.contains("WIN!") || line.starts_with("Won ") { | 330 | } else if line.contains("WIN!") || line.starts_with("Won ") { |
| @@ -361,22 +423,37 @@ impl ClientState { | |||
| 361 | return "ERROR Not connected".to_string(); | 423 | return "ERROR Not connected".to_string(); |
| 362 | }; | 424 | }; |
| 363 | 425 | ||
| 426 | // A crash event can be pushed while a request is in flight. Route those | ||
| 427 | // lines as they arrive and keep waiting for the actual reply. | ||
| 428 | let mut pushed: Vec<String> = Vec::new(); | ||
| 364 | let result = (|| -> std::io::Result<String> { | 429 | let result = (|| -> std::io::Result<String> { |
| 365 | stream.write_line(line)?; | 430 | stream.write_line(line)?; |
| 366 | let mut response = String::new(); | 431 | loop { |
| 367 | let read = stream.read_line(&mut response)?; | 432 | let mut response = String::new(); |
| 368 | if read == 0 { | 433 | let read = stream.read_line(&mut response)?; |
| 369 | return Err(std::io::Error::new( | 434 | if read == 0 { |
| 370 | std::io::ErrorKind::UnexpectedEof, | 435 | return Err(std::io::Error::new( |
| 371 | "connection closed", | 436 | std::io::ErrorKind::UnexpectedEof, |
| 372 | )); | 437 | "connection closed", |
| 438 | )); | ||
| 439 | } | ||
| 440 | let response = response.trim_end().to_string(); | ||
| 441 | if self.core.is_async(&response) { | ||
| 442 | pushed.push(response); | ||
| 443 | continue; | ||
| 444 | } | ||
| 445 | return Ok(response); | ||
| 373 | } | 446 | } |
| 374 | Ok(response) | ||
| 375 | })(); | 447 | })(); |
| 376 | 448 | ||
| 377 | match result { | 449 | match result { |
| 378 | Ok(response) => { | 450 | Ok(response) => { |
| 379 | self.stream = Some(stream); | 451 | self.stream = Some(stream); |
| 452 | for line in pushed { | ||
| 453 | if let Some(effects) = self.core.handle_async_line(&line) { | ||
| 454 | self.apply_effects(effects); | ||
| 455 | } | ||
| 456 | } | ||
| 380 | response | 457 | response |
| 381 | } | 458 | } |
| 382 | Err(error) => { | 459 | Err(error) => { |
diff --git a/src/bin/client/tls.rs b/src/bin/client/tls.rs index 8c052f7..b46f52b 100644 --- a/src/bin/client/tls.rs +++ b/src/bin/client/tls.rs | |||
| @@ -64,6 +64,7 @@ impl TlsConnector { | |||
| 64 | sock, | 64 | sock, |
| 65 | tls: conn, | 65 | tls: conn, |
| 66 | lines: LineBuffer::new(), | 66 | lines: LineBuffer::new(), |
| 67 | read_timeout: Some(std::time::Duration::from_secs(10)), | ||
| 67 | })), | 68 | })), |
| 68 | }) | 69 | }) |
| 69 | } | 70 | } |
| @@ -150,6 +151,7 @@ struct Session { | |||
| 150 | sock: TcpStream, | 151 | sock: TcpStream, |
| 151 | tls: rustls::ClientConnection, | 152 | tls: rustls::ClientConnection, |
| 152 | lines: LineBuffer, | 153 | lines: LineBuffer, |
| 154 | read_timeout: Option<std::time::Duration>, | ||
| 153 | } | 155 | } |
| 154 | 156 | ||
| 155 | impl Session { | 157 | impl Session { |
| @@ -202,7 +204,61 @@ impl TlsStream { | |||
| 202 | &self, | 204 | &self, |
| 203 | duration: Option<std::time::Duration>, | 205 | duration: Option<std::time::Duration>, |
| 204 | ) -> std::io::Result<()> { | 206 | ) -> std::io::Result<()> { |
| 205 | self.session.lock().sock.set_read_timeout(duration) | 207 | let mut s = self.session.lock(); |
| 208 | s.read_timeout = duration; | ||
| 209 | s.sock.set_read_timeout(duration) | ||
| 210 | } | ||
| 211 | |||
| 212 | /// Return one complete line if the socket already has one, without | ||
| 213 | /// blocking. Used to pick up server pushes between requests. | ||
| 214 | pub fn read_available_line(&mut self) -> std::io::Result<Option<String>> { | ||
| 215 | let mut s = self.session.lock(); | ||
| 216 | if let Some(line) = s.lines.next_line() { | ||
| 217 | return Ok(Some(line)); | ||
| 218 | } | ||
| 219 | |||
| 220 | // `set_read_timeout(Some(0))` is rejected by std, so poll with the | ||
| 221 | // shortest timeout the socket accepts instead of a nonblocking mode. | ||
| 222 | s.sock | ||
| 223 | .set_read_timeout(Some(std::time::Duration::from_millis(1)))?; | ||
| 224 | let drained = (|| -> std::io::Result<()> { | ||
| 225 | loop { | ||
| 226 | let mut buf = [0u8; 4096]; | ||
| 227 | match s.tls.reader().read(&mut buf) { | ||
| 228 | Ok(0) => return Ok(()), | ||
| 229 | Ok(n) => s.lines.push(&buf[..n]), | ||
| 230 | // The reader only drains plaintext rustls has already | ||
| 231 | // decrypted; `drive` moves bytes off the socket. | ||
| 232 | Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { | ||
| 233 | match s.drive() { | ||
| 234 | Ok(()) => continue, | ||
| 235 | Err(error) | ||
| 236 | if matches!( | ||
| 237 | error.kind(), | ||
| 238 | std::io::ErrorKind::WouldBlock | ||
| 239 | | std::io::ErrorKind::TimedOut | ||
| 240 | ) => | ||
| 241 | { | ||
| 242 | return Ok(()); | ||
| 243 | } | ||
| 244 | Err(error) => return Err(error), | ||
| 245 | } | ||
| 246 | } | ||
| 247 | Err(error) | ||
| 248 | if matches!( | ||
| 249 | error.kind(), | ||
| 250 | std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut | ||
| 251 | ) => | ||
| 252 | { | ||
| 253 | return Ok(()); | ||
| 254 | } | ||
| 255 | Err(error) => return Err(error), | ||
| 256 | } | ||
| 257 | } | ||
| 258 | })(); | ||
| 259 | s.sock.set_read_timeout(s.read_timeout)?; | ||
| 260 | drained?; | ||
| 261 | Ok(s.lines.next_line()) | ||
| 206 | } | 262 | } |
| 207 | } | 263 | } |
| 208 | 264 | ||
diff --git a/src/bin/server/handler.rs b/src/bin/server/handler.rs index 86b09cb..dffae69 100644 --- a/src/bin/server/handler.rs +++ b/src/bin/server/handler.rs | |||
| @@ -1,6 +1,11 @@ | |||
| 1 | use std::time::Duration; | ||
| 2 | |||
| 1 | use crate::tls::TlsStream; | 3 | use crate::tls::TlsStream; |
| 2 | use zagan_bet::server::core::{ConnectionState, ServerCore}; | 4 | use zagan_bet::server::core::{ConnectionState, ServerCore}; |
| 3 | 5 | ||
| 6 | /// How long the connection waits for a request before advancing timed games. | ||
| 7 | const POLL_INTERVAL: Duration = Duration::from_millis(50); | ||
| 8 | |||
| 4 | pub fn handle_client_tls(mut stream: TlsStream) { | 9 | pub fn handle_client_tls(mut stream: TlsStream) { |
| 5 | let client_addr = match stream.peer_addr() { | 10 | let client_addr = match stream.peer_addr() { |
| 6 | Ok(addr) => addr.to_string(), | 11 | Ok(addr) => addr.to_string(), |
| @@ -19,34 +24,54 @@ pub fn handle_client_tls(mut stream: TlsStream) { | |||
| 19 | } | 24 | } |
| 20 | }; | 25 | }; |
| 21 | 26 | ||
| 27 | if let Err(error) = stream.set_read_timeout(Some(POLL_INTERVAL)) { | ||
| 28 | eprintln!("Could not set read timeout: {error}"); | ||
| 29 | return; | ||
| 30 | } | ||
| 31 | |||
| 22 | let mut state = ConnectionState::default(); | 32 | let mut state = ConnectionState::default(); |
| 23 | println!("[TLS Client connected] {client_addr}"); | 33 | println!("[TLS Client connected] {client_addr}"); |
| 24 | 34 | ||
| 25 | loop { | 35 | 'connection: loop { |
| 26 | let mut line = String::new(); | 36 | let mut line = String::new(); |
| 27 | match stream.read_line(&mut line) { | 37 | match stream.read_line(&mut line) { |
| 28 | Ok(0) => break, | 38 | Ok(0) => break, |
| 29 | Ok(_) => {} | 39 | Ok(_) => { |
| 40 | let line = line.trim(); | ||
| 41 | if !line.is_empty() { | ||
| 42 | let user = state.user().unwrap_or("guest").to_string(); | ||
| 43 | println!("[{user} @ {client_addr}] Received: {line}"); | ||
| 44 | let response = core.handle_message(&mut state, line); | ||
| 45 | if let Err(error) = stream.write_line(&response) { | ||
| 46 | eprintln!("Write error: {error}"); | ||
| 47 | break; | ||
| 48 | } | ||
| 49 | let user = state.user().unwrap_or("guest"); | ||
| 50 | println!("[{user} @ {client_addr}] Sent: {response}"); | ||
| 51 | } | ||
| 52 | } | ||
| 53 | Err(error) | ||
| 54 | if matches!( | ||
| 55 | error.kind(), | ||
| 56 | std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut | ||
| 57 | ) => | ||
| 58 | { | ||
| 59 | // No request yet: fall through and let timed games advance. | ||
| 60 | } | ||
| 30 | Err(error) => { | 61 | Err(error) => { |
| 31 | eprintln!("Read error: {error}"); | 62 | eprintln!("Read error: {error}"); |
| 32 | break; | 63 | break; |
| 33 | } | 64 | } |
| 34 | } | 65 | } |
| 35 | 66 | ||
| 36 | let line = line.trim(); | 67 | for push in core.poll(&state) { |
| 37 | if line.is_empty() { | 68 | if let Err(error) = stream.write_line(&push) { |
| 38 | continue; | 69 | eprintln!("Write error: {error}"); |
| 39 | } | 70 | break 'connection; |
| 40 | 71 | } | |
| 41 | let user = state.user().unwrap_or("guest").to_string(); | 72 | let user = state.user().unwrap_or("guest"); |
| 42 | println!("[{user} @ {client_addr}] Received: {line}"); | 73 | println!("[{user} @ {client_addr}] Pushed: {push}"); |
| 43 | let response = core.handle_message(&mut state, line); | ||
| 44 | if let Err(error) = stream.write_line(&response) { | ||
| 45 | eprintln!("Write error: {error}"); | ||
| 46 | break; | ||
| 47 | } | 74 | } |
| 48 | let user = state.user().unwrap_or("guest"); | ||
| 49 | println!("[{user} @ {client_addr}] Sent: {response}"); | ||
| 50 | } | 75 | } |
| 51 | 76 | ||
| 52 | core.disconnect(&state); | 77 | core.disconnect(&state); |
diff --git a/src/bin/server/tls.rs b/src/bin/server/tls.rs index 292ebf7..5d84589 100644 --- a/src/bin/server/tls.rs +++ b/src/bin/server/tls.rs | |||
| @@ -77,6 +77,15 @@ impl TlsStream { | |||
| 77 | pub fn peer_addr(&self) -> std::io::Result<std::net::SocketAddr> { | 77 | pub fn peer_addr(&self) -> std::io::Result<std::net::SocketAddr> { |
| 78 | self.stream.get_ref().peer_addr() | 78 | self.stream.get_ref().peer_addr() |
| 79 | } | 79 | } |
| 80 | |||
| 81 | /// Read timeout for the socket. The connection loop uses a short one so it | ||
| 82 | /// can advance crash rounds while no request is in flight. | ||
| 83 | pub fn set_read_timeout( | ||
| 84 | &self, | ||
| 85 | duration: Option<std::time::Duration>, | ||
| 86 | ) -> std::io::Result<()> { | ||
| 87 | self.stream.get_ref().set_read_timeout(duration) | ||
| 88 | } | ||
| 80 | } | 89 | } |
| 81 | 90 | ||
| 82 | impl Read for TlsStream { | 91 | impl Read for TlsStream { |
diff --git a/src/client/core.rs b/src/client/core.rs index 77761b4..5f8c513 100644 --- a/src/client/core.rs +++ b/src/client/core.rs | |||
| @@ -1,12 +1,14 @@ | |||
| 1 | use crate::client::games::manager::GameManager; | 1 | use crate::client::games::manager::GameManager; |
| 2 | use crate::client::games::response::{ | 2 | use crate::client::games::response::{ |
| 3 | parse_blackjack_response, parse_roulette_response, parse_spin_response, | 3 | parse_blackjack_response, parse_crash_response, parse_roulette_response, parse_spin_response, |
| 4 | }; | 4 | }; |
| 5 | use crate::client::ui::{ | 5 | use crate::client::ui::{ |
| 6 | command_names, completion_lines, display_width, help_lines, input_cursor_column, | 6 | command_names, completion_lines, display_width, help_lines, input_cursor_column, |
| 7 | parse_bet_arg, record_history, | 7 | parse_bet_arg, record_history, |
| 8 | }; | 8 | }; |
| 9 | use crate::crash::{multiplier_at, CrashState}; | ||
| 9 | use crate::roulette::{Bets, NumberBet}; | 10 | use crate::roulette::{Bets, NumberBet}; |
| 11 | use std::time::{Duration, Instant}; | ||
| 10 | 12 | ||
| 11 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 13 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 12 | pub enum Key { | 14 | pub enum Key { |
| @@ -66,6 +68,9 @@ enum Pending { | |||
| 66 | BlackjackStart, | 68 | BlackjackStart, |
| 67 | BlackjackAction, | 69 | BlackjackAction, |
| 68 | BlackjackForfeit, | 70 | BlackjackForfeit, |
| 71 | CrashStart, | ||
| 72 | CrashCashout, | ||
| 73 | CrashForfeit, | ||
| 69 | Ping, | 74 | Ping, |
| 70 | Echo, | 75 | Echo, |
| 71 | Info, | 76 | Info, |
| @@ -90,6 +95,8 @@ pub struct ClientCore { | |||
| 90 | balance: Option<i64>, | 95 | balance: Option<i64>, |
| 91 | host: Option<String>, | 96 | host: Option<String>, |
| 92 | fingerprint: Option<String>, | 97 | fingerprint: Option<String>, |
| 98 | /// Local anchor for the crash multiplier, set when a round starts. | ||
| 99 | crash_started: Option<Instant>, | ||
| 93 | } | 100 | } |
| 94 | 101 | ||
| 95 | impl ClientCore { | 102 | impl ClientCore { |
| @@ -105,6 +112,7 @@ impl ClientCore { | |||
| 105 | balance: None, | 112 | balance: None, |
| 106 | host: None, | 113 | host: None, |
| 107 | fingerprint: None, | 114 | fingerprint: None, |
| 115 | crash_started: None, | ||
| 108 | } | 116 | } |
| 109 | } | 117 | } |
| 110 | 118 | ||
| @@ -113,6 +121,7 @@ impl ClientCore { | |||
| 113 | if !connected { | 121 | if !connected { |
| 114 | self.logged_in = None; | 122 | self.logged_in = None; |
| 115 | self.pending = None; | 123 | self.pending = None; |
| 124 | self.crash_started = None; | ||
| 116 | } | 125 | } |
| 117 | } | 126 | } |
| 118 | 127 | ||
| @@ -226,6 +235,49 @@ impl ClientCore { | |||
| 226 | self.board_effects() | 235 | self.board_effects() |
| 227 | } | 236 | } |
| 228 | }, | 237 | }, |
| 238 | Some(Pending::CrashStart) => match parse_crash_response(reply) { | ||
| 239 | Ok(state) => self.apply_crash(state), | ||
| 240 | Err(message) => { | ||
| 241 | self.game.set_error(message); | ||
| 242 | self.board_effects() | ||
| 243 | } | ||
| 244 | }, | ||
| 245 | Some(Pending::CrashCashout) => match parse_crash_response(reply) { | ||
| 246 | Ok(state) => self.apply_crash(state), | ||
| 247 | Err(message) => { | ||
| 248 | // A pushed bust can settle the round before this reply | ||
| 249 | // arrives. The late request must not overwrite the result. | ||
| 250 | if self.game.crash_status() != "running" { | ||
| 251 | return self.board_effects(); | ||
| 252 | } | ||
| 253 | self.game.set_error(message); | ||
| 254 | self.board_effects() | ||
| 255 | } | ||
| 256 | }, | ||
| 257 | Some(Pending::CrashForfeit) => match parse_crash_response(reply) { | ||
| 258 | Ok(state) => { | ||
| 259 | self.crash_started = None; | ||
| 260 | self.game.end_game(); | ||
| 261 | let message = format!( | ||
| 262 | "Game exited. Round forfeited ({} PLN lost).", | ||
| 263 | state.bet | ||
| 264 | ); | ||
| 265 | vec![Effect::ClearRegion, Effect::Print(vec![message])] | ||
| 266 | } | ||
| 267 | Err(_) if self.game.crash_status() != "running" => { | ||
| 268 | // The round ended while the forfeit was in flight. | ||
| 269 | self.crash_started = None; | ||
| 270 | self.game.end_game(); | ||
| 271 | vec![ | ||
| 272 | Effect::ClearRegion, | ||
| 273 | Effect::Print(vec!["Game exited.".to_string()]), | ||
| 274 | ] | ||
| 275 | } | ||
| 276 | Err(message) => { | ||
| 277 | self.game.set_error(message); | ||
| 278 | self.board_effects() | ||
| 279 | } | ||
| 280 | }, | ||
| 229 | Some(Pending::Ping) => { | 281 | Some(Pending::Ping) => { |
| 230 | if reply.trim() == "PONG" { | 282 | if reply.trim() == "PONG" { |
| 231 | print_line("Pong") | 283 | print_line("Pong") |
| @@ -270,6 +322,9 @@ impl ClientCore { | |||
| 270 | if game_type == Some("blackjack") { | 322 | if game_type == Some("blackjack") { |
| 271 | return self.handle_blackjack_key(key); | 323 | return self.handle_blackjack_key(key); |
| 272 | } | 324 | } |
| 325 | if game_type == Some("crash") { | ||
| 326 | return self.handle_crash_key(key); | ||
| 327 | } | ||
| 273 | 328 | ||
| 274 | match key { | 329 | match key { |
| 275 | Key::CtrlC => vec![Effect::Quit], | 330 | Key::CtrlC => vec![Effect::Quit], |
| @@ -559,6 +614,138 @@ impl ClientCore { | |||
| 559 | ))] | 614 | ))] |
| 560 | } | 615 | } |
| 561 | 616 | ||
| 617 | /// Redraw the rising multiplier. Called by the terminal loop on a timer. | ||
| 618 | pub fn tick(&mut self) -> Vec<Effect> { | ||
| 619 | let Some(started) = self.crash_started else { | ||
| 620 | return Vec::new(); | ||
| 621 | }; | ||
| 622 | self.tick_at(started.elapsed()) | ||
| 623 | } | ||
| 624 | |||
| 625 | pub fn tick_at(&mut self, elapsed: Duration) -> Vec<Effect> { | ||
| 626 | if self.crash_started.is_none() { | ||
| 627 | return Vec::new(); | ||
| 628 | } | ||
| 629 | if self | ||
| 630 | .game | ||
| 631 | .get_current_state() | ||
| 632 | .map(|state| state.game_type.as_str()) | ||
| 633 | != Some("crash") | ||
| 634 | { | ||
| 635 | return Vec::new(); | ||
| 636 | } | ||
| 637 | self.game.set_crash_multiplier(multiplier_at(elapsed)); | ||
| 638 | self.board_effects() | ||
| 639 | } | ||
| 640 | |||
| 641 | /// True while the server may push a crash event between requests. | ||
| 642 | pub fn expects_push(&self) -> bool { | ||
| 643 | self.crash_started.is_some() | ||
| 644 | } | ||
| 645 | |||
| 646 | pub fn is_async(&self, line: &str) -> bool { | ||
| 647 | line.trim_start().starts_with("CRASH_BUSTED ") | ||
| 648 | } | ||
| 649 | |||
| 650 | /// Handle a server-initiated line. Returns `None` when the line is a normal | ||
| 651 | /// request/response reply and belongs to `handle_reply`. | ||
| 652 | pub fn handle_async_line(&mut self, line: &str) -> Option<Vec<Effect>> { | ||
| 653 | if !self.is_async(line) { | ||
| 654 | return None; | ||
| 655 | } | ||
| 656 | match parse_crash_response(line) { | ||
| 657 | Ok(state) => Some(self.apply_crash(state)), | ||
| 658 | Err(message) => { | ||
| 659 | self.game.set_error(message); | ||
| 660 | Some(self.board_effects()) | ||
| 661 | } | ||
| 662 | } | ||
| 663 | } | ||
| 664 | |||
| 665 | fn apply_crash(&mut self, state: CrashState) -> Vec<Effect> { | ||
| 666 | self.balance = Some(state.balance); | ||
| 667 | if state.is_running() { | ||
| 668 | if self.crash_started.is_none() { | ||
| 669 | self.crash_started = Some(Instant::now()); | ||
| 670 | } | ||
| 671 | } else { | ||
| 672 | self.crash_started = None; | ||
| 673 | } | ||
| 674 | self.game.apply_crash_state(state); | ||
| 675 | self.board_effects() | ||
| 676 | } | ||
| 677 | |||
| 678 | fn handle_crash_key(&mut self, key: Key) -> Vec<Effect> { | ||
| 679 | let status = self.game.crash_status(); | ||
| 680 | let running = status == "running"; | ||
| 681 | |||
| 682 | match key { | ||
| 683 | Key::CtrlC => vec![Effect::Quit], | ||
| 684 | Key::Char('q') => { | ||
| 685 | if running { | ||
| 686 | let Some(session) = self.game.crash_session_id() else { | ||
| 687 | return Vec::new(); | ||
| 688 | }; | ||
| 689 | self.pending = Some(Pending::CrashForfeit); | ||
| 690 | vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))] | ||
| 691 | } else { | ||
| 692 | self.crash_started = None; | ||
| 693 | self.game.end_game(); | ||
| 694 | vec![ | ||
| 695 | Effect::ClearRegion, | ||
| 696 | Effect::Print(vec!["Game exited.".to_string()]), | ||
| 697 | ] | ||
| 698 | } | ||
| 699 | } | ||
| 700 | Key::Char(' ') if !running && self.pending.is_none() => self.start_crash(), | ||
| 701 | Key::Char('c') if running && self.pending.is_none() => self.cash_out_crash(), | ||
| 702 | Key::Char('+') | Key::Char('=') if !running => { | ||
| 703 | let _ = self.game.adjust_bet(10); | ||
| 704 | self.board_effects() | ||
| 705 | } | ||
| 706 | Key::Char('-') if !running => { | ||
| 707 | let _ = self.game.adjust_bet(-10); | ||
| 708 | self.board_effects() | ||
| 709 | } | ||
| 710 | _ => Vec::new(), | ||
| 711 | } | ||
| 712 | } | ||
| 713 | |||
| 714 | fn start_crash(&mut self) -> Vec<Effect> { | ||
| 715 | let status = self.game.crash_status(); | ||
| 716 | if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") { | ||
| 717 | return Vec::new(); | ||
| 718 | } | ||
| 719 | let Some(request) = self.game.spin_request() else { | ||
| 720 | return Vec::new(); | ||
| 721 | }; | ||
| 722 | if let Some(balance) = self.balance { | ||
| 723 | let bet = self | ||
| 724 | .game | ||
| 725 | .get_current_state() | ||
| 726 | .map(|state| state.bet_amount) | ||
| 727 | .unwrap_or(0); | ||
| 728 | if bet as i64 > balance { | ||
| 729 | self.game.set_error("Insufficient balance"); | ||
| 730 | return self.board_effects(); | ||
| 731 | } | ||
| 732 | } | ||
| 733 | |||
| 734 | self.game.set_crash_status("starting"); | ||
| 735 | self.pending = Some(Pending::CrashStart); | ||
| 736 | let mut effects = self.board_effects(); | ||
| 737 | effects.push(Effect::SendLine(request)); | ||
| 738 | effects | ||
| 739 | } | ||
| 740 | |||
| 741 | fn cash_out_crash(&mut self) -> Vec<Effect> { | ||
| 742 | let Some(session) = self.game.crash_session_id() else { | ||
| 743 | return Vec::new(); | ||
| 744 | }; | ||
| 745 | self.pending = Some(Pending::CrashCashout); | ||
| 746 | vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))] | ||
| 747 | } | ||
| 748 | |||
| 562 | fn submit(&mut self) -> Vec<Effect> { | 749 | fn submit(&mut self) -> Vec<Effect> { |
| 563 | let line = self.input.trim().to_string(); | 750 | let line = self.input.trim().to_string(); |
| 564 | self.input.clear(); | 751 | self.input.clear(); |
| @@ -616,6 +803,7 @@ impl ClientCore { | |||
| 616 | self.connected = false; | 803 | self.connected = false; |
| 617 | self.logged_in = None; | 804 | self.logged_in = None; |
| 618 | self.pending = None; | 805 | self.pending = None; |
| 806 | self.crash_started = None; | ||
| 619 | self.game.end_game(); | 807 | self.game.end_game(); |
| 620 | vec![ | 808 | vec![ |
| 621 | Effect::ClearRegion, | 809 | Effect::ClearRegion, |
diff --git a/src/client/games/crash.rs b/src/client/games/crash.rs new file mode 100644 index 0000000..19733f2 --- /dev/null +++ b/src/client/games/crash.rs | |||
| @@ -0,0 +1,91 @@ | |||
| 1 | use super::traits::{Game, GameState}; | ||
| 2 | |||
| 3 | pub const CRASH_BOARD_LINES: usize = 6; | ||
| 4 | |||
| 5 | pub struct CrashGame; | ||
| 6 | |||
| 7 | impl CrashGame { | ||
| 8 | pub fn new() -> Self { | ||
| 9 | Self | ||
| 10 | } | ||
| 11 | |||
| 12 | fn value<'a>(state: &'a GameState, key: &str) -> Option<&'a str> { | ||
| 13 | state.data.get(key).map(String::as_str) | ||
| 14 | } | ||
| 15 | |||
| 16 | fn status_text(state: &GameState) -> String { | ||
| 17 | let status = Self::value(state, "status").unwrap_or("ready"); | ||
| 18 | match status { | ||
| 19 | "starting" => "Starting...".to_string(), | ||
| 20 | "running" => "Rising... cash out before it crashes.".to_string(), | ||
| 21 | "cashed" => format!( | ||
| 22 | "Cashed out at {}x Won {} PLN", | ||
| 23 | Self::value(state, "multiplier").unwrap_or("1.00"), | ||
| 24 | Self::value(state, "payout").unwrap_or("0") | ||
| 25 | ), | ||
| 26 | "crashed" => format!( | ||
| 27 | "CRASHED at {}x Bet lost", | ||
| 28 | Self::value(state, "crash_point").unwrap_or("?") | ||
| 29 | ), | ||
| 30 | "forfeit" => "Forfeited. Bet lost.".to_string(), | ||
| 31 | _ => "Press [SPACE] to start.".to_string(), | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | fn controls(state: &GameState) -> &'static str { | ||
| 36 | if Self::value(state, "status") == Some("running") { | ||
| 37 | "[C] Cash out [Q] Forfeit" | ||
| 38 | } else { | ||
| 39 | "[SPACE] Play [+/=/-] Bet [Q] Quit" | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | impl Game for CrashGame { | ||
| 45 | fn get_name(&self) -> &'static str { | ||
| 46 | "Crash" | ||
| 47 | } | ||
| 48 | |||
| 49 | fn render(&self, state: &GameState) -> String { | ||
| 50 | let balance = Self::value(state, "balance").unwrap_or("?"); | ||
| 51 | let multiplier = Self::value(state, "multiplier").unwrap_or("1.00"); | ||
| 52 | let potential = Self::value(state, "potential") | ||
| 53 | .map(str::to_string) | ||
| 54 | .unwrap_or_else(|| state.bet_amount.to_string()); | ||
| 55 | |||
| 56 | if let Some(error) = Self::value(state, "error") { | ||
| 57 | return format!( | ||
| 58 | "=== CRASH ===\r\nBalance: {balance} PLN Bet: {} PLN\r\n\r\nERROR: {error}\r\n\r\n[SPACE] Play [+/=/-] Bet [Q] Quit", | ||
| 59 | state.bet_amount | ||
| 60 | ); | ||
| 61 | } | ||
| 62 | |||
| 63 | format!( | ||
| 64 | "=== CRASH ===\r\nBalance: {} PLN Bet: {} PLN\r\nMultiplier: {}x\r\nPotential: {} PLN\r\n{}\r\n{}", | ||
| 65 | balance, | ||
| 66 | state.bet_amount, | ||
| 67 | multiplier, | ||
| 68 | potential, | ||
| 69 | Self::status_text(state), | ||
| 70 | Self::controls(state) | ||
| 71 | ) | ||
| 72 | } | ||
| 73 | |||
| 74 | fn render_lines(&self, _state: &GameState) -> usize { | ||
| 75 | CRASH_BOARD_LINES | ||
| 76 | } | ||
| 77 | |||
| 78 | fn get_help(&self) -> String { | ||
| 79 | "CRASH\n\ | ||
| 80 | =====\n\ | ||
| 81 | The multiplier rises from 1.00x and crashes at a hidden point.\n\ | ||
| 82 | Cash out before it crashes to win bet x multiplier.\n\ | ||
| 83 | A round can crash at 1.00x, so an instant cashout still risks the bet.\n\ | ||
| 84 | \n\ | ||
| 85 | C - cash out at the server's current multiplier\n\ | ||
| 86 | SPACE - start the next round\n\ | ||
| 87 | +/- - change the next round's bet\n\ | ||
| 88 | Q - forfeit an active round (bet is lost), or leave when idle" | ||
| 89 | .to_string() | ||
| 90 | } | ||
| 91 | } | ||
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs index 4e0218b..4d9e6a8 100644 --- a/src/client/games/manager.rs +++ b/src/client/games/manager.rs | |||
| @@ -1,9 +1,11 @@ | |||
| 1 | use super::blackjack::BlackjackGame; | 1 | use super::blackjack::BlackjackGame; |
| 2 | use super::crash::CrashGame; | ||
| 2 | use super::response::{RouletteResult, SpinResult}; | 3 | use super::response::{RouletteResult, SpinResult}; |
| 3 | use super::roulette::RouletteGame; | 4 | use super::roulette::RouletteGame; |
| 4 | use super::slots::SlotsGame; | 5 | use super::slots::SlotsGame; |
| 5 | use super::traits::{Game, GameError, GamePhase, GameState}; | 6 | use super::traits::{Game, GameError, GamePhase, GameState}; |
| 6 | use crate::blackjack::BlackjackState; | 7 | use crate::blackjack::BlackjackState; |
| 8 | use crate::crash::{payout, CrashState}; | ||
| 7 | use crate::roulette::Bets; | 9 | use crate::roulette::Bets; |
| 8 | use std::collections::HashMap; | 10 | use std::collections::HashMap; |
| 9 | 11 | ||
| @@ -18,6 +20,7 @@ impl GameManager { | |||
| 18 | games.insert("slots".to_string(), Box::new(SlotsGame::new())); | 20 | games.insert("slots".to_string(), Box::new(SlotsGame::new())); |
| 19 | games.insert("roulette".to_string(), Box::new(RouletteGame::new())); | 21 | games.insert("roulette".to_string(), Box::new(RouletteGame::new())); |
| 20 | games.insert("blackjack".to_string(), Box::new(BlackjackGame::new())); | 22 | games.insert("blackjack".to_string(), Box::new(BlackjackGame::new())); |
| 23 | games.insert("crash".to_string(), Box::new(CrashGame::new())); | ||
| 21 | 24 | ||
| 22 | Self { | 25 | Self { |
| 23 | current_session: None, | 26 | current_session: None, |
| @@ -57,6 +60,10 @@ impl GameManager { | |||
| 57 | } else if game_type == "blackjack" { | 60 | } else if game_type == "blackjack" { |
| 58 | data.insert("status".to_string(), "ready".to_string()); | 61 | data.insert("status".to_string(), "ready".to_string()); |
| 59 | 10 | 62 | 10 |
| 63 | } else if game_type == "crash" { | ||
| 64 | data.insert("status".to_string(), "ready".to_string()); | ||
| 65 | data.insert("multiplier".to_string(), "1.00".to_string()); | ||
| 66 | 10 | ||
| 60 | } else { | 67 | } else { |
| 61 | 10 | 68 | 10 |
| 62 | }; | 69 | }; |
| @@ -173,11 +180,19 @@ impl GameManager { | |||
| 173 | session.data.remove("winnings"); | 180 | session.data.remove("winnings"); |
| 174 | session.data.remove("number"); | 181 | session.data.remove("number"); |
| 175 | session.data.remove("color"); | 182 | session.data.remove("color"); |
| 183 | session.data.remove("payout"); | ||
| 184 | session.data.remove("crash_point"); | ||
| 176 | if session.game_type == "roulette" { | 185 | if session.game_type == "roulette" { |
| 177 | session | 186 | session |
| 178 | .data | 187 | .data |
| 179 | .insert("status".to_string(), "place".to_string()); | 188 | .insert("status".to_string(), "place".to_string()); |
| 180 | } | 189 | } |
| 190 | if session.game_type == "crash" { | ||
| 191 | session | ||
| 192 | .data | ||
| 193 | .insert("status".to_string(), "ready".to_string()); | ||
| 194 | session.data.remove("session_id"); | ||
| 195 | } | ||
| 181 | session.data.insert("error".to_string(), message.into()); | 196 | session.data.insert("error".to_string(), message.into()); |
| 182 | } | 197 | } |
| 183 | 198 | ||
| @@ -309,6 +324,81 @@ impl GameManager { | |||
| 309 | } | 324 | } |
| 310 | } | 325 | } |
| 311 | 326 | ||
| 327 | pub fn apply_crash_state(&mut self, state: CrashState) { | ||
| 328 | let Some(session) = self.current_session.as_mut() else { | ||
| 329 | return; | ||
| 330 | }; | ||
| 331 | let running = state.is_running(); | ||
| 332 | session.phase = if running { | ||
| 333 | GamePhase::Playing | ||
| 334 | } else { | ||
| 335 | GamePhase::Finished | ||
| 336 | }; | ||
| 337 | session.bet_amount = state.bet; | ||
| 338 | session.data.remove("error"); | ||
| 339 | for key in ["crash_point", "payout"] { | ||
| 340 | session.data.remove(key); | ||
| 341 | } | ||
| 342 | session.data.insert("status".to_string(), state.phase.clone()); | ||
| 343 | session | ||
| 344 | .data | ||
| 345 | .insert("session_id".to_string(), state.session_id); | ||
| 346 | session | ||
| 347 | .data | ||
| 348 | .insert("balance".to_string(), state.balance.to_string()); | ||
| 349 | session | ||
| 350 | .data | ||
| 351 | .insert("multiplier".to_string(), format!("{:.2}", state.multiplier)); | ||
| 352 | session | ||
| 353 | .data | ||
| 354 | .insert("potential".to_string(), state.potential.to_string()); | ||
| 355 | session | ||
| 356 | .data | ||
| 357 | .insert("payout".to_string(), state.payout.to_string()); | ||
| 358 | if let Some(crash_point) = state.crash_point { | ||
| 359 | session | ||
| 360 | .data | ||
| 361 | .insert("crash_point".to_string(), format!("{crash_point:.2}")); | ||
| 362 | } | ||
| 363 | } | ||
| 364 | |||
| 365 | /// Redraw the live multiplier from the client's local clock. | ||
| 366 | pub fn set_crash_multiplier(&mut self, multiplier: f64) { | ||
| 367 | let Some(session) = self.current_session.as_mut() else { | ||
| 368 | return; | ||
| 369 | }; | ||
| 370 | let bet = session.bet_amount; | ||
| 371 | session | ||
| 372 | .data | ||
| 373 | .insert("multiplier".to_string(), format!("{multiplier:.2}")); | ||
| 374 | session | ||
| 375 | .data | ||
| 376 | .insert("potential".to_string(), payout(bet, multiplier).to_string()); | ||
| 377 | } | ||
| 378 | |||
| 379 | pub fn set_crash_status(&mut self, status: &str) { | ||
| 380 | if let Some(session) = self.current_session.as_mut() { | ||
| 381 | session | ||
| 382 | .data | ||
| 383 | .insert("status".to_string(), status.to_string()); | ||
| 384 | } | ||
| 385 | } | ||
| 386 | |||
| 387 | pub fn crash_status(&self) -> String { | ||
| 388 | self.current_session | ||
| 389 | .as_ref() | ||
| 390 | .and_then(|session| session.data.get("status")) | ||
| 391 | .cloned() | ||
| 392 | .unwrap_or_else(|| "ready".to_string()) | ||
| 393 | } | ||
| 394 | |||
| 395 | pub fn crash_session_id(&self) -> Option<String> { | ||
| 396 | self.current_session | ||
| 397 | .as_ref() | ||
| 398 | .and_then(|session| session.data.get("session_id")) | ||
| 399 | .cloned() | ||
| 400 | } | ||
| 401 | |||
| 312 | pub fn get_current_game(&self) -> Option<&dyn Game> { | 402 | pub fn get_current_game(&self) -> Option<&dyn Game> { |
| 313 | self.current_session | 403 | self.current_session |
| 314 | .as_ref() | 404 | .as_ref() |
diff --git a/src/client/games/mod.rs b/src/client/games/mod.rs index 40ba32a..e966a25 100644 --- a/src/client/games/mod.rs +++ b/src/client/games/mod.rs | |||
| @@ -1,4 +1,5 @@ | |||
| 1 | pub mod blackjack; | 1 | pub mod blackjack; |
| 2 | pub mod crash; | ||
| 2 | pub mod manager; | 3 | pub mod manager; |
| 3 | pub mod response; | 4 | pub mod response; |
| 4 | pub mod roulette; | 5 | pub mod roulette; |
diff --git a/src/client/games/response.rs b/src/client/games/response.rs index d9883f7..71e1fed 100644 --- a/src/client/games/response.rs +++ b/src/client/games/response.rs | |||
| @@ -163,3 +163,24 @@ pub fn parse_blackjack_response( | |||
| 163 | .ok_or_else(|| format!("Unexpected server response: {response}"))?; | 163 | .ok_or_else(|| format!("Unexpected server response: {response}"))?; |
| 164 | serde_json::from_str(raw).map_err(|error| format!("Invalid blackjack state: {error}")) | 164 | serde_json::from_str(raw).map_err(|error| format!("Invalid blackjack state: {error}")) |
| 165 | } | 165 | } |
| 166 | |||
| 167 | pub fn parse_crash_response(response: &str) -> Result<crate::crash::CrashState, String> { | ||
| 168 | let response = response.trim(); | ||
| 169 | if response.is_empty() { | ||
| 170 | return Err("No response from server".to_string()); | ||
| 171 | } | ||
| 172 | if let Some(message) = response.strip_prefix("ERROR") { | ||
| 173 | let message = message.trim(); | ||
| 174 | return Err(if message.is_empty() { | ||
| 175 | "Server error".to_string() | ||
| 176 | } else { | ||
| 177 | message.to_string() | ||
| 178 | }); | ||
| 179 | } | ||
| 180 | |||
| 181 | let raw = ["CRASH_START ", "CRASH_STATE ", "CRASH_BUSTED "] | ||
| 182 | .iter() | ||
| 183 | .find_map(|prefix| response.strip_prefix(prefix)) | ||
| 184 | .ok_or_else(|| format!("Unexpected server response: {response}"))?; | ||
| 185 | serde_json::from_str(raw).map_err(|error| format!("Invalid crash state: {error}")) | ||
| 186 | } | ||
diff --git a/src/crash.rs b/src/crash.rs new file mode 100644 index 0000000..55aab19 --- /dev/null +++ b/src/crash.rs | |||
| @@ -0,0 +1,87 @@ | |||
| 1 | //! Shared crash game math and wire state. | ||
| 2 | //! | ||
| 3 | //! The multiplier is a pure function of elapsed time, so the client can draw it | ||
| 4 | //! locally. The crash point is drawn up front and never leaves the server until | ||
| 5 | //! the round ends; the payout at cashout is always computed from the server's | ||
| 6 | //! clock, never from anything the client sends. | ||
| 7 | |||
| 8 | use serde::{Deserialize, Serialize}; | ||
| 9 | use std::time::Duration; | ||
| 10 | |||
| 11 | /// `exp(GROWTH_RATE * t)` doubles the multiplier every five seconds. | ||
| 12 | pub const GROWTH_RATE: f64 = 0.138_629_436_111_989_07; // ln(2) / 5 | ||
| 13 | /// House edge baked into the crash-point distribution. | ||
| 14 | pub const HOUSE_EDGE: f64 = 0.01; | ||
| 15 | /// Liability cap. At the cap `P(crash >= m)` stops following `edge / m`. | ||
| 16 | pub const MAX_MULTIPLIER: f64 = 1000.0; | ||
| 17 | |||
| 18 | /// Multiplier after `elapsed` seconds of a round. | ||
| 19 | pub fn multiplier_at(elapsed: Duration) -> f64 { | ||
| 20 | multiplier_at_secs(elapsed.as_secs_f64()) | ||
| 21 | } | ||
| 22 | |||
| 23 | pub fn multiplier_at_secs(seconds: f64) -> f64 { | ||
| 24 | if !seconds.is_finite() || seconds <= 0.0 { | ||
| 25 | return 1.0; | ||
| 26 | } | ||
| 27 | (GROWTH_RATE * seconds) | ||
| 28 | .exp() | ||
| 29 | .clamp(1.0, MAX_MULTIPLIER) | ||
| 30 | } | ||
| 31 | |||
| 32 | /// How long a round runs before reaching `multiplier`. | ||
| 33 | pub fn time_for_multiplier(multiplier: f64) -> Duration { | ||
| 34 | let multiplier = multiplier.clamp(1.0, MAX_MULTIPLIER); | ||
| 35 | Duration::from_secs_f64(multiplier.ln() / GROWTH_RATE) | ||
| 36 | } | ||
| 37 | |||
| 38 | /// Crash point for a uniform draw in `[0, 1)`. | ||
| 39 | /// | ||
| 40 | /// `crash = (1 - edge) / (1 - u)` gives `P(crash >= m) = edge_keep / m` for | ||
| 41 | /// every `m` in `[1, MAX_MULTIPLIER]`, so a fixed-target cashout returns | ||
| 42 | /// `(1 - edge)` of the stake on average. | ||
| 43 | pub fn crash_point_from_uniform(u: f64) -> f64 { | ||
| 44 | let u = if u.is_finite() { | ||
| 45 | u.clamp(0.0, 0.999_999_999) | ||
| 46 | } else { | ||
| 47 | 0.0 | ||
| 48 | }; | ||
| 49 | ((1.0 - HOUSE_EDGE) / (1.0 - u)).clamp(1.0, MAX_MULTIPLIER) | ||
| 50 | } | ||
| 51 | |||
| 52 | /// Whole-PLN cashout value. Balances are integers, so the payout is floored. | ||
| 53 | pub fn payout(bet: u64, multiplier: f64) -> u64 { | ||
| 54 | if bet == 0 || !multiplier.is_finite() || multiplier <= 0.0 { | ||
| 55 | return 0; | ||
| 56 | } | ||
| 57 | let value = bet as f64 * multiplier.min(MAX_MULTIPLIER); | ||
| 58 | if value >= u64::MAX as f64 { | ||
| 59 | u64::MAX | ||
| 60 | } else { | ||
| 61 | value.floor() as u64 | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | /// Round state as sent to the client. | ||
| 66 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 67 | pub struct CrashState { | ||
| 68 | pub session_id: String, | ||
| 69 | /// `running`, `cashed`, `crashed` or `forfeit`. | ||
| 70 | pub phase: String, | ||
| 71 | pub bet: u64, | ||
| 72 | pub balance: i64, | ||
| 73 | /// Live multiplier while running, otherwise the value the round ended at. | ||
| 74 | pub multiplier: f64, | ||
| 75 | /// `floor(bet * multiplier)` for the value above. | ||
| 76 | pub potential: u64, | ||
| 77 | /// Hidden until the round ends. | ||
| 78 | pub crash_point: Option<f64>, | ||
| 79 | pub payout: u64, | ||
| 80 | pub result: String, | ||
| 81 | } | ||
| 82 | |||
| 83 | impl CrashState { | ||
| 84 | pub fn is_running(&self) -> bool { | ||
| 85 | self.phase == "running" | ||
| 86 | } | ||
| 87 | } | ||
| @@ -7,6 +7,7 @@ | |||
| 7 | 7 | ||
| 8 | pub mod blackjack; | 8 | pub mod blackjack; |
| 9 | pub mod client; | 9 | pub mod client; |
| 10 | pub mod crash; | ||
| 10 | pub mod protocol; | 11 | pub mod protocol; |
| 11 | pub mod roulette; | 12 | pub mod roulette; |
| 12 | pub mod server; | 13 | pub mod server; |
diff --git a/src/server/core.rs b/src/server/core.rs index 259f936..d4c95f6 100644 --- a/src/server/core.rs +++ b/src/server/core.rs | |||
| @@ -1,10 +1,13 @@ | |||
| 1 | use bcrypt::{hash, verify, DEFAULT_COST}; | 1 | use bcrypt::{hash, verify, DEFAULT_COST}; |
| 2 | use rusqlite::Connection; | 2 | use rusqlite::Connection; |
| 3 | use std::collections::HashMap; | 3 | use std::collections::HashMap; |
| 4 | use std::sync::Arc; | ||
| 4 | 5 | ||
| 5 | use crate::blackjack::BlackjackState; | 6 | use crate::blackjack::BlackjackState; |
| 7 | use crate::crash::CrashState; | ||
| 6 | use crate::server::database; | 8 | use crate::server::database; |
| 7 | use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; | 9 | use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; |
| 10 | use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock}; | ||
| 8 | use crate::server::games::registry::GameRegistry; | 11 | use crate::server::games::registry::GameRegistry; |
| 9 | use crate::server::games::session::SessionManager; | 12 | use crate::server::games::session::SessionManager; |
| 10 | use crate::server::games::traits::{GameError, GamePhase, GameState}; | 13 | use crate::server::games::traits::{GameError, GamePhase, GameState}; |
| @@ -26,6 +29,8 @@ pub struct ServerCore { | |||
| 26 | registry: GameRegistry, | 29 | registry: GameRegistry, |
| 27 | bcrypt_cost: u32, | 30 | bcrypt_cost: u32, |
| 28 | blackjack: BlackjackGame, | 31 | blackjack: BlackjackGame, |
| 32 | crash: CrashGame, | ||
| 33 | clock: Arc<dyn Clock>, | ||
| 29 | } | 34 | } |
| 30 | 35 | ||
| 31 | impl ServerCore { | 36 | impl ServerCore { |
| @@ -44,6 +49,8 @@ impl ServerCore { | |||
| 44 | registry, | 49 | registry, |
| 45 | bcrypt_cost, | 50 | bcrypt_cost, |
| 46 | blackjack: BlackjackGame::new(), | 51 | blackjack: BlackjackGame::new(), |
| 52 | crash: CrashGame::new(), | ||
| 53 | clock: Arc::new(RealClock::new()), | ||
| 47 | } | 54 | } |
| 48 | } | 55 | } |
| 49 | 56 | ||
| @@ -53,6 +60,22 @@ impl ServerCore { | |||
| 53 | self | 60 | self |
| 54 | } | 61 | } |
| 55 | 62 | ||
| 63 | /// Test/utility hook: queue the next crash-point draws. | ||
| 64 | pub fn with_crash_uniforms(mut self, uniforms: impl IntoIterator<Item = f64>) -> Self { | ||
| 65 | self.crash = CrashGame::with_uniforms(uniforms); | ||
| 66 | self | ||
| 67 | } | ||
| 68 | |||
| 69 | pub fn with_crash_uniform(self, uniform: f64) -> Self { | ||
| 70 | self.with_crash_uniforms([uniform]) | ||
| 71 | } | ||
| 72 | |||
| 73 | /// Test/utility hook: replace the monotonic clock driving crash rounds. | ||
| 74 | pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self { | ||
| 75 | self.clock = clock; | ||
| 76 | self | ||
| 77 | } | ||
| 78 | |||
| 56 | pub fn open(path: &str) -> rusqlite::Result<Self> { | 79 | pub fn open(path: &str) -> rusqlite::Result<Self> { |
| 57 | let conn = Connection::open(path)?; | 80 | let conn = Connection::open(path)?; |
| 58 | database::init_db(&conn); | 81 | database::init_db(&conn); |
| @@ -98,6 +121,8 @@ impl ServerCore { | |||
| 98 | "PLAY_START" => { | 121 | "PLAY_START" => { |
| 99 | if parts.len() == 3 && parts[1] == "blackjack" { | 122 | if parts.len() == 3 && parts[1] == "blackjack" { |
| 100 | self.handle_blackjack_start(state, parts[2]) | 123 | self.handle_blackjack_start(state, parts[2]) |
| 124 | } else if parts.len() == 3 && parts[1] == "crash" { | ||
| 125 | self.handle_crash_start(state, parts[2]) | ||
| 101 | } else if parts.len() != 3 && parts.len() != 4 { | 126 | } else if parts.len() != 3 && parts.len() != 4 { |
| 102 | "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount> [payload]" | 127 | "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount> [payload]" |
| 103 | .to_string() | 128 | .to_string() |
| @@ -105,6 +130,22 @@ impl ServerCore { | |||
| 105 | self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied()) | 130 | self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied()) |
| 106 | } | 131 | } |
| 107 | } | 132 | } |
| 133 | "CRASH_CASHOUT" => { | ||
| 134 | if parts.len() != 2 { | ||
| 135 | "ERROR Invalid CRASH_CASHOUT format. Usage: CRASH_CASHOUT <session>" | ||
| 136 | .to_string() | ||
| 137 | } else { | ||
| 138 | self.handle_crash_end(state, parts[1], false) | ||
| 139 | } | ||
| 140 | } | ||
| 141 | "CRASH_FORFEIT" => { | ||
| 142 | if parts.len() != 2 { | ||
| 143 | "ERROR Invalid CRASH_FORFEIT format. Usage: CRASH_FORFEIT <session>" | ||
| 144 | .to_string() | ||
| 145 | } else { | ||
| 146 | self.handle_crash_end(state, parts[1], true) | ||
| 147 | } | ||
| 148 | } | ||
| 108 | "BLACKJACK_ACTION" => { | 149 | "BLACKJACK_ACTION" => { |
| 109 | if parts.len() != 3 { | 150 | if parts.len() != 3 { |
| 110 | "ERROR Invalid BLACKJACK_ACTION format. Usage: BLACKJACK_ACTION <session> <action>" | 151 | "ERROR Invalid BLACKJACK_ACTION format. Usage: BLACKJACK_ACTION <session> <action>" |
| @@ -371,6 +412,175 @@ impl ServerCore { | |||
| 371 | } | 412 | } |
| 372 | } | 413 | } |
| 373 | 414 | ||
| 415 | fn handle_crash_start(&mut self, state: &ConnectionState, bet_str: &str) -> String { | ||
| 416 | let Some(user) = state.user.as_deref() else { | ||
| 417 | return "ERROR Not logged in".to_string(); | ||
| 418 | }; | ||
| 419 | if self | ||
| 420 | .sessions | ||
| 421 | .get_user_sessions(user) | ||
| 422 | .iter() | ||
| 423 | .any(|session| session.game_type == "crash") | ||
| 424 | { | ||
| 425 | return "ERROR Crash round already in progress".to_string(); | ||
| 426 | } | ||
| 427 | let bet: i64 = match bet_str.parse() { | ||
| 428 | Ok(bet) => bet, | ||
| 429 | Err(_) => return "ERROR Invalid bet amount".to_string(), | ||
| 430 | }; | ||
| 431 | if bet <= 0 { | ||
| 432 | return "ERROR Invalid bet amount".to_string(); | ||
| 433 | } | ||
| 434 | let Some(balance) = database::get_balance(&self.conn, user) else { | ||
| 435 | return "ERROR Could not retrieve balance".to_string(); | ||
| 436 | }; | ||
| 437 | if balance < bet { | ||
| 438 | return "ERROR Insufficient balance".to_string(); | ||
| 439 | } | ||
| 440 | |||
| 441 | // The session is the audit guard for the deduction, so it exists before | ||
| 442 | // the balance is touched. | ||
| 443 | let crash_point = self.crash.next_crash_point(); | ||
| 444 | let round = CrashRound::new(bet as u64, crash_point, self.clock.now()); | ||
| 445 | let mut data = HashMap::new(); | ||
| 446 | data.insert("round".to_string(), round.to_json()); | ||
| 447 | data.insert("balance".to_string(), balance.to_string()); | ||
| 448 | let game_state = GameState { | ||
| 449 | session_id: String::new(), | ||
| 450 | game_type: "crash".to_string(), | ||
| 451 | bet_amount: round.bet, | ||
| 452 | phase: GamePhase::Playing, | ||
| 453 | data, | ||
| 454 | }; | ||
| 455 | let session_id = self | ||
| 456 | .sessions | ||
| 457 | .create_session(user, "crash".to_string(), game_state); | ||
| 458 | |||
| 459 | let balance = match database::debit(&mut self.conn, user, bet) { | ||
| 460 | Ok(balance) => balance, | ||
| 461 | Err(_) => { | ||
| 462 | let _ = self.sessions.remove_session(&session_id); | ||
| 463 | return "ERROR Insufficient balance".to_string(); | ||
| 464 | } | ||
| 465 | }; | ||
| 466 | if let Ok(mut session) = self.sessions.get_session(&session_id) { | ||
| 467 | session | ||
| 468 | .state | ||
| 469 | .data | ||
| 470 | .insert("balance".to_string(), balance.to_string()); | ||
| 471 | let _ = self.sessions.update_session(&session_id, session.state); | ||
| 472 | } | ||
| 473 | |||
| 474 | let state = round.state(&session_id, balance); | ||
| 475 | crash_wire("CRASH_START", &state) | ||
| 476 | } | ||
| 477 | |||
| 478 | /// Cash out or forfeit. The multiplier comes from the server clock at the | ||
| 479 | /// moment the request is handled; the client never supplies one. | ||
| 480 | fn handle_crash_end(&mut self, state: &ConnectionState, session_id: &str, forfeit: bool) -> String { | ||
| 481 | let Some(user) = state.user.as_deref() else { | ||
| 482 | return "ERROR Not logged in".to_string(); | ||
| 483 | }; | ||
| 484 | let Ok(session) = self.sessions.get_session(session_id) else { | ||
| 485 | return "ERROR Session not found".to_string(); | ||
| 486 | }; | ||
| 487 | if session.username != user || session.game_type != "crash" { | ||
| 488 | return "ERROR Invalid session".to_string(); | ||
| 489 | } | ||
| 490 | let Some(raw) = session.state.data.get("round") else { | ||
| 491 | return "ERROR Missing round".to_string(); | ||
| 492 | }; | ||
| 493 | let Ok(mut round) = CrashRound::from_json(raw) else { | ||
| 494 | return "ERROR Invalid round".to_string(); | ||
| 495 | }; | ||
| 496 | if !round.is_running() { | ||
| 497 | return "ERROR Round already ended".to_string(); | ||
| 498 | } | ||
| 499 | |||
| 500 | let elapsed = (self.clock.now() - round.started_at).max(0.0); | ||
| 501 | let multiplier = crate::crash::multiplier_at_secs(elapsed); | ||
| 502 | let payout = if forfeit { | ||
| 503 | round.multiplier = multiplier; | ||
| 504 | round.forfeit(); | ||
| 505 | 0 | ||
| 506 | } else if multiplier < round.crash_point { | ||
| 507 | round.cash_out(multiplier) | ||
| 508 | } else { | ||
| 509 | round.crash(); | ||
| 510 | 0 | ||
| 511 | }; | ||
| 512 | |||
| 513 | match self.settle_crash(user, session_id, &round, payout) { | ||
| 514 | Ok(balance) => { | ||
| 515 | let state = round.state(session_id, balance); | ||
| 516 | crash_wire("CRASH_STATE", &state) | ||
| 517 | } | ||
| 518 | Err(message) => { | ||
| 519 | eprintln!("Crash settlement failed for {user}: {message}"); | ||
| 520 | "ERROR Failed to update balance".to_string() | ||
| 521 | } | ||
| 522 | } | ||
| 523 | } | ||
| 524 | |||
| 525 | /// Server-driven end of a round. `handler` calls this on every socket | ||
| 526 | /// timeout and forwards whatever lines come back. | ||
| 527 | pub fn poll(&mut self, state: &ConnectionState) -> Vec<String> { | ||
| 528 | let Some(user) = state.user.as_deref() else { | ||
| 529 | return Vec::new(); | ||
| 530 | }; | ||
| 531 | let mut out = Vec::new(); | ||
| 532 | for session in self.sessions.get_user_sessions(user) { | ||
| 533 | if session.game_type != "crash" { | ||
| 534 | continue; | ||
| 535 | } | ||
| 536 | let Some(raw) = session.state.data.get("round") else { | ||
| 537 | continue; | ||
| 538 | }; | ||
| 539 | let Ok(mut round) = CrashRound::from_json(raw) else { | ||
| 540 | continue; | ||
| 541 | }; | ||
| 542 | if !round.is_running() { | ||
| 543 | continue; | ||
| 544 | } | ||
| 545 | let elapsed = (self.clock.now() - round.started_at).max(0.0); | ||
| 546 | if crate::crash::multiplier_at_secs(elapsed) < round.crash_point { | ||
| 547 | continue; | ||
| 548 | } | ||
| 549 | round.crash(); | ||
| 550 | match self.settle_crash(user, &session.session_id, &round, 0) { | ||
| 551 | Ok(balance) => { | ||
| 552 | let state = round.state(&session.session_id, balance); | ||
| 553 | out.push(crash_wire("CRASH_BUSTED", &state)); | ||
| 554 | } | ||
| 555 | Err(message) => { | ||
| 556 | eprintln!("Crash push settlement failed for {user}: {message}"); | ||
| 557 | } | ||
| 558 | } | ||
| 559 | } | ||
| 560 | out | ||
| 561 | } | ||
| 562 | |||
| 563 | fn settle_crash( | ||
| 564 | &mut self, | ||
| 565 | user: &str, | ||
| 566 | session_id: &str, | ||
| 567 | round: &CrashRound, | ||
| 568 | payout: u64, | ||
| 569 | ) -> Result<i64, String> { | ||
| 570 | let details = round.to_json(); | ||
| 571 | let result = database::settle_play( | ||
| 572 | &mut self.conn, | ||
| 573 | session_id, | ||
| 574 | user, | ||
| 575 | "crash", | ||
| 576 | round.bet as i64, | ||
| 577 | payout as i64, | ||
| 578 | &details, | ||
| 579 | ); | ||
| 580 | let _ = self.sessions.remove_session(session_id); | ||
| 581 | result.map_err(|error| error.to_string()) | ||
| 582 | } | ||
| 583 | |||
| 374 | fn handle_play_start( | 584 | fn handle_play_start( |
| 375 | &mut self, | 585 | &mut self, |
| 376 | state: &ConnectionState, | 586 | state: &ConnectionState, |
| @@ -527,6 +737,29 @@ impl ServerCore { | |||
| 527 | } | 737 | } |
| 528 | } | 738 | } |
| 529 | } | 739 | } |
| 740 | if session.game_type == "crash" { | ||
| 741 | if let Some(raw) = session.state.data.get("round") { | ||
| 742 | if let Ok(mut round) = CrashRound::from_json(raw) { | ||
| 743 | if round.is_running() { | ||
| 744 | round.forfeit(); | ||
| 745 | let details = round.to_json(); | ||
| 746 | if let Err(error) = database::settle_play( | ||
| 747 | &mut self.conn, | ||
| 748 | &session.session_id, | ||
| 749 | user, | ||
| 750 | "crash", | ||
| 751 | round.bet as i64, | ||
| 752 | 0, | ||
| 753 | &details, | ||
| 754 | ) { | ||
| 755 | eprintln!( | ||
| 756 | "Crash disconnect forfeit failed for {user}: {error}" | ||
| 757 | ); | ||
| 758 | } | ||
| 759 | } | ||
| 760 | } | ||
| 761 | } | ||
| 762 | } | ||
| 530 | let _ = self.sessions.remove_session(&session.session_id); | 763 | let _ = self.sessions.remove_session(&session.session_id); |
| 531 | } | 764 | } |
| 532 | } | 765 | } |
| @@ -562,3 +795,10 @@ fn blackjack_wire(state: &BlackjackState) -> String { | |||
| 562 | serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string()) | 795 | serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string()) |
| 563 | ) | 796 | ) |
| 564 | } | 797 | } |
| 798 | |||
| 799 | fn crash_wire(prefix: &str, state: &CrashState) -> String { | ||
| 800 | format!( | ||
| 801 | "{prefix} {}", | ||
| 802 | serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string()) | ||
| 803 | ) | ||
| 804 | } | ||
diff --git a/src/server/games/crash.rs b/src/server/games/crash.rs new file mode 100644 index 0000000..ba73c7f --- /dev/null +++ b/src/server/games/crash.rs | |||
| @@ -0,0 +1,216 @@ | |||
| 1 | use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; | ||
| 2 | use crate::crash::{crash_point_from_uniform, payout, CrashState}; | ||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | use std::collections::{HashMap, VecDeque}; | ||
| 5 | use std::sync::{Arc, Mutex}; | ||
| 6 | use std::time::Instant; | ||
| 7 | |||
| 8 | /// Monotonic seconds. Injected so tests can drive a round without sleeping. | ||
| 9 | pub trait Clock: Send + Sync { | ||
| 10 | fn now(&self) -> f64; | ||
| 11 | } | ||
| 12 | |||
| 13 | pub struct RealClock { | ||
| 14 | origin: Instant, | ||
| 15 | } | ||
| 16 | |||
| 17 | impl RealClock { | ||
| 18 | pub fn new() -> Self { | ||
| 19 | Self { | ||
| 20 | origin: Instant::now(), | ||
| 21 | } | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | impl Clock for RealClock { | ||
| 26 | fn now(&self) -> f64 { | ||
| 27 | self.origin.elapsed().as_secs_f64() | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | /// A clock a test can move by hand. Clones share the same instant. | ||
| 32 | #[derive(Clone, Default)] | ||
| 33 | pub struct ManualClock { | ||
| 34 | seconds: Arc<Mutex<f64>>, | ||
| 35 | } | ||
| 36 | |||
| 37 | impl ManualClock { | ||
| 38 | pub fn new() -> Self { | ||
| 39 | Self::default() | ||
| 40 | } | ||
| 41 | |||
| 42 | pub fn set(&self, seconds: f64) { | ||
| 43 | *self.seconds.lock().unwrap() = seconds; | ||
| 44 | } | ||
| 45 | |||
| 46 | pub fn advance(&self, seconds: f64) { | ||
| 47 | *self.seconds.lock().unwrap() += seconds; | ||
| 48 | } | ||
| 49 | |||
| 50 | pub fn seconds(&self) -> f64 { | ||
| 51 | *self.seconds.lock().unwrap() | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | impl Clock for ManualClock { | ||
| 56 | fn now(&self) -> f64 { | ||
| 57 | self.seconds() | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | /// One crash round. Lives in the game session's `data` while it runs. | ||
| 62 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 63 | pub struct CrashRound { | ||
| 64 | pub bet: u64, | ||
| 65 | /// Drawn at start, revealed only when the round ends. | ||
| 66 | pub crash_point: f64, | ||
| 67 | pub started_at: f64, | ||
| 68 | /// `running`, `cashed`, `crashed` or `forfeit`. | ||
| 69 | pub phase: String, | ||
| 70 | pub result: String, | ||
| 71 | /// Live while running; frozen at the final value afterwards. | ||
| 72 | pub multiplier: f64, | ||
| 73 | pub payout: u64, | ||
| 74 | } | ||
| 75 | |||
| 76 | impl CrashRound { | ||
| 77 | pub fn new(bet: u64, crash_point: f64, started_at: f64) -> Self { | ||
| 78 | Self { | ||
| 79 | bet, | ||
| 80 | crash_point, | ||
| 81 | started_at, | ||
| 82 | phase: "running".to_string(), | ||
| 83 | result: "playing".to_string(), | ||
| 84 | multiplier: 1.0, | ||
| 85 | payout: 0, | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | pub fn is_running(&self) -> bool { | ||
| 90 | self.phase == "running" | ||
| 91 | } | ||
| 92 | |||
| 93 | pub fn cash_out(&mut self, multiplier: f64) -> u64 { | ||
| 94 | self.multiplier = multiplier; | ||
| 95 | self.payout = payout(self.bet, multiplier); | ||
| 96 | self.phase = "cashed".to_string(); | ||
| 97 | self.result = "cashed".to_string(); | ||
| 98 | self.payout | ||
| 99 | } | ||
| 100 | |||
| 101 | /// The round ended before the player could cash out. | ||
| 102 | pub fn crash(&mut self) { | ||
| 103 | self.multiplier = self.crash_point; | ||
| 104 | self.payout = 0; | ||
| 105 | self.phase = "crashed".to_string(); | ||
| 106 | self.result = "crashed".to_string(); | ||
| 107 | } | ||
| 108 | |||
| 109 | pub fn forfeit(&mut self) { | ||
| 110 | self.payout = 0; | ||
| 111 | self.phase = "forfeit".to_string(); | ||
| 112 | self.result = "forfeit".to_string(); | ||
| 113 | } | ||
| 114 | |||
| 115 | pub fn state(&self, session_id: &str, balance: i64) -> CrashState { | ||
| 116 | CrashState { | ||
| 117 | session_id: session_id.to_string(), | ||
| 118 | phase: self.phase.clone(), | ||
| 119 | bet: self.bet, | ||
| 120 | balance, | ||
| 121 | multiplier: self.multiplier, | ||
| 122 | potential: payout(self.bet, self.multiplier), | ||
| 123 | crash_point: if self.is_running() { | ||
| 124 | None | ||
| 125 | } else { | ||
| 126 | Some(self.crash_point) | ||
| 127 | }, | ||
| 128 | payout: self.payout, | ||
| 129 | result: self.result.clone(), | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | pub fn to_json(&self) -> String { | ||
| 134 | serde_json::to_string(self).unwrap_or_default() | ||
| 135 | } | ||
| 136 | |||
| 137 | pub fn from_json(raw: &str) -> Result<Self, serde_json::Error> { | ||
| 138 | serde_json::from_str(raw) | ||
| 139 | } | ||
| 140 | } | ||
| 141 | |||
| 142 | /// Crash-point generator. Tests queue fixed uniforms; production draws random | ||
| 143 | /// ones. The visible crash point stays hidden until `poll` or cashout ends the | ||
| 144 | /// round. | ||
| 145 | pub struct CrashGame { | ||
| 146 | uniforms: VecDeque<f64>, | ||
| 147 | } | ||
| 148 | |||
| 149 | impl CrashGame { | ||
| 150 | pub fn new() -> Self { | ||
| 151 | Self { | ||
| 152 | uniforms: VecDeque::new(), | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | pub fn with_uniform(uniform: f64) -> Self { | ||
| 157 | Self::with_uniforms([uniform]) | ||
| 158 | } | ||
| 159 | |||
| 160 | pub fn with_uniforms(uniforms: impl IntoIterator<Item = f64>) -> Self { | ||
| 161 | Self { | ||
| 162 | uniforms: uniforms.into_iter().collect(), | ||
| 163 | } | ||
| 164 | } | ||
| 165 | |||
| 166 | pub fn next_crash_point(&mut self) -> f64 { | ||
| 167 | let uniform = self | ||
| 168 | .uniforms | ||
| 169 | .pop_front() | ||
| 170 | .unwrap_or_else(rand::random::<f64>); | ||
| 171 | crash_point_from_uniform(uniform) | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | impl Game for CrashGame { | ||
| 176 | fn game_type(&self) -> &'static str { | ||
| 177 | "crash" | ||
| 178 | } | ||
| 179 | |||
| 180 | fn get_name(&self) -> &'static str { | ||
| 181 | "Crash" | ||
| 182 | } | ||
| 183 | |||
| 184 | fn get_description(&self) -> &'static str { | ||
| 185 | "Crash: cash out before the multiplier crashes." | ||
| 186 | } | ||
| 187 | |||
| 188 | fn prepare_state(&self, _username: &str, bet_amount: u64) -> GameState { | ||
| 189 | let mut data = HashMap::new(); | ||
| 190 | data.insert("status".to_string(), "ready".to_string()); | ||
| 191 | GameState { | ||
| 192 | session_id: String::new(), | ||
| 193 | game_type: "crash".to_string(), | ||
| 194 | bet_amount, | ||
| 195 | phase: GamePhase::WaitingToStart, | ||
| 196 | data, | ||
| 197 | } | ||
| 198 | } | ||
| 199 | |||
| 200 | fn process_action( | ||
| 201 | &self, | ||
| 202 | _state: &GameState, | ||
| 203 | _action: &str, | ||
| 204 | _params: &[&str], | ||
| 205 | ) -> Result<GameState, GameError> { | ||
| 206 | Err(GameError::InvalidAction( | ||
| 207 | "Crash uses CRASH_CASHOUT and CRASH_FORFEIT".to_string(), | ||
| 208 | )) | ||
| 209 | } | ||
| 210 | |||
| 211 | fn calculate_result(&self, _state: &GameState) -> Result<GameResult, GameError> { | ||
| 212 | Err(GameError::InternalError( | ||
| 213 | "Crash settles through CRASH_CASHOUT".to_string(), | ||
| 214 | )) | ||
| 215 | } | ||
| 216 | } | ||
diff --git a/src/server/games/mod.rs b/src/server/games/mod.rs index 07c5772..5de9eef 100644 --- a/src/server/games/mod.rs +++ b/src/server/games/mod.rs | |||
| @@ -1,4 +1,5 @@ | |||
| 1 | pub mod blackjack; | 1 | pub mod blackjack; |
| 2 | pub mod crash; | ||
| 2 | pub mod registry; | 3 | pub mod registry; |
| 3 | pub mod roulette; | 4 | pub mod roulette; |
| 4 | pub mod session; | 5 | pub mod session; |
diff --git a/src/server/games/registry.rs b/src/server/games/registry.rs index 7ff8fbf..7a8b605 100644 --- a/src/server/games/registry.rs +++ b/src/server/games/registry.rs | |||
| @@ -1,6 +1,7 @@ | |||
| 1 | #![allow(dead_code)] | 1 | #![allow(dead_code)] |
| 2 | 2 | ||
| 3 | use super::blackjack::BlackjackGame; | 3 | use super::blackjack::BlackjackGame; |
| 4 | use super::crash::CrashGame; | ||
| 4 | use super::roulette::RouletteGame; | 5 | use super::roulette::RouletteGame; |
| 5 | use super::slots::SlotsGame; | 6 | use super::slots::SlotsGame; |
| 6 | use super::traits::Game; | 7 | use super::traits::Game; |
| @@ -25,6 +26,7 @@ impl GameRegistry { | |||
| 25 | registry.register_game(SlotsGame::new()); | 26 | registry.register_game(SlotsGame::new()); |
| 26 | registry.register_game(RouletteGame::new()); | 27 | registry.register_game(RouletteGame::new()); |
| 27 | registry.register_game(BlackjackGame::new()); | 28 | registry.register_game(BlackjackGame::new()); |
| 29 | registry.register_game(CrashGame::new()); | ||
| 28 | registry | 30 | registry |
| 29 | } | 31 | } |
| 30 | 32 | ||
