summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 20:43:09 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 20:43:09 +0100
commit6e2787d792de64c003eff03b62a2df798ef851c0 (patch)
tree0e9bf97b2f71df5fb9eb00d5195d32b8326886ff
parentv2.2.1: forfeit blackjack bets when leaving or disconnecting (diff)
downloadzagan-bet-6e2787d792de64c003eff03b62a2df798ef851c0.tar.gz
zagan-bet-6e2787d792de64c003eff03b62a2df798ef851c0.zip
v2.3.0: add crash with local multiplier and pushed crash event
-rw-r--r--AGENTS.md15
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GAMES.md65
-rw-r--r--docs/TESTING.md18
-rw-r--r--src/bin/client/connection.rs5
-rw-r--r--src/bin/client/repl.rs123
-rw-r--r--src/bin/client/tls.rs58
-rw-r--r--src/bin/server/handler.rs55
-rw-r--r--src/bin/server/tls.rs9
-rw-r--r--src/client/core.rs190
-rw-r--r--src/client/games/crash.rs91
-rw-r--r--src/client/games/manager.rs90
-rw-r--r--src/client/games/mod.rs1
-rw-r--r--src/client/games/response.rs21
-rw-r--r--src/crash.rs87
-rw-r--r--src/lib.rs1
-rw-r--r--src/server/core.rs240
-rw-r--r--src/server/games/crash.rs216
-rw-r--r--src/server/games/mod.rs1
-rw-r--r--src/server/games/registry.rs2
-rw-r--r--tests/client_core.rs210
-rw-r--r--tests/crash.rs100
-rw-r--r--tests/server_core.rs245
23 files changed, 1800 insertions, 45 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 9bee619..be00df5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,6 +30,21 @@
30- All balance mutations (`SET balance = balance ± ?`) must happen in a 30- All balance mutations (`SET balance = balance ± ?`) must happen in a
31 transaction. 31 transaction.
32 32
33## Crash
34- The client draws the multiplier from its own clock for display only. A
35 cashout carries a session id and nothing else; the multiplier and payout come
36 from the server's clock at receipt.
37- Never send the crash point to the client before the round ends. A client that
38 knows it can cash out at `crash - epsilon` every round for a guaranteed win.
39- The crash point is `max(1.0, (1 - edge) / (1 - u))`; do not "fix" the 1% of
40 rounds that crash at 1.00x. That is where the house edge is charged on an
41 instant cashout, and it is why no cashout lockout is needed.
42- The crash event is a server-initiated line (`CRASH_BUSTED`), not a reply.
43 Client code must drain the socket on a timer and route pushed lines by
44 prefix instead of assuming the next line answers the last request.
45- `ServerCore::poll` must be idempotent: it settles the round, removes the
46 session, and pushes once. A second poll must produce nothing.
47
33## Client TUI 48## Client TUI
34- `render_lines()` and `render()` must agree. If `render()` uses `\r\n`, 49- `render_lines()` and `render()` must agree. If `render()` uses `\r\n`,
35 count lines against the same string the cursor will erase. 50 count lines against the same string the cursor will erase.
diff --git a/Cargo.toml b/Cargo.toml
index d65b9ef..54c41df 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "2.2.1" 3version = "2.3.0"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
diff --git a/docs/GAMES.md b/docs/GAMES.md
index ae15d50..844d1b0 100644
--- a/docs/GAMES.md
+++ b/docs/GAMES.md
@@ -7,15 +7,52 @@
7| Slots | Done | 5 reels x 3 rows, 5 paylines, 98.6% RTP | 7| Slots | Done | 5 reels x 3 rows, 5 paylines, 98.6% RTP |
8| Roulette | Done | European wheel, split bets, 97.3% RTP | 8| Roulette | Done | European wheel, split bets, 97.3% RTP |
9| Blackjack | Done | Six-deck shoe per hand, S17, 3:2, hit/stand/double | 9| Blackjack | Done | Six-deck shoe per hand, S17, 3:2, hit/stand/double |
10| Crash | Done | Local multiplier, server-owned clock and crash event, 99% RTP |
10 11
11## Planned 12## Planned
12 13
13| Game | Shape | Main open decisions | 14| Game | Shape | Main open decisions |
14| --- | --- | --- | 15| --- | --- | --- |
15| Crash | Real-time round | Crash-point distribution; cashout latency; provable fairness | 16| — | — | Provable fairness for the crash point (`hash` commit/reveal) |
17
18Crash rounds are private to one player and use the same request/response socket
19plus one server-initiated line. A shared multiplayer round would need a lobby
20and broadcast, which is a different product.
21
22## Crash
23
24- The multiplier is `exp(0.1386294 * t)`: 1.00x at the start, 2.00x at 5s,
25 10x at ~16.6s, capped at 1000x.
26- The crash point is drawn when the round starts:
27 `max(1.0, (1 - 0.01) / (1 - u))` for a uniform `u`. That gives
28 `P(crash >= m) = 0.99 / m`, so a fixed-target cashout returns 99% of the
29 stake. The cap trims the RTP by a hair.
30- The crash point is **not** sent to the client until the round ends. Knowing it
31 early would let a patched client cash out at `crash - epsilon` every round.
32- The client draws the multiplier from its own monotonic clock, anchored when
33 `CRASH_START` arrives. That number is display only.
34- The server computes the cashout multiplier from its own clock when the
35 request arrives. The client never sends a multiplier. A cashout that arrives
36 after the crash time loses the bet.
37- Because `P(crash = 1.00x) = 1%`, an instant cashout returns the stake 99% of
38 the time and nothing 1% of the time. There is no risk-free window, which is
39 why there is no cashout lockout.
40- Payouts are `floor(bet * multiplier)` in whole PLN, so anything under 1.10x
41 on a 10 PLN bet returns 10.
42- When the crash time passes, the server pushes `CRASH_BUSTED` on the same
43 connection without being asked. The client's event loop polls for keys and
44 drains the socket every 25ms.
45- Leaving with Q or disconnecting during a round forfeits the reserved bet and
46 writes a `result = forfeit` audit row.
16 47
17Crash needs a timed round loop and a cashout action, so it should be its own 48Client controls:
18protocol/loop change. 49
50```
51C cash out at the server's current multiplier
52SPACE start the next round
53+ or = / - change the next round's bet
54Q forfeit an active round (bet is lost), or leave when idle
55```
19 56
20## Blackjack 57## Blackjack
21 58
@@ -92,6 +129,9 @@ plays (
92 129
93// blackjack 130// blackjack
94{"player":["10S","6H","5D"],"dealer":["9D","8C"],"player_total":21,"dealer_total":17,"bet":10,"result":"win","winnings":20} 131{"player":["10S","6H","5D"],"dealer":["9D","8C"],"player_total":21,"dealer_total":17,"bet":10,"result":"win","winnings":20}
132
133// crash
134{"bet":10,"crash_point":3.0,"started_at":12.44,"phase":"cashed","result":"cashed","multiplier":2.0,"payout":20}
95``` 135```
96 136
97The balance update and the `plays` insert happen in one transaction 137The balance update and the `plays` insert happen in one transaction
@@ -124,3 +164,22 @@ BLACKJACK_STATE <json>
124 164
125`BLACKJACK_STATE` carries the player/dealer cards, totals, actions, balance, 165`BLACKJACK_STATE` carries the player/dealer cards, totals, actions, balance,
126result and winnings. The dealer's hole card is `?` until the hand is done. 166result and winnings. The dealer's hole card is `?` until the hand is done.
167
168Crash sends one line when the round starts, one line when it crashes (without a
169request), and answers the cashout:
170
171```
172PLAY_START crash <bet>
173CRASH_START <json>
174
175CRASH_CASHOUT <session>
176CRASH_STATE <json>
177
178CRASH_BUSTED <json> # pushed when the crash time passes
179CRASH_FORFEIT <session>
180CRASH_STATE <json>
181```
182
183`CRASH_STATE` carries `session_id`, `phase` (`running`, `cashed`, `crashed`,
184`forfeit`), `bet`, `balance`, the current/final `multiplier`, `potential`,
185`crash_point` (null while running), `payout` and `result`.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index ec5c681..3c7fa61 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -14,6 +14,7 @@ shared global database. Each test owns its state.
14| `tests/client_core.rs` | Client command/game flows via a headless screen applying logical effects. | 14| `tests/client_core.rs` | Client command/game flows via a headless screen applying logical effects. |
15| `tests/client_ui.rs` | Help, history, completion, bet parsing, display width, cursor math. | 15| `tests/client_ui.rs` | Help, history, completion, bet parsing, display width, cursor math. |
16| `tests/blackjack.rs` | Card totals, naturals, hit/stand/double, dealer bust, S17, state parsing. | 16| `tests/blackjack.rs` | Card totals, naturals, hit/stand/double, dealer bust, S17, state parsing. |
17| `tests/crash.rs` | Multiplier curve, payout floor, crash-point distribution, house edge, cap. |
17| `tests/protocol.rs` | Newline buffering and timeout/error classification. | 18| `tests/protocol.rs` | Newline buffering and timeout/error classification. |
18| `tests/roulette.rs` | European wheel, split-bet payouts, zero handling, result parser. | 19| `tests/roulette.rs` | European wheel, split-bet payouts, zero handling, result parser. |
19| `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | 20| `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. |
@@ -93,6 +94,23 @@ Use an 80-column terminal because several output lines are width-sensitive.
93 double), winnings, and the final cards/result in `details`. A forfeit has 94 double), winnings, and the final cards/result in `details`. A forfeit has
94 `result = forfeit`, `winnings = 0`. 95 `result = forfeit`, `winnings = 0`.
95 96
97## Manual crash checklist
98
991. `play crash` shows the ready board with a 1.00x multiplier; Space starts and
100 reserves the bet.
1012. The multiplier rises smoothly with no network traffic. Cash out with C and
102 confirm the board shows the cashout multiplier, the payout and the new
103 balance.
1043. On another round, press nothing. The board must switch to `CRASHED at Xx`
105 by itself when the crash point is reached, and the bet must be lost.
1064. Press C just as a round crashes. Either it pays or the crashed result wins;
107 a late cashout must never show an error over a finished round.
1085. Q during a live round forfeits the bet; Q when idle just leaves.
1096. Check the audit rows: one per round, `game_type = crash`, the reserved bet,
110 the payout, and `crash_point`/`multiplier`/`result` in `details`.
1117. Confirm the crash point is absent from the `CRASH_START` line (it must only
112 appear once the round is over).
113
96## Manual server checks 114## Manual server checks
97 115
98- Register, log out, log in again (bcrypt path). 116- Register, log out, log in again (bcrypt path).
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
155impl Session { 157impl 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 @@
1use std::time::Duration;
2
1use crate::tls::TlsStream; 3use crate::tls::TlsStream;
2use zagan_bet::server::core::{ConnectionState, ServerCore}; 4use zagan_bet::server::core::{ConnectionState, ServerCore};
3 5
6/// How long the connection waits for a request before advancing timed games.
7const POLL_INTERVAL: Duration = Duration::from_millis(50);
8
4pub fn handle_client_tls(mut stream: TlsStream) { 9pub 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
82impl Read for TlsStream { 91impl 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 @@
1use crate::client::games::manager::GameManager; 1use crate::client::games::manager::GameManager;
2use crate::client::games::response::{ 2use 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};
5use crate::client::ui::{ 5use 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};
9use crate::crash::{multiplier_at, CrashState};
9use crate::roulette::{Bets, NumberBet}; 10use crate::roulette::{Bets, NumberBet};
11use std::time::{Duration, Instant};
10 12
11#[derive(Debug, Clone, Copy, PartialEq, Eq)] 13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Key { 14pub 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
95impl ClientCore { 102impl 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 @@
1use super::traits::{Game, GameState};
2
3pub const CRASH_BOARD_LINES: usize = 6;
4
5pub struct CrashGame;
6
7impl 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
44impl 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 @@
1use super::blackjack::BlackjackGame; 1use super::blackjack::BlackjackGame;
2use super::crash::CrashGame;
2use super::response::{RouletteResult, SpinResult}; 3use super::response::{RouletteResult, SpinResult};
3use super::roulette::RouletteGame; 4use super::roulette::RouletteGame;
4use super::slots::SlotsGame; 5use super::slots::SlotsGame;
5use super::traits::{Game, GameError, GamePhase, GameState}; 6use super::traits::{Game, GameError, GamePhase, GameState};
6use crate::blackjack::BlackjackState; 7use crate::blackjack::BlackjackState;
8use crate::crash::{payout, CrashState};
7use crate::roulette::Bets; 9use crate::roulette::Bets;
8use std::collections::HashMap; 10use 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 @@
1pub mod blackjack; 1pub mod blackjack;
2pub mod crash;
2pub mod manager; 3pub mod manager;
3pub mod response; 4pub mod response;
4pub mod roulette; 5pub 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
167pub 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
8use serde::{Deserialize, Serialize};
9use std::time::Duration;
10
11/// `exp(GROWTH_RATE * t)` doubles the multiplier every five seconds.
12pub const GROWTH_RATE: f64 = 0.138_629_436_111_989_07; // ln(2) / 5
13/// House edge baked into the crash-point distribution.
14pub const HOUSE_EDGE: f64 = 0.01;
15/// Liability cap. At the cap `P(crash >= m)` stops following `edge / m`.
16pub const MAX_MULTIPLIER: f64 = 1000.0;
17
18/// Multiplier after `elapsed` seconds of a round.
19pub fn multiplier_at(elapsed: Duration) -> f64 {
20 multiplier_at_secs(elapsed.as_secs_f64())
21}
22
23pub 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`.
33pub 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.
43pub 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.
53pub 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)]
67pub 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
83impl CrashState {
84 pub fn is_running(&self) -> bool {
85 self.phase == "running"
86 }
87}
diff --git a/src/lib.rs b/src/lib.rs
index 74af6e1..ffc3d60 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,6 +7,7 @@
7 7
8pub mod blackjack; 8pub mod blackjack;
9pub mod client; 9pub mod client;
10pub mod crash;
10pub mod protocol; 11pub mod protocol;
11pub mod roulette; 12pub mod roulette;
12pub mod server; 13pub 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 @@
1use bcrypt::{hash, verify, DEFAULT_COST}; 1use bcrypt::{hash, verify, DEFAULT_COST};
2use rusqlite::Connection; 2use rusqlite::Connection;
3use std::collections::HashMap; 3use std::collections::HashMap;
4use std::sync::Arc;
4 5
5use crate::blackjack::BlackjackState; 6use crate::blackjack::BlackjackState;
7use crate::crash::CrashState;
6use crate::server::database; 8use crate::server::database;
7use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; 9use crate::server::games::blackjack::{BlackjackGame, BlackjackHand};
10use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock};
8use crate::server::games::registry::GameRegistry; 11use crate::server::games::registry::GameRegistry;
9use crate::server::games::session::SessionManager; 12use crate::server::games::session::SessionManager;
10use crate::server::games::traits::{GameError, GamePhase, GameState}; 13use 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
31impl ServerCore { 36impl 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
799fn 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 @@
1use super::traits::{Game, GameError, GamePhase, GameResult, GameState};
2use crate::crash::{crash_point_from_uniform, payout, CrashState};
3use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, VecDeque};
5use std::sync::{Arc, Mutex};
6use std::time::Instant;
7
8/// Monotonic seconds. Injected so tests can drive a round without sleeping.
9pub trait Clock: Send + Sync {
10 fn now(&self) -> f64;
11}
12
13pub struct RealClock {
14 origin: Instant,
15}
16
17impl RealClock {
18 pub fn new() -> Self {
19 Self {
20 origin: Instant::now(),
21 }
22 }
23}
24
25impl 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)]
33pub struct ManualClock {
34 seconds: Arc<Mutex<f64>>,
35}
36
37impl 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
55impl 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)]
63pub 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
76impl 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.
145pub struct CrashGame {
146 uniforms: VecDeque<f64>,
147}
148
149impl 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
175impl 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 @@
1pub mod blackjack; 1pub mod blackjack;
2pub mod crash;
2pub mod registry; 3pub mod registry;
3pub mod roulette; 4pub mod roulette;
4pub mod session; 5pub 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
3use super::blackjack::BlackjackGame; 3use super::blackjack::BlackjackGame;
4use super::crash::CrashGame;
4use super::roulette::RouletteGame; 5use super::roulette::RouletteGame;
5use super::slots::SlotsGame; 6use super::slots::SlotsGame;
6use super::traits::Game; 7use 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
diff --git a/tests/client_core.rs b/tests/client_core.rs
index ec444bd..e8b5646 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -6,8 +6,12 @@
6 6
7use zagan_bet::blackjack::BlackjackState; 7use zagan_bet::blackjack::BlackjackState;
8use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; 8use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key};
9use zagan_bet::client::games::response::{parse_blackjack_response, parse_spin_response}; 9use zagan_bet::client::games::response::{
10 parse_blackjack_response, parse_crash_response, parse_spin_response,
11};
10use zagan_bet::client::ui::command_names; 12use zagan_bet::client::ui::command_names;
13use zagan_bet::crash::CrashState;
14use std::time::Duration;
11 15
12const WIN3: &str = "CCCCLLCLODGSDGS"; 16const WIN3: &str = "CCCCLLCLODGSDGS";
13const LOSS: &str = "CLOOGDSLCDCOGLS"; 17const LOSS: &str = "CLOOGDSLCDCOGLS";
@@ -145,6 +149,25 @@ impl Harness {
145 self.line("play blackjack"); 149 self.line("play blackjack");
146 self.reply("INFO alice 1 1000"); 150 self.reply("INFO alice 1 1000");
147 } 151 }
152
153 fn enter_crash(&mut self) {
154 self.line("play crash");
155 self.reply("INFO alice 1 1000");
156 }
157
158 fn tick(&mut self, elapsed: Duration) {
159 let effects = self.core.tick_at(elapsed);
160 self.apply(effects);
161 }
162
163 /// Deliver a server-initiated line the way the terminal loop does.
164 fn push(&mut self, line: &str) {
165 let effects = self
166 .core
167 .handle_async_line(line)
168 .expect("a pushed crash line");
169 self.apply(effects);
170 }
148} 171}
149 172
150#[test] 173#[test]
@@ -851,3 +874,188 @@ fn parses_a_blackjack_state() {
851 assert_eq!(parse_blackjack_response(&response).unwrap(), state); 874 assert_eq!(parse_blackjack_response(&response).unwrap(), state);
852 assert!(parse_blackjack_response("ERROR Insufficient balance").is_err()); 875 assert!(parse_blackjack_response("ERROR Insufficient balance").is_err());
853} 876}
877
878fn crash_state(phase: &str, multiplier: f64, crash_point: Option<f64>, payout: u64, balance: i64) -> CrashState {
879 CrashState {
880 session_id: "cr1".to_string(),
881 phase: phase.to_string(),
882 bet: 10,
883 balance,
884 multiplier,
885 potential: (10.0 * multiplier) as u64,
886 crash_point,
887 payout,
888 result: phase.to_string(),
889 }
890}
891
892fn crash_line(prefix: &str, state: &CrashState) -> String {
893 format!("{prefix} {}", serde_json::to_string(state).unwrap())
894}
895
896#[test]
897fn crash_starts_a_round_and_draws_the_multiplier_locally() {
898 let mut h = Harness::new();
899 h.enter_crash();
900
901 assert_eq!(h.board().len(), 6);
902 assert!(h.board()[4].contains("Press [SPACE] to start."));
903 assert!(h.board()[5].contains("[SPACE] Play"));
904
905 h.key(Key::Char(' '));
906 assert!(h.sent.iter().any(|line| line == "PLAY_START crash 10"));
907
908 let running = crash_state("running", 1.0, None, 0, 990);
909 h.reply(&crash_line("CRASH_START", &running));
910
911 assert_eq!(h.board().len(), 6);
912 assert!(h.board()[2].contains("Multiplier: 1.00x"));
913 assert!(h.board()[3].contains("Potential: 10 PLN"));
914 assert!(h.board()[4].contains("Rising"));
915 assert!(h.board()[5].contains("[C] Cash out"));
916
917 // The multiplier is drawn from the local clock, with no server round trip.
918 h.tick(Duration::from_secs(5));
919 assert!(h.board()[2].contains("Multiplier: 2.00x"));
920 assert!(h.board()[3].contains("Potential: 20 PLN"));
921
922 h.key(Key::Char('c'));
923 assert_eq!(
924 h.sent.last().map(String::as_str),
925 Some("CRASH_CASHOUT cr1")
926 );
927
928 let cashed = crash_state("cashed", 2.0, Some(3.0), 20, 1010);
929 h.reply(&crash_line("CRASH_STATE", &cashed));
930
931 assert!(h.board()[4].contains("Cashed out at 2.00x Won 20 PLN"));
932 assert!(h.board()[5].contains("[SPACE] Play"));
933 assert_eq!(h.board()[1], "Balance: 1010 PLN Bet: 10 PLN");
934}
935
936#[test]
937fn a_pushed_bust_ends_the_round_without_a_request() {
938 let mut h = Harness::new();
939 h.enter_crash();
940 h.key(Key::Char(' '));
941
942 let running = crash_state("running", 1.0, None, 0, 990);
943 h.reply(&crash_line("CRASH_START", &running));
944 h.tick(Duration::from_secs(3));
945
946 assert!(h.sent.iter().all(|line| line != "CRASH_CASHOUT cr1"));
947
948 let busted = crash_state("crashed", 1.5, Some(1.5), 0, 990);
949 h.push(&crash_line("CRASH_BUSTED", &busted));
950
951 assert!(h.board()[4].contains("CRASHED at 1.50x Bet lost"));
952 assert!(h.board()[5].contains("[SPACE] Play"));
953
954 // The local clock stops once the round is over.
955 h.tick(Duration::from_secs(30));
956 assert!(h.board()[2].contains("Multiplier: 1.50x"));
957 assert!(!h.core.expects_push());
958}
959
960#[test]
961fn crash_q_forfeits_an_active_round() {
962 let mut h = Harness::new();
963 h.enter_crash();
964 h.key(Key::Char(' '));
965
966 let running = crash_state("running", 1.0, None, 0, 990);
967 h.reply(&crash_line("CRASH_START", &running));
968
969 h.key(Key::Char('q'));
970 assert_eq!(
971 h.sent.last().map(String::as_str),
972 Some("CRASH_FORFEIT cr1")
973 );
974
975 let forfeited = crash_state("forfeit", 1.2, Some(2.0), 0, 990);
976 h.reply(&crash_line("CRASH_STATE", &forfeited));
977
978 assert!(h.board().is_empty());
979 assert!(h
980 .screen
981 .lines
982 .iter()
983 .any(|line| line.contains("Round forfeited (10 PLN lost)")));
984}
985
986#[test]
987fn crash_ready_board_changes_the_next_bet() {
988 let mut h = Harness::new();
989 h.enter_crash();
990
991 h.key(Key::Char('='));
992 assert!(h.board()[1].contains("Bet: 20 PLN"));
993 assert!(h.board()[3].contains("Potential: 20 PLN"));
994
995 h.key(Key::Char(' '));
996 assert!(h.sent.iter().any(|line| line == "PLAY_START crash 20"));
997}
998
999#[test]
1000fn parses_crash_messages_and_errors() {
1001 let running = crash_state("running", 1.0, None, 0, 990);
1002 let cashed = crash_state("cashed", 2.0, Some(3.0), 20, 1010);
1003
1004 assert_eq!(
1005 parse_crash_response(&crash_line("CRASH_START", &running)).unwrap(),
1006 running
1007 );
1008 assert_eq!(
1009 parse_crash_response(&crash_line("CRASH_BUSTED", &cashed)).unwrap(),
1010 cashed
1011 );
1012 assert_eq!(
1013 parse_crash_response("ERROR Round already ended").unwrap_err(),
1014 "Round already ended"
1015 );
1016 assert!(parse_crash_response("GAME_RESULT nope").is_err());
1017}
1018
1019#[test]
1020fn a_late_cashout_does_not_overwrite_a_pushed_bust() {
1021 let mut h = Harness::new();
1022 h.enter_crash();
1023 h.key(Key::Char(' '));
1024
1025 let running = crash_state("running", 1.0, None, 0, 990);
1026 h.reply(&crash_line("CRASH_START", &running));
1027
1028 h.key(Key::Char('c'));
1029 assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_CASHOUT cr1"));
1030
1031 // The round crashes while the cashout is in flight, so the push lands
1032 // before the server's "session not found" reply.
1033 let busted = crash_state("crashed", 1.2, Some(1.2), 0, 990);
1034 h.push(&crash_line("CRASH_BUSTED", &busted));
1035 h.reply("ERROR Session not found");
1036
1037 assert!(h.board()[4].contains("CRASHED at 1.20x"));
1038 assert!(!h.board().iter().any(|line| line.contains("ERROR")));
1039 assert!(h.board()[5].contains("[SPACE] Play"));
1040}
1041
1042#[test]
1043fn a_forfeit_that_arrives_after_the_bust_still_leaves_cleanly() {
1044 let mut h = Harness::new();
1045 h.enter_crash();
1046 h.key(Key::Char(' '));
1047
1048 let running = crash_state("running", 1.0, None, 0, 990);
1049 h.reply(&crash_line("CRASH_START", &running));
1050
1051 h.key(Key::Char('q'));
1052 assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_FORFEIT cr1"));
1053
1054 let busted = crash_state("crashed", 1.5, Some(1.5), 0, 990);
1055 h.push(&crash_line("CRASH_BUSTED", &busted));
1056 h.reply("ERROR Session not found");
1057
1058 assert!(h.board().is_empty());
1059 assert!(h.screen.lines.iter().any(|line| line == "Game exited."));
1060 assert!(!h.screen.lines.iter().any(|line| line.contains("PLN lost")));
1061}
diff --git a/tests/crash.rs b/tests/crash.rs
new file mode 100644
index 0000000..f59c52f
--- /dev/null
+++ b/tests/crash.rs
@@ -0,0 +1,100 @@
1//! Crash math: the curve, the payout floor, and the house edge.
2
3use std::time::Duration;
4use zagan_bet::crash::{
5 crash_point_from_uniform, multiplier_at, multiplier_at_secs, payout, time_for_multiplier,
6 HOUSE_EDGE, MAX_MULTIPLIER,
7};
8
9fn close(a: f64, b: f64, tolerance: f64) {
10 assert!(
11 (a - b).abs() <= tolerance,
12 "expected {b} +/- {tolerance}, got {a}"
13 );
14}
15
16#[test]
17fn the_multiplier_doubles_every_five_seconds() {
18 close(multiplier_at(Duration::ZERO), 1.0, 1e-9);
19 close(multiplier_at(Duration::from_secs(5)), 2.0, 1e-9);
20 close(multiplier_at(Duration::from_secs(10)), 4.0, 1e-6);
21 close(multiplier_at(Duration::from_millis(2500)), 1.414_213_562, 1e-6);
22
23 // Never below 1x, even for nonsense input.
24 assert_eq!(multiplier_at_secs(-3.0), 1.0);
25 assert_eq!(multiplier_at_secs(f64::NAN), 1.0);
26 assert_eq!(multiplier_at_secs(10_000.0), MAX_MULTIPLIER);
27}
28
29#[test]
30fn the_time_for_a_multiplier_is_the_inverse_of_the_curve() {
31 close(time_for_multiplier(2.0).as_secs_f64(), 5.0, 1e-9);
32 close(time_for_multiplier(10.0).as_secs_f64(), 16.609_640, 1e-4);
33 close(multiplier_at(time_for_multiplier(3.5)), 3.5, 1e-9);
34}
35
36#[test]
37fn cashout_value_is_floored_to_whole_pln() {
38 assert_eq!(payout(10, 1.0), 10);
39 assert_eq!(payout(10, 1.05), 10, "half a PLN is not paid out");
40 assert_eq!(payout(10, 1.10), 11);
41 assert_eq!(payout(10, 2.47), 24);
42 assert_eq!(payout(7, 3.0), 21);
43
44 assert_eq!(payout(0, 2.0), 0);
45 assert_eq!(payout(10, f64::NAN), 0);
46 assert_eq!(payout(10, f64::INFINITY), 0);
47 assert_eq!(
48 payout(u64::MAX, MAX_MULTIPLIER),
49 u64::MAX,
50 "a huge win saturates instead of wrapping"
51 );
52}
53
54#[test]
55fn crash_points_follow_the_house_edge() {
56 // P(crash >= m) should be (1 - edge) / m.
57 let samples = 100_000;
58 for target in [1.5_f64, 2.0, 5.0, 50.0] {
59 let survivors = (0..samples)
60 .filter(|index| {
61 let u = *index as f64 / samples as f64;
62 crash_point_from_uniform(u) >= target
63 })
64 .count();
65 let observed = survivors as f64 / samples as f64;
66 let expected = (1.0 - HOUSE_EDGE) / target;
67 close(observed, expected, 0.002);
68 }
69}
70
71#[test]
72fn an_instant_cashout_still_pays_the_house_edge() {
73 // One round in a hundred crashes at 1.00x, so cashing out instantly returns
74 // the stake 99% of the time and nothing 1% of the time: no free money.
75 let samples = 100_000;
76 let instant_crashes = (0..samples)
77 .filter(|index| {
78 let u = *index as f64 / samples as f64;
79 crash_point_from_uniform(u) <= 1.0
80 })
81 .count();
82 close(instant_crashes as f64 / samples as f64, HOUSE_EDGE, 0.001);
83
84 let stake = 10;
85 let kept = payout(stake, 1.0);
86 assert_eq!(kept, stake);
87 let ev = kept as f64 * (1.0 - HOUSE_EDGE);
88 close(ev, stake as f64 * (1.0 - HOUSE_EDGE), 1e-9);
89}
90
91#[test]
92fn crash_points_are_clamped_to_the_liability_cap() {
93 assert_eq!(crash_point_from_uniform(0.0), 1.0);
94 assert_eq!(crash_point_from_uniform(-1.0), 1.0);
95 assert_eq!(crash_point_from_uniform(f64::NAN), 1.0);
96 close(crash_point_from_uniform(0.5), 1.98, 1e-9);
97 close(crash_point_from_uniform(0.99), 99.0, 1e-9);
98 assert_eq!(crash_point_from_uniform(0.999_999), MAX_MULTIPLIER);
99 assert_eq!(crash_point_from_uniform(1.0), MAX_MULTIPLIER);
100}
diff --git a/tests/server_core.rs b/tests/server_core.rs
index 08a2bf2..695cfe4 100644
--- a/tests/server_core.rs
+++ b/tests/server_core.rs
@@ -1,9 +1,12 @@
1use std::path::PathBuf; 1use std::path::PathBuf;
2use std::sync::Arc;
2 3
3use rusqlite::Connection; 4use rusqlite::Connection;
4use zagan_bet::blackjack::BlackjackState; 5use zagan_bet::blackjack::BlackjackState;
6use zagan_bet::crash::CrashState;
5use zagan_bet::server::core::{ConnectionState, ServerCore}; 7use zagan_bet::server::core::{ConnectionState, ServerCore};
6use zagan_bet::server::database; 8use zagan_bet::server::database;
9use zagan_bet::server::games::crash::ManualClock;
7use zagan_bet::server::games::registry::GameRegistry; 10use zagan_bet::server::games::registry::GameRegistry;
8use zagan_bet::server::games::roulette::RouletteGame; 11use zagan_bet::server::games::roulette::RouletteGame;
9use zagan_bet::server::games::slots::SlotsGame; 12use zagan_bet::server::games::slots::SlotsGame;
@@ -73,6 +76,31 @@ fn blackjack_state(response: &str) -> BlackjackState {
73 serde_json::from_str(raw).expect("valid blackjack state") 76 serde_json::from_str(raw).expect("valid blackjack state")
74} 77}
75 78
79fn setup_crash(name: &str, uniform: f64) -> (ServerCore, String, ManualClock) {
80 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
81 .join(format!("server-core-{name}.db"))
82 .to_string_lossy()
83 .into_owned();
84 let _ = std::fs::remove_file(&path);
85
86 let conn = Connection::open(&path).unwrap();
87 database::init_db(&conn);
88
89 let clock = ManualClock::new();
90 let core = ServerCore::with_registry(conn, GameRegistry::with_defaults(), 4)
91 .with_crash_uniform(uniform)
92 .with_clock(Arc::new(clock.clone()));
93 (core, path, clock)
94}
95
96fn crash_state(response: &str) -> CrashState {
97 let raw = ["CRASH_START ", "CRASH_STATE ", "CRASH_BUSTED "]
98 .iter()
99 .find_map(|prefix| response.strip_prefix(prefix))
100 .unwrap_or_else(|| panic!("not a crash response: {response}"));
101 serde_json::from_str(raw).expect("valid crash state")
102}
103
76fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { 104fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
77 assert_eq!( 105 assert_eq!(
78 core.handle_message(state, &format!("REGISTER {user} credential")), 106 core.handle_message(state, &format!("REGISTER {user} credential")),
@@ -496,3 +524,220 @@ fn blackjack_disconnect_forfeits_the_reserved_bet() {
496 assert_eq!(core.play_count("alice"), 1); 524 assert_eq!(core.play_count("alice"), 1);
497 assert_eq!(core.session_count(), 0); 525 assert_eq!(core.session_count(), 0);
498} 526}
527
528// 0.67 crashes at 3.00x: (1 - 0.01) / (1 - 0.67) = 3.0.
529const CRASH_AT_3: f64 = 0.67;
530
531#[test]
532fn crash_start_reserves_the_bet_and_hides_the_crash_point() {
533 let (mut core, _path, _clock) = setup_crash("crash-start", CRASH_AT_3);
534 let mut state = ConnectionState::default();
535 register(&mut core, &mut state, "alice");
536 login(&mut core, &mut state, "alice");
537
538 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
539
540 assert_eq!(started.phase, "running");
541 assert_eq!(started.bet, 10);
542 assert_eq!(started.balance, 990);
543 assert_eq!(started.multiplier, 1.0);
544 assert_eq!(started.potential, 10);
545 assert_eq!(started.payout, 0);
546 assert_eq!(
547 started.crash_point, None,
548 "the crash point must not leak before the round ends"
549 );
550 assert_eq!(core.balance("alice"), Some(990));
551 assert_eq!(core.session_count(), 1);
552 assert_eq!(core.play_count("alice"), 0);
553}
554
555#[test]
556fn crash_cashout_pays_the_multiplier_from_the_server_clock() {
557 let (mut core, path, clock) = setup_crash("crash-cashout", CRASH_AT_3);
558 let mut state = ConnectionState::default();
559 register(&mut core, &mut state, "alice");
560 login(&mut core, &mut state, "alice");
561
562 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
563
564 // Five seconds of round time: 2.00x, still below the 3.00x crash point.
565 clock.advance(5.0);
566 let cashed = crash_state(&core.handle_message(
567 &mut state,
568 &format!("CRASH_CASHOUT {}", started.session_id),
569 ));
570
571 assert_eq!(cashed.phase, "cashed");
572 assert_eq!(cashed.multiplier, 2.0);
573 assert_eq!(cashed.potential, 20);
574 assert_eq!(cashed.payout, 20);
575 assert_eq!(cashed.balance, 1010);
576 let crash_point = cashed.crash_point.unwrap();
577 assert!((crash_point - 3.0).abs() < 1e-9, "got {crash_point}");
578 assert_eq!(core.balance("alice"), Some(1010));
579 assert_eq!(core.play_count("alice"), 1);
580 assert_eq!(core.session_count(), 0);
581
582 let conn = Connection::open(&path).unwrap();
583 let (game_type, bet, winnings, balance_after, details): (String, i64, i64, i64, String) = conn
584 .query_row(
585 "SELECT game_type, bet_amount, winnings, balance_after, details
586 FROM plays WHERE username = 'alice'",
587 [],
588 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
589 )
590 .unwrap();
591 assert_eq!(game_type, "crash");
592 assert_eq!(bet, 10);
593 assert_eq!(winnings, 20);
594 assert_eq!(balance_after, 1010);
595 let details: serde_json::Value = serde_json::from_str(&details).unwrap();
596 assert_eq!(details["phase"], "cashed");
597 let audited_crash_point = details["crash_point"].as_f64().unwrap();
598 assert!(
599 (audited_crash_point - 3.0).abs() < 1e-9,
600 "got {audited_crash_point}"
601 );
602 assert_eq!(details["multiplier"], 2.0);
603}
604
605#[test]
606fn a_cashout_after_the_crash_time_loses_the_reserved_bet() {
607 let (mut core, _path, clock) = setup_crash("crash-too-late", CRASH_AT_3);
608 let mut state = ConnectionState::default();
609 register(&mut core, &mut state, "alice");
610 login(&mut core, &mut state, "alice");
611
612 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
613
614 clock.advance(20.0);
615 let late = crash_state(&core.handle_message(
616 &mut state,
617 &format!("CRASH_CASHOUT {}", started.session_id),
618 ));
619
620 assert_eq!(late.phase, "crashed");
621 assert_eq!(late.payout, 0);
622 assert_eq!(late.balance, 990);
623 let crash_point = late.crash_point.unwrap();
624 assert!((crash_point - 3.0).abs() < 1e-9, "got {crash_point}");
625 assert_eq!(core.balance("alice"), Some(990));
626 assert_eq!(core.play_count("alice"), 1);
627 assert_eq!(core.session_count(), 0);
628}
629
630#[test]
631fn crash_poll_pushes_the_bust_exactly_once() {
632 let (mut core, _path, clock) = setup_crash("crash-poll", CRASH_AT_3);
633 let mut state = ConnectionState::default();
634 register(&mut core, &mut state, "alice");
635 login(&mut core, &mut state, "alice");
636
637 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
638
639 clock.advance(1.0);
640 assert!(
641 core.poll(&state).is_empty(),
642 "a running round must not push anything"
643 );
644
645 clock.advance(15.0);
646 let pushed = core.poll(&state);
647 assert_eq!(pushed.len(), 1);
648 let busted = crash_state(&pushed[0]);
649 assert_eq!(busted.session_id, started.session_id);
650 assert_eq!(busted.phase, "crashed");
651 assert_eq!(busted.payout, 0);
652 assert_eq!(busted.balance, 990);
653
654 assert!(core.poll(&state).is_empty(), "the bust must not repeat");
655 assert_eq!(core.play_count("alice"), 1);
656 assert_eq!(core.session_count(), 0);
657}
658
659#[test]
660fn crash_forfeit_keeps_the_reserved_bet() {
661 let (mut core, _path, _clock) = setup_crash("crash-forfeit", CRASH_AT_3);
662 let mut state = ConnectionState::default();
663 register(&mut core, &mut state, "alice");
664 login(&mut core, &mut state, "alice");
665
666 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
667 let forfeited = crash_state(&core.handle_message(
668 &mut state,
669 &format!("CRASH_FORFEIT {}", started.session_id),
670 ));
671
672 assert_eq!(forfeited.phase, "forfeit");
673 assert_eq!(forfeited.payout, 0);
674 assert_eq!(core.balance("alice"), Some(990));
675 assert_eq!(core.play_count("alice"), 1);
676 assert_eq!(core.session_count(), 0);
677}
678
679#[test]
680fn crash_disconnect_forfeits_the_reserved_bet() {
681 let (mut core, _path, _clock) = setup_crash("crash-disconnect", CRASH_AT_3);
682 let mut state = ConnectionState::default();
683 register(&mut core, &mut state, "alice");
684 login(&mut core, &mut state, "alice");
685
686 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
687 assert_eq!(started.balance, 990);
688
689 core.disconnect(&state);
690
691 assert_eq!(core.balance("alice"), Some(990));
692 assert_eq!(core.play_count("alice"), 1);
693 assert_eq!(core.session_count(), 0);
694}
695
696#[test]
697fn crash_rejects_a_second_round_and_bad_cashout_formats() {
698 let (mut core, _path, _clock) = setup_crash("crash-guards", CRASH_AT_3);
699 let mut state = ConnectionState::default();
700 register(&mut core, &mut state, "alice");
701 login(&mut core, &mut state, "alice");
702
703 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
704 assert_eq!(
705 core.handle_message(&mut state, "PLAY_START crash 10"),
706 "ERROR Crash round already in progress"
707 );
708 assert_eq!(
709 core.handle_message(
710 &mut state,
711 &format!("CRASH_CASHOUT {} 50.0", started.session_id)
712 ),
713 "ERROR Invalid CRASH_CASHOUT format. Usage: CRASH_CASHOUT <session>"
714 );
715 assert_eq!(
716 core.handle_message(&mut state, "CRASH_CASHOUT nope"),
717 "ERROR Session not found"
718 );
719}
720
721#[test]
722fn crash_rounds_are_owned_by_the_player_who_started_them() {
723 let (mut core, _path, clock) = setup_crash("crash-owner", CRASH_AT_3);
724 let mut alice = ConnectionState::default();
725 let mut bob = ConnectionState::default();
726 register(&mut core, &mut alice, "alice");
727 register(&mut core, &mut bob, "bob");
728 login(&mut core, &mut alice, "alice");
729 login(&mut core, &mut bob, "bob");
730
731 let started = crash_state(&core.handle_message(&mut alice, "PLAY_START crash 10"));
732
733 clock.advance(5.0);
734 assert_eq!(
735 core.handle_message(
736 &mut bob,
737 &format!("CRASH_CASHOUT {}", started.session_id)
738 ),
739 "ERROR Invalid session"
740 );
741 assert_eq!(core.balance("bob"), Some(1000));
742 assert_eq!(core.session_count(), 1, "alice's round must survive");
743}