summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 17:18:34 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 17:18:34 +0100
commitd9f1fe513e6f7696809d3a9b4d44ca485fe99c97 (patch)
tree9da99200fdfe004a47056d10340b3f59c7ccc52e
parentv2.0.0: reset plays audit to a generic per-game schema (diff)
downloadzagan-bet-d9f1fe513e6f7696809d3a9b4d44ca485fe99c97.tar.gz
zagan-bet-d9f1fe513e6f7696809d3a9b4d44ca485fe99c97.zip
v2.1.0: add European roulette with split bets and wheel animation
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GAMES.md63
-rw-r--r--docs/TESTING.md14
-rw-r--r--src/bin/client/repl.rs54
-rw-r--r--src/client/core.rs183
-rw-r--r--src/client/games/manager.rs131
-rw-r--r--src/client/games/mod.rs1
-rw-r--r--src/client/games/response.rs64
-rw-r--r--src/client/games/roulette.rs157
-rw-r--r--src/lib.rs1
-rw-r--r--src/roulette.rs132
-rw-r--r--src/server/core.rs77
-rw-r--r--src/server/games/mod.rs1
-rw-r--r--src/server/games/registry.rs2
-rw-r--r--src/server/games/roulette.rs151
-rw-r--r--tests/client_core.rs88
-rw-r--r--tests/roulette.rs184
-rw-r--r--tests/server_core.rs59
18 files changed, 1312 insertions, 52 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 6b32e67..9432fcd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "2.0.0" 3version = "2.1.0"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
diff --git a/docs/GAMES.md b/docs/GAMES.md
index cbe355e..52ab1bf 100644
--- a/docs/GAMES.md
+++ b/docs/GAMES.md
@@ -5,18 +5,40 @@
5| Game | Status | Notes | 5| Game | Status | Notes |
6| --- | --- | --- | 6| --- | --- | --- |
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 9
9## Planned 10## Planned
10 11
11| Game | Shape | Main open decisions | 12| Game | Shape | Main open decisions |
12| --- | --- | --- | 13| --- | --- | --- |
13| Roulette | One spin, many bets | European/French/American wheel; which bet types; 35:1 straight and other payouts |
14| Blackjack | Multi-step hand | Dealer hits/stands soft 17; double/split/insurance; blackjack payout; shoe persistence | 14| Blackjack | Multi-step hand | Dealer hits/stands soft 17; double/split/insurance; blackjack payout; shoe persistence |
15| Crash | Real-time round | Crash-point distribution; cashout latency; provable fairness | 15| Crash | Real-time round | Crash-point distribution; cashout latency; provable fairness |
16 16
17Roulette fits the current one-request-settle model most easily. Blackjack needs 17Blackjack needs server-side hand state and player actions. Crash needs a timed
18server-side hand state and player actions. Crash needs a timed round loop and a 18round loop and a cashout action, so it should come after blackjack unless we
19cashout action, so it should come last unless we redesign the loop first. 19redesign the loop first.
20
21## Roulette
22
23- European wheel: 0-36, one green zero.
24- Bets: red, black, even, odd, and straight numbers.
25- Bets can be split across all of those; every winning bet is added together.
26- Straight pays 35:1 (36x returned), outside bets pay 1:1 (2x returned).
27- 0 loses all outside bets. RTP is 36/37 = 97.30%.
28
29Client controls inside roulette:
30
31```
32R / B / E / O add the current chip to red/black/even/odd
33digits + Enter add the current chip to that number
34C clear all bets
35+ or = / - change the chip size
36SPACE spin
37Q leave the table
38```
39
40The result is shown as a CS:GO-style horizontal strip: the ball marker stays
41fixed and the wheel numbers scroll beneath it, ending on the winning number.
20 42
21## Audit schema 43## Audit schema
22 44
@@ -35,35 +57,36 @@ plays (
35) 57)
36``` 58```
37 59
38`game_type` is the game that was played. `details` is a JSON object whose shape 60`details` is game-specific JSON:
39depends on the game:
40 61
41```json 62```json
42// slots 63// slots
43{"reels":"CCCCLLCLODGSDGS","win_type":"win","win_lines":"1","win_cells":"0,3,6"} 64{"reels":"CCCCLLCLODGSDGS","win_type":"win","win_lines":"1","win_cells":"0,3,6"}
44 65
45// rumoured future examples 66// roulette
46{"player":["AS","10H"],"dealer":["9D","7C"],"outcome":"blackjack"} 67{"number":"17","color":"black","bets":"{\"red\":10,\"black\":10,\"even\":10,\"odd\":10,\"numbers\":[{\"number\":17,\"amount\":5}]}"}
47{"bets":[{"kind":"straight","number":17,"amount":10}],"winning":17}
48{"crash_point":2.41,"cashout":1.80}
49``` 68```
50 69
51The balance updates and the `plays` insert happen in one transaction 70The balance update and the `plays` insert happen in one transaction
52(`database::apply_play`). The slots-specific wire fields (`reels`, `win_lines`, 71(`database::apply_play`).
53`win_cells`) are still in `GAME_RESULT`, but the database no longer assumes
54them.
55 72
56## Protocol gap 73## Wire protocol
57 74
58The current exchange is slots-shaped: 75Slots keeps its original exchange:
59 76
60``` 77```
61PLAY_START <game> <bet> 78PLAY_START slots <bet>
62GAME_RESULT <session> <winnings> <bet> <balance> <reels> <win_type> <win_lines> <win_cells> 79GAME_RESULT <session> <winnings> <bet> <balance> <reels> <win_type> <win_lines> <win_cells>
63``` 80```
64 81
65Before blackjack, roulette, or crash can be added, the game session needs a 82Roulette adds a compact bets payload and a roulette-specific result:
66generic state/action protocol. A possible shape: 83
84```
85PLAY_START roulette <total> <bets-json>
86ROULETTE_RESULT <session> <winnings> <bet> <balance> <number> <color> <bets-json>
87```
88
89Blackjack and crash still need a generic state/action exchange, for example:
67 90
68``` 91```
69GAME_START <game> <bet> 92GAME_START <game> <bet>
@@ -71,4 +94,4 @@ GAME_ACTION <action> [json]
71GAME_STATE <json> # cards, bets, multiplier, legal actions, result 94GAME_STATE <json> # cards, bets, multiplier, legal actions, result
72``` 95```
73 96
74That is a wire-protocol change and will be its own major version. 97That will be its own protocol change.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 4d46855..2dcb01f 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/protocol.rs` | Newline buffering and timeout/error classification. | 16| `tests/protocol.rs` | Newline buffering and timeout/error classification. |
17| `tests/roulette.rs` | European wheel, split-bet payouts, zero handling, result parser. |
17| `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | 18| `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. |
18| `tests/server_setup.rs` | `--init` identity generation, PKCS#12 loadability, overwrite refusal. | 19| `tests/server_setup.rs` | `--init` identity generation, PKCS#12 loadability, overwrite refusal. |
19| `tests/slots.rs` | Server slots payout rules and overflow handling. | 20| `tests/slots.rs` | Server slots payout rules and overflow handling. |
@@ -63,6 +64,19 @@ Use an 80-column terminal because several output lines are width-sensitive.
63 fingerprint fits 80 columns. 64 fingerprint fits 80 columns.
6410. Windows: repeat 2-9 and confirm keys are not duplicated or printed twice. 6510. Windows: repeat 2-9 and confirm keys are not duplicated or printed twice.
65 66
67## Manual roulette checklist
68
691. `play roulette` shows the balance, chip size, empty bets, and controls.
702. `R`/`B`/`E`/`O` add one chip; digits + Enter add a number; `C` clears;
71 `+`/`=`/`-` change the chip size.
723. Space scrolls the wheel under a fixed ball marker and ends on the winning
73 number.
744. Check a split result:
75 - `17 black odd`: black+odd pay, red+even lose, straight 17 pays 36x.
76 - `0`: all outside bets lose; a straight 0 pays 36x.
775. The result board shows the winning number/color/parity, the payout, and the
78 new balance. The next spin reuses the placed bets.
79
66## Manual server checks 80## Manual server checks
67 81
68- Register, log out, log in again (bcrypt path). 82- Register, log out, log in again (bcrypt path).
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs
index 1ed3073..e028cc2 100644
--- a/src/bin/client/repl.rs
+++ b/src/bin/client/repl.rs
@@ -20,6 +20,7 @@ use zagan_bet::client::ui::{
20 visual_line_count, 20 visual_line_count,
21}; 21};
22use zagan_bet::protocol::io_failure_message; 22use zagan_bet::protocol::io_failure_message;
23use zagan_bet::roulette::WHEEL;
23 24
24use crate::connection::TlsStream; 25use crate::connection::TlsStream;
25 26
@@ -150,6 +151,16 @@ impl ClientState {
150 } 151 }
151 } 152 }
152 Effect::ReplaceRegion(lines) => self.draw_region(&lines), 153 Effect::ReplaceRegion(lines) => self.draw_region(&lines),
154 Effect::AnimateRoulette {
155 winning,
156 final_lines,
157 } => {
158 self.animate_wheel(winning);
159 self.draw_region(&final_lines);
160 while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) {
161 let _ = crossterm::event::read();
162 }
163 }
153 Effect::ClearRegion => self.clear_region(), 164 Effect::ClearRegion => self.clear_region(),
154 Effect::SetPrompt { text, cursor_col } => self.draw_prompt(&text, cursor_col), 165 Effect::SetPrompt { text, cursor_col } => self.draw_prompt(&text, cursor_col),
155 Effect::SendLine(line) => { 166 Effect::SendLine(line) => {
@@ -276,6 +287,49 @@ impl ClientState {
276 self.game_lines = 0; 287 self.game_lines = 0;
277 } 288 }
278 289
290 /// CS:GO-style wheel strip: the ball marker is fixed and the numbers move
291 /// left-to-right under it, ending with the winning number in the center.
292 fn animate_wheel(&mut self, winning: u8) {
293 use std::thread;
294 use std::time::Duration;
295
296 let target = WHEEL
297 .iter()
298 .position(|number| *number == winning)
299 .unwrap_or(0) as f64;
300 let wheel_len = WHEEL.len() as f64;
301 let start = target - 4.0 * wheel_len - 7.0;
302 let frames = 32;
303
304 for frame in 0..frames {
305 let t = frame as f64 / (frames - 1) as f64;
306 let ease = 1.0 - (1.0 - t).powi(3);
307 let center = (start + (target - start) * ease).round() as isize;
308
309 let strip = (0..9)
310 .map(|offset| {
311 let index = (center - 4 + offset).rem_euclid(WHEEL.len() as isize) as usize;
312 format!("[ {:>2} ]", WHEEL[index])
313 })
314 .collect::<Vec<_>>()
315 .join(" ");
316 let marker = format!("{}â–¼", " ".repeat(4 * 7 + 3));
317 let lines = vec![
318 "=== ROULETTE ===".to_string(),
319 strip,
320 marker,
321 "Spinning...".to_string(),
322 ];
323
324 self.draw_region(&lines);
325 thread::sleep(Duration::from_millis(35));
326 }
327
328 // Let the winning number sit under the static ball before the result
329 // board replaces the wheel.
330 thread::sleep(Duration::from_millis(450));
331 }
332
279 fn draw_prompt(&mut self, text: &str, cursor_col: u16) { 333 fn draw_prompt(&mut self, text: &str, cursor_col: u16) {
280 execute!(stdout(), MoveToColumn(0), Clear(ClearType::CurrentLine)).unwrap(); 334 execute!(stdout(), MoveToColumn(0), Clear(ClearType::CurrentLine)).unwrap();
281 print!("{}", "> ".cyan()); 335 print!("{}", "> ".cyan());
diff --git a/src/client/core.rs b/src/client/core.rs
index a88991b..e005f2a 100644
--- a/src/client/core.rs
+++ b/src/client/core.rs
@@ -1,9 +1,10 @@
1use crate::client::games::manager::GameManager; 1use crate::client::games::manager::GameManager;
2use crate::client::games::response::parse_spin_response; 2use crate::client::games::response::{parse_roulette_response, parse_spin_response};
3use crate::client::ui::{ 3use crate::client::ui::{
4 command_names, completion_lines, display_width, help_lines, input_cursor_column, 4 command_names, completion_lines, display_width, help_lines, input_cursor_column,
5 parse_bet_arg, record_history, 5 parse_bet_arg, record_history,
6}; 6};
7use crate::roulette::{Bets, NumberBet};
7 8
8#[derive(Debug, Clone, Copy, PartialEq, Eq)] 9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Key { 10pub enum Key {
@@ -33,6 +34,11 @@ pub enum Effect {
33 Print(Vec<String>), 34 Print(Vec<String>),
34 /// Replace the game board region in place. 35 /// Replace the game board region in place.
35 ReplaceRegion(Vec<String>), 36 ReplaceRegion(Vec<String>),
37 /// Run the roulette wheel animation, then draw the final board lines.
38 AnimateRoulette {
39 winning: u8,
40 final_lines: Vec<String>,
41 },
36 /// Remove the game board region. 42 /// Remove the game board region.
37 ClearRegion, 43 ClearRegion,
38 /// Redraw the command prompt. `text` is the input without the `> ` prefix. 44 /// Redraw the command prompt. `text` is the input without the `> ` prefix.
@@ -54,11 +60,20 @@ pub enum Effect {
54#[derive(Debug, Clone, Copy, PartialEq, Eq)] 60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55enum Pending { 61enum Pending {
56 Spin, 62 Spin,
63 RouletteSpin,
57 Ping, 64 Ping,
58 Echo, 65 Echo,
59 Info, 66 Info,
60} 67}
61 68
69#[derive(Debug, Clone, Copy)]
70enum RouletteSide {
71 Red,
72 Black,
73 Even,
74 Odd,
75}
76
62pub struct ClientCore { 77pub struct ClientCore {
63 input: String, 78 input: String,
64 history: Vec<String>, 79 history: Vec<String>,
@@ -160,6 +175,22 @@ impl ClientCore {
160 self.board_effects() 175 self.board_effects()
161 } 176 }
162 }, 177 },
178 Some(Pending::RouletteSpin) => match parse_roulette_response(reply) {
179 Ok(result) => {
180 self.balance = Some(result.balance);
181 let winning = result.number;
182 self.game.apply_roulette_result(result);
183 let final_lines = self.board_lines().unwrap_or_default();
184 vec![Effect::AnimateRoulette {
185 winning,
186 final_lines,
187 }]
188 }
189 Err(message) => {
190 self.game.set_error(message);
191 self.board_effects()
192 }
193 },
163 Some(Pending::Ping) => { 194 Some(Pending::Ping) => {
164 if reply.trim() == "PONG" { 195 if reply.trim() == "PONG" {
165 print_line("Pong") 196 print_line("Pong")
@@ -194,6 +225,15 @@ impl ClientCore {
194 } 225 }
195 226
196 fn handle_game_key(&mut self, key: Key) -> Vec<Effect> { 227 fn handle_game_key(&mut self, key: Key) -> Vec<Effect> {
228 if self
229 .game
230 .get_current_state()
231 .map(|state| state.game_type.as_str())
232 == Some("roulette")
233 {
234 return self.handle_roulette_key(key);
235 }
236
197 match key { 237 match key {
198 Key::CtrlC => vec![Effect::Quit], 238 Key::CtrlC => vec![Effect::Quit],
199 Key::Esc | Key::Char('q') => { 239 Key::Esc | Key::Char('q') => {
@@ -230,6 +270,147 @@ impl ClientCore {
230 effects 270 effects
231 } 271 }
232 272
273 fn handle_roulette_key(&mut self, key: Key) -> Vec<Effect> {
274 match key {
275 Key::CtrlC => vec![Effect::Quit],
276 Key::Char('q') => {
277 self.game.end_game();
278 vec![
279 Effect::ClearRegion,
280 Effect::Print(vec!["Game exited.".to_string()]),
281 ]
282 }
283 Key::Esc => {
284 self.game.set_roulette_number_input("");
285 self.board_effects()
286 }
287 Key::Char(' ') => self.spin_roulette(),
288 Key::Char('r') => self.add_roulette_side(RouletteSide::Red),
289 Key::Char('b') => self.add_roulette_side(RouletteSide::Black),
290 Key::Char('e') => self.add_roulette_side(RouletteSide::Even),
291 Key::Char('o') => self.add_roulette_side(RouletteSide::Odd),
292 Key::Char('c') => {
293 self.game.set_roulette_bets(&Bets::default());
294 self.game.set_roulette_number_input("");
295 self.game.set_roulette_status("place");
296 self.board_effects()
297 }
298 Key::Char('+') | Key::Char('=') => {
299 let chip = self.game.roulette_chip().saturating_add(10);
300 self.game.set_roulette_chip(chip);
301 self.board_effects()
302 }
303 Key::Char('-') => {
304 let chip = self.game.roulette_chip().saturating_sub(10).max(10);
305 self.game.set_roulette_chip(chip);
306 self.board_effects()
307 }
308 Key::Char(c) if c.is_ascii_digit() => {
309 let mut input = self.roulette_input();
310 if input.len() < 2 {
311 input.push(c);
312 self.game.set_roulette_number_input(&input);
313 }
314 self.board_effects()
315 }
316 Key::Backspace => {
317 let mut input = self.roulette_input();
318 input.pop();
319 self.game.set_roulette_number_input(&input);
320 self.board_effects()
321 }
322 Key::Enter => {
323 let input = self.roulette_input();
324 if !input.is_empty() {
325 match input.parse::<u8>() {
326 Ok(number) if number <= 36 => {
327 self.add_roulette_number(number);
328 self.game.set_roulette_number_input("");
329 }
330 _ => {
331 self.game.set_roulette_number_input("");
332 self.game.set_error("Number must be 0-36");
333 }
334 }
335 }
336 self.board_effects()
337 }
338 _ => Vec::new(),
339 }
340 }
341
342 fn add_roulette_side(&mut self, side: RouletteSide) -> Vec<Effect> {
343 let chip = self.game.roulette_chip();
344 let mut bets = self.game.roulette_bets();
345 match side {
346 RouletteSide::Red => bets.red = bets.red.saturating_add(chip),
347 RouletteSide::Black => bets.black = bets.black.saturating_add(chip),
348 RouletteSide::Even => bets.even = bets.even.saturating_add(chip),
349 RouletteSide::Odd => bets.odd = bets.odd.saturating_add(chip),
350 }
351 self.place_roulette_bets(bets)
352 }
353
354 fn add_roulette_number(&mut self, number: u8) -> Vec<Effect> {
355 let chip = self.game.roulette_chip();
356 let mut bets = self.game.roulette_bets();
357 if let Some(existing) = bets.numbers.iter_mut().find(|bet| bet.number == number) {
358 existing.amount = existing.amount.saturating_add(chip);
359 } else {
360 bets.numbers.push(NumberBet {
361 number,
362 amount: chip,
363 });
364 }
365 self.place_roulette_bets(bets)
366 }
367
368 fn place_roulette_bets(&mut self, bets: Bets) -> Vec<Effect> {
369 if let Some(balance) = self.balance {
370 if bets.total() > balance.max(0) as u64 {
371 self.game.set_error("Insufficient balance");
372 return self.board_effects();
373 }
374 }
375
376 self.game.set_roulette_bets(&bets);
377 self.game.set_roulette_status("place");
378 self.board_effects()
379 }
380
381 fn spin_roulette(&mut self) -> Vec<Effect> {
382 let bets = self.game.roulette_bets();
383 if bets.is_empty() {
384 self.game.set_error("Place at least one bet");
385 return self.board_effects();
386 }
387 if let Some(balance) = self.balance {
388 if bets.total() > balance.max(0) as u64 {
389 self.game.set_error("Insufficient balance");
390 return self.board_effects();
391 }
392 }
393 let Some(request) = self.game.spin_request() else {
394 return Vec::new();
395 };
396 if self.game.begin_spin().is_err() {
397 return Vec::new();
398 }
399 self.pending = Some(Pending::RouletteSpin);
400
401 let mut effects = self.board_effects();
402 effects.push(Effect::SendLine(request));
403 effects
404 }
405
406 fn roulette_input(&self) -> String {
407 self.game
408 .get_current_state()
409 .and_then(|state| state.data.get("number_input"))
410 .cloned()
411 .unwrap_or_default()
412 }
413
233 fn submit(&mut self) -> Vec<Effect> { 414 fn submit(&mut self) -> Vec<Effect> {
234 let line = self.input.trim().to_string(); 415 let line = self.input.trim().to_string();
235 self.input.clear(); 416 self.input.clear();
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs
index 168c49d..02e9451 100644
--- a/src/client/games/manager.rs
+++ b/src/client/games/manager.rs
@@ -1,6 +1,8 @@
1use super::response::SpinResult; 1use super::response::{RouletteResult, SpinResult};
2use super::roulette::RouletteGame;
2use super::slots::SlotsGame; 3use super::slots::SlotsGame;
3use super::traits::{Game, GameError, GamePhase, GameState}; 4use super::traits::{Game, GameError, GamePhase, GameState};
5use crate::roulette::Bets;
4use std::collections::HashMap; 6use std::collections::HashMap;
5 7
6pub struct GameManager { 8pub struct GameManager {
@@ -12,6 +14,7 @@ impl GameManager {
12 pub fn new() -> Self { 14 pub fn new() -> Self {
13 let mut games: HashMap<String, Box<dyn Game>> = HashMap::new(); 15 let mut games: HashMap<String, Box<dyn Game>> = HashMap::new();
14 games.insert("slots".to_string(), Box::new(SlotsGame::new())); 16 games.insert("slots".to_string(), Box::new(SlotsGame::new()));
17 games.insert("roulette".to_string(), Box::new(RouletteGame::new()));
15 18
16 Self { 19 Self {
17 current_session: None, 20 current_session: None,
@@ -38,11 +41,25 @@ impl GameManager {
38 return Err(GameError::InvalidAction); 41 return Err(GameError::InvalidAction);
39 } 42 }
40 43
44 let mut data = HashMap::new();
45 let bet_amount = if game_type == "roulette" {
46 data.insert(
47 "bets".to_string(),
48 serde_json::to_string(&Bets::default()).unwrap_or_else(|_| "{}".to_string()),
49 );
50 data.insert("chip".to_string(), "10".to_string());
51 data.insert("status".to_string(), "place".to_string());
52 data.insert("number_input".to_string(), String::new());
53 0
54 } else {
55 10
56 };
57
41 self.current_session = Some(GameState { 58 self.current_session = Some(GameState {
42 game_type: game_type.to_string(), 59 game_type: game_type.to_string(),
43 bet_amount: 10, 60 bet_amount,
44 phase: GamePhase::WaitingToStart, 61 phase: GamePhase::WaitingToStart,
45 data: HashMap::new(), 62 data,
46 }); 63 });
47 64
48 Ok(()) 65 Ok(())
@@ -54,6 +71,17 @@ impl GameManager {
54 if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { 71 if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished {
55 return None; 72 return None;
56 } 73 }
74 if session.game_type == "roulette" {
75 let bets = session
76 .data
77 .get("bets")
78 .cloned()
79 .unwrap_or_else(|| "{}".to_string());
80 return Some(format!(
81 "PLAY_START roulette {} {}",
82 session.bet_amount, bets
83 ));
84 }
57 Some(format!( 85 Some(format!(
58 "PLAY_START {} {}", 86 "PLAY_START {} {}",
59 session.game_type, session.bet_amount 87 session.game_type, session.bet_amount
@@ -78,6 +106,13 @@ impl GameManager {
78 session.data.remove("win_lines"); 106 session.data.remove("win_lines");
79 session.data.remove("win_cells"); 107 session.data.remove("win_cells");
80 session.data.remove("winnings"); 108 session.data.remove("winnings");
109 session.data.remove("number");
110 session.data.remove("color");
111 if session.game_type == "roulette" {
112 session
113 .data
114 .insert("status".to_string(), "spinning".to_string());
115 }
81 Ok(()) 116 Ok(())
82 } 117 }
83 118
@@ -130,6 +165,13 @@ impl GameManager {
130 session.data.remove("win_lines"); 165 session.data.remove("win_lines");
131 session.data.remove("win_cells"); 166 session.data.remove("win_cells");
132 session.data.remove("winnings"); 167 session.data.remove("winnings");
168 session.data.remove("number");
169 session.data.remove("color");
170 if session.game_type == "roulette" {
171 session
172 .data
173 .insert("status".to_string(), "place".to_string());
174 }
133 session.data.insert("error".to_string(), message.into()); 175 session.data.insert("error".to_string(), message.into());
134 } 176 }
135 177
@@ -141,6 +183,83 @@ impl GameManager {
141 } 183 }
142 } 184 }
143 185
186 pub fn roulette_bets(&self) -> Bets {
187 self.current_session
188 .as_ref()
189 .and_then(|session| session.data.get("bets"))
190 .and_then(|raw| serde_json::from_str(raw).ok())
191 .unwrap_or_default()
192 }
193
194 pub fn set_roulette_bets(&mut self, bets: &Bets) {
195 if let Some(session) = self.current_session.as_mut() {
196 session.data.insert(
197 "bets".to_string(),
198 serde_json::to_string(bets).unwrap_or_else(|_| "{}".to_string()),
199 );
200 session.bet_amount = bets.total();
201 }
202 }
203
204 pub fn roulette_chip(&self) -> u64 {
205 self.current_session
206 .as_ref()
207 .and_then(|session| session.data.get("chip"))
208 .and_then(|value| value.parse().ok())
209 .unwrap_or(10)
210 }
211
212 pub fn set_roulette_chip(&mut self, chip: u64) {
213 if let Some(session) = self.current_session.as_mut() {
214 session.data.insert("chip".to_string(), chip.to_string());
215 }
216 }
217
218 pub fn set_roulette_number_input(&mut self, value: &str) {
219 if let Some(session) = self.current_session.as_mut() {
220 session
221 .data
222 .insert("number_input".to_string(), value.to_string());
223 }
224 }
225
226 pub fn set_roulette_status(&mut self, status: &str) {
227 if let Some(session) = self.current_session.as_mut() {
228 session
229 .data
230 .insert("status".to_string(), status.to_string());
231 if status != "error" {
232 session.data.remove("error");
233 }
234 }
235 }
236
237 pub fn apply_roulette_result(&mut self, result: RouletteResult) {
238 let Some(session) = self.current_session.as_mut() else {
239 return;
240 };
241 session.phase = GamePhase::Finished;
242 session.bet_amount = result.bet_amount;
243 session.data.remove("error");
244 session.data.insert("bets".to_string(), result.bets);
245 session
246 .data
247 .insert("number".to_string(), result.number.to_string());
248 session.data.insert("color".to_string(), result.color);
249 session
250 .data
251 .insert("winnings".to_string(), result.winnings.to_string());
252 session
253 .data
254 .insert("balance".to_string(), result.balance.to_string());
255 session
256 .data
257 .insert("status".to_string(), "result".to_string());
258 session
259 .data
260 .insert("number_input".to_string(), String::new());
261 }
262
144 pub fn get_current_game(&self) -> Option<&dyn Game> { 263 pub fn get_current_game(&self) -> Option<&dyn Game> {
145 self.current_session 264 self.current_session
146 .as_ref() 265 .as_ref()
@@ -167,6 +286,12 @@ impl GameManager {
167 if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { 286 if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished {
168 return Err(GameError::InvalidAction); 287 return Err(GameError::InvalidAction);
169 } 288 }
289 if session.game_type == "roulette" {
290 session
291 .data
292 .insert("chip".to_string(), bet.max(10).to_string());
293 return Ok(bet.max(10));
294 }
170 session.bet_amount = bet.max(10); 295 session.bet_amount = bet.max(10);
171 Ok(session.bet_amount) 296 Ok(session.bet_amount)
172 } 297 }
diff --git a/src/client/games/mod.rs b/src/client/games/mod.rs
index 63cc579..131ffbb 100644
--- a/src/client/games/mod.rs
+++ b/src/client/games/mod.rs
@@ -1,4 +1,5 @@
1pub mod manager; 1pub mod manager;
2pub mod response; 2pub mod response;
3pub mod roulette;
3pub mod slots; 4pub mod slots;
4pub mod traits; 5pub mod traits;
diff --git a/src/client/games/response.rs b/src/client/games/response.rs
index d7f9d45..73080dd 100644
--- a/src/client/games/response.rs
+++ b/src/client/games/response.rs
@@ -77,3 +77,67 @@ fn parse_indices(raw: &str) -> Vec<usize> {
77 .filter_map(|value| value.trim().parse::<usize>().ok()) 77 .filter_map(|value| value.trim().parse::<usize>().ok())
78 .collect() 78 .collect()
79} 79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct RouletteResult {
83 pub session_id: String,
84 pub winnings: u64,
85 pub bet_amount: u64,
86 pub balance: i64,
87 pub number: u8,
88 pub color: String,
89 pub bets: String,
90}
91
92pub fn parse_roulette_response(response: &str) -> Result<RouletteResult, String> {
93 let response = response.trim();
94 if response.is_empty() {
95 return Err("No response from server".to_string());
96 }
97 if let Some(message) = response.strip_prefix("ERROR") {
98 let message = message.trim();
99 return Err(if message.is_empty() {
100 "Server error".to_string()
101 } else {
102 message.to_string()
103 });
104 }
105
106 let parts: Vec<&str> = response.split_whitespace().collect();
107 if parts.first() != Some(&"ROULETTE_RESULT") || parts.len() < 8 {
108 return Err(format!("Unexpected server response: {response}"));
109 }
110
111 let winnings = parts[2]
112 .parse::<u64>()
113 .map_err(|_| format!("Invalid winnings from server: {}", parts[2]))?;
114 let bet_amount = parts[3]
115 .parse::<u64>()
116 .map_err(|_| format!("Invalid bet from server: {}", parts[3]))?;
117 let balance = parts[4]
118 .parse::<i64>()
119 .map_err(|_| format!("Invalid balance from server: {}", parts[4]))?;
120 let number = parts[5]
121 .parse::<u8>()
122 .map_err(|_| format!("Invalid roulette number: {}", parts[5]))?;
123 if number > 36 {
124 return Err(format!("Invalid roulette number: {number}"));
125 }
126 let color = parts[6].to_string();
127 if !matches!(color.as_str(), "green" | "red" | "black") {
128 return Err(format!("Invalid roulette color: {color}"));
129 }
130 let bets = parts[7].to_string();
131 let _: crate::roulette::Bets = serde_json::from_str(&bets)
132 .map_err(|error| format!("Invalid roulette bets: {error}"))?;
133
134 Ok(RouletteResult {
135 session_id: parts[1].to_string(),
136 winnings,
137 bet_amount,
138 balance,
139 number,
140 color,
141 bets,
142 })
143}
diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs
new file mode 100644
index 0000000..3379317
--- /dev/null
+++ b/src/client/games/roulette.rs
@@ -0,0 +1,157 @@
1use super::traits::{Game, GameState};
2use crate::roulette::{is_even, is_odd, Bets};
3
4pub const ROULETTE_BOARD_LINES: usize = 8;
5
6pub struct RouletteGame;
7
8impl RouletteGame {
9 pub fn new() -> Self {
10 Self
11 }
12
13 fn bets(state: &GameState) -> Bets {
14 state
15 .data
16 .get("bets")
17 .and_then(|raw| serde_json::from_str(raw).ok())
18 .unwrap_or_default()
19 }
20
21 fn bets_line(bets: &Bets) -> String {
22 let mut parts = Vec::new();
23 if bets.red > 0 {
24 parts.push(format!("RED {}", bets.red));
25 }
26 if bets.black > 0 {
27 parts.push(format!("BLACK {}", bets.black));
28 }
29 if bets.even > 0 {
30 parts.push(format!("EVEN {}", bets.even));
31 }
32 if bets.odd > 0 {
33 parts.push(format!("ODD {}", bets.odd));
34 }
35 for bet in &bets.numbers {
36 parts.push(format!("#{} {}", bet.number, bet.amount));
37 }
38
39 let list = if parts.is_empty() {
40 "(none)".to_string()
41 } else {
42 parts.join(" ")
43 };
44 format!("Bets: {} Total: {} PLN", list, bets.total())
45 }
46
47 fn result_lines(state: &GameState) -> (String, String) {
48 if let Some(error) = state.data.get("error") {
49 if !error.is_empty() {
50 return (format!("ERROR: {error}"), String::new());
51 }
52 }
53
54 match state.data.get("status").map(String::as_str) {
55 Some("result") => {
56 let number = state
57 .data
58 .get("number")
59 .map(String::as_str)
60 .unwrap_or("?");
61 let color = state
62 .data
63 .get("color")
64 .map(|value| value.to_uppercase())
65 .unwrap_or_default();
66 let parity = number
67 .parse::<u8>()
68 .ok()
69 .map(|number| {
70 if is_even(number) {
71 " EVEN"
72 } else if is_odd(number) {
73 " ODD"
74 } else {
75 ""
76 }
77 })
78 .unwrap_or("");
79 let winnings = state
80 .data
81 .get("winnings")
82 .map(String::as_str)
83 .unwrap_or("0");
84 let result = if winnings == "0" {
85 "No win.".to_string()
86 } else {
87 format!("Won {winnings} PLN")
88 };
89 (format!("Winning: {number} {color}{parity}"), result)
90 }
91 Some("spinning") => ("Spinning...".to_string(), String::new()),
92 _ => ("Place bets, then SPACE to spin.".to_string(), String::new()),
93 }
94 }
95}
96
97impl Game for RouletteGame {
98 fn get_name(&self) -> &'static str {
99 "Roulette"
100 }
101
102 fn render(&self, state: &GameState) -> String {
103 let bets = Self::bets(state);
104 let chip = state.data.get("chip").map(String::as_str).unwrap_or("10");
105 let balance = state
106 .data
107 .get("balance")
108 .map(String::as_str)
109 .unwrap_or("?");
110 let input = state
111 .data
112 .get("number_input")
113 .map(String::as_str)
114 .unwrap_or("");
115 let input_line = if input.is_empty() {
116 String::new()
117 } else {
118 format!("Number: {input}_")
119 };
120 let (status, result) = Self::result_lines(state);
121
122 format!(
123 "=== ROULETTE ===\r\nBalance: {} PLN Chip: {} PLN\r\n{}\r\n{}\r\n{}\r\n{}\r\n[R]ed [B]lack [E]ven [O]dd [#]Number [C]lear\r\n[+/-] Chip [SPACE] Spin [Q] Quit",
124 balance,
125 chip,
126 Self::bets_line(&bets),
127 input_line,
128 status,
129 result
130 )
131 }
132
133 fn render_lines(&self, _state: &GameState) -> usize {
134 ROULETTE_BOARD_LINES
135 }
136
137 fn get_help(&self) -> String {
138 "ROULETTE (European)\n\
139 ==================\n\
140 37 pockets: 0 plus 1-36. 0 is green and loses all outside bets.\n\
141 \n\
142 Betting:\n\
143 R / B / E / O - add the current chip to red/black/even/odd\n\
144 digits + Enter - add the current chip to that number\n\
145 C - clear all bets\n\
146 + or = / - - change the chip size\n\
147 SPACE - spin when at least one bet is placed\n\
148 Q - exit\n\
149 \n\
150 Payouts:\n\
151 straight number: 35:1 (36x returned)\n\
152 red/black: 1:1 (2x returned)\n\
153 even/odd: 1:1 (2x returned)\n\
154 Multiple bets are settled independently and added together."
155 .to_string()
156 }
157}
diff --git a/src/lib.rs b/src/lib.rs
index a261ab0..84628e5 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,6 +7,7 @@
7 7
8pub mod client; 8pub mod client;
9pub mod protocol; 9pub mod protocol;
10pub mod roulette;
10pub mod server; 11pub mod server;
11pub mod slots; 12pub mod slots;
12pub mod tofu; 13pub mod tofu;
diff --git a/src/roulette.rs b/src/roulette.rs
new file mode 100644
index 0000000..b59f92e
--- /dev/null
+++ b/src/roulette.rs
@@ -0,0 +1,132 @@
1//! European roulette rules shared by the server and client.
2
3use serde::{Deserialize, Serialize};
4
5/// European wheel clockwise from zero.
6pub const WHEEL: [u8; 37] = [
7 0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5,
8 24, 16, 33, 1, 20, 14, 31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26,
9];
10
11pub const RED_NUMBERS: [u8; 18] = [
12 1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36,
13];
14
15/// Total return on a winning straight (35:1 plus the stake).
16pub const STRAIGHT_RETURN: u64 = 36;
17/// Total return on a winning outside bet (1:1 plus the stake).
18pub const EVEN_MONEY_RETURN: u64 = 2;
19
20pub fn is_red(number: u8) -> bool {
21 RED_NUMBERS.contains(&number)
22}
23
24pub fn is_black(number: u8) -> bool {
25 number != 0 && !is_red(number)
26}
27
28pub fn color(number: u8) -> &'static str {
29 if number == 0 {
30 "green"
31 } else if is_red(number) {
32 "red"
33 } else {
34 "black"
35 }
36}
37
38pub fn is_even(number: u8) -> bool {
39 number != 0 && number % 2 == 0
40}
41
42pub fn is_odd(number: u8) -> bool {
43 number != 0 && number % 2 == 1
44}
45
46pub fn rtp() -> f64 {
47 36.0 / 37.0
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct NumberBet {
52 pub number: u8,
53 pub amount: u64,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Bets {
58 #[serde(default)]
59 pub red: u64,
60 #[serde(default)]
61 pub black: u64,
62 #[serde(default)]
63 pub even: u64,
64 #[serde(default)]
65 pub odd: u64,
66 #[serde(default)]
67 pub numbers: Vec<NumberBet>,
68}
69
70impl Bets {
71 pub fn total(&self) -> u64 {
72 let mut total = self
73 .red
74 .saturating_add(self.black)
75 .saturating_add(self.even)
76 .saturating_add(self.odd);
77 for bet in &self.numbers {
78 total = total.saturating_add(bet.amount);
79 }
80 total
81 }
82
83 pub fn is_empty(&self) -> bool {
84 self.total() == 0
85 }
86
87 /// Total returned for a winning number, including returned stakes.
88 pub fn winnings(&self, number: u8) -> u64 {
89 let mut winnings = 0u64;
90
91 if number != 0 {
92 if is_red(number) {
93 winnings = winnings.saturating_add(self.red.saturating_mul(EVEN_MONEY_RETURN));
94 } else {
95 winnings =
96 winnings.saturating_add(self.black.saturating_mul(EVEN_MONEY_RETURN));
97 }
98 if is_even(number) {
99 winnings = winnings.saturating_add(self.even.saturating_mul(EVEN_MONEY_RETURN));
100 } else {
101 winnings = winnings.saturating_add(self.odd.saturating_mul(EVEN_MONEY_RETURN));
102 }
103 }
104
105 for bet in &self.numbers {
106 if bet.number == number {
107 winnings = winnings
108 .saturating_add(bet.amount.saturating_mul(STRAIGHT_RETURN));
109 }
110 }
111
112 winnings
113 }
114
115 pub fn validate(&self, balance: u64) -> Result<(), String> {
116 if self.is_empty() {
117 return Err("Place at least one bet".to_string());
118 }
119 if self.total() > balance {
120 return Err("Insufficient balance".to_string());
121 }
122 for bet in &self.numbers {
123 if bet.number > 36 {
124 return Err(format!("Invalid number: {}", bet.number));
125 }
126 if bet.amount == 0 {
127 return Err("A bet amount must be positive".to_string());
128 }
129 }
130 Ok(())
131 }
132}
diff --git a/src/server/core.rs b/src/server/core.rs
index 7231577..2ce8b2b 100644
--- a/src/server/core.rs
+++ b/src/server/core.rs
@@ -85,11 +85,11 @@ impl ServerCore {
85 } 85 }
86 } 86 }
87 "PLAY_START" => { 87 "PLAY_START" => {
88 if parts.len() != 3 { 88 if parts.len() != 3 && parts.len() != 4 {
89 "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount>" 89 "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount> [payload]"
90 .to_string() 90 .to_string()
91 } else { 91 } else {
92 self.handle_play_start(state, parts[1], parts[2]) 92 self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied())
93 } 93 }
94 } 94 }
95 "REGISTER" => { 95 "REGISTER" => {
@@ -187,6 +187,7 @@ impl ServerCore {
187 state: &ConnectionState, 187 state: &ConnectionState,
188 game_type: &str, 188 game_type: &str,
189 bet_amount_str: &str, 189 bet_amount_str: &str,
190 payload: Option<&str>,
190 ) -> String { 191 ) -> String {
191 let Some(user) = state.user.as_deref() else { 192 let Some(user) = state.user.as_deref() else {
192 return "ERROR Not logged in".to_string(); 193 return "ERROR Not logged in".to_string();
@@ -216,7 +217,8 @@ impl ServerCore {
216 } 217 }
217 218
218 let game_state = game.prepare_state(user, bet_amount as u64); 219 let game_state = game.prepare_state(user, bet_amount as u64);
219 let new_state = match game.process_action(&game_state, "spin", &[]) { 220 let params: Vec<&str> = payload.into_iter().collect();
221 let new_state = match game.process_action(&game_state, "spin", &params) {
220 Ok(state) => state, 222 Ok(state) => state,
221 Err(error) => return game_error(error), 223 Err(error) => return game_error(error),
222 }; 224 };
@@ -239,26 +241,6 @@ impl ServerCore {
239 } 241 }
240 }; 242 };
241 243
242 let reels = result
243 .result_data
244 .get("reels")
245 .cloned()
246 .unwrap_or_default();
247 let win_type = result
248 .result_data
249 .get("win_type")
250 .cloned()
251 .unwrap_or_default();
252 let win_lines = result
253 .result_data
254 .get("win_lines")
255 .cloned()
256 .unwrap_or_else(|| "-".to_string());
257 let win_cells = result
258 .result_data
259 .get("win_cells")
260 .cloned()
261 .unwrap_or_else(|| "-".to_string());
262 let details = 244 let details =
263 serde_json::to_string(&result.result_data).unwrap_or_else(|_| "{}".to_string()); 245 serde_json::to_string(&result.result_data).unwrap_or_else(|_| "{}".to_string());
264 246
@@ -273,9 +255,50 @@ impl ServerCore {
273 ) { 255 ) {
274 Ok(new_balance) => { 256 Ok(new_balance) => {
275 let _ = self.sessions.remove_session(&session_id); 257 let _ = self.sessions.remove_session(&session_id);
276 format!( 258 if game_type == "roulette" {
277 "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type} {win_lines} {win_cells}" 259 let number = result
278 ) 260 .result_data
261 .get("number")
262 .cloned()
263 .unwrap_or_default();
264 let color = result
265 .result_data
266 .get("color")
267 .cloned()
268 .unwrap_or_default();
269 let bets = result
270 .result_data
271 .get("bets")
272 .cloned()
273 .unwrap_or_else(|| "{}".to_string());
274 format!(
275 "ROULETTE_RESULT {session_id} {winnings} {bet_amount} {new_balance} {number} {color} {bets}"
276 )
277 } else {
278 let reels = result
279 .result_data
280 .get("reels")
281 .cloned()
282 .unwrap_or_default();
283 let win_type = result
284 .result_data
285 .get("win_type")
286 .cloned()
287 .unwrap_or_default();
288 let win_lines = result
289 .result_data
290 .get("win_lines")
291 .cloned()
292 .unwrap_or_else(|| "-".to_string());
293 let win_cells = result
294 .result_data
295 .get("win_cells")
296 .cloned()
297 .unwrap_or_else(|| "-".to_string());
298 format!(
299 "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type} {win_lines} {win_cells}"
300 )
301 }
279 } 302 }
280 Err(error) => { 303 Err(error) => {
281 let _ = self.sessions.remove_session(&session_id); 304 let _ = self.sessions.remove_session(&session_id);
diff --git a/src/server/games/mod.rs b/src/server/games/mod.rs
index 4c62760..35728a1 100644
--- a/src/server/games/mod.rs
+++ b/src/server/games/mod.rs
@@ -1,4 +1,5 @@
1pub mod registry; 1pub mod registry;
2pub mod roulette;
2pub mod session; 3pub mod session;
3pub mod slots; 4pub mod slots;
4pub mod traits; 5pub mod traits;
diff --git a/src/server/games/registry.rs b/src/server/games/registry.rs
index b7ed9d3..51d446b 100644
--- a/src/server/games/registry.rs
+++ b/src/server/games/registry.rs
@@ -1,5 +1,6 @@
1#![allow(dead_code)] 1#![allow(dead_code)]
2 2
3use super::roulette::RouletteGame;
3use super::slots::SlotsGame; 4use super::slots::SlotsGame;
4use super::traits::Game; 5use super::traits::Game;
5use std::collections::HashMap; 6use std::collections::HashMap;
@@ -21,6 +22,7 @@ impl GameRegistry {
21 pub fn with_defaults() -> Self { 22 pub fn with_defaults() -> Self {
22 let registry = Self::new(); 23 let registry = Self::new();
23 registry.register_game(SlotsGame::new()); 24 registry.register_game(SlotsGame::new());
25 registry.register_game(RouletteGame::new());
24 registry 26 registry
25 } 27 }
26 28
diff --git a/src/server/games/roulette.rs b/src/server/games/roulette.rs
new file mode 100644
index 0000000..7beb8c9
--- /dev/null
+++ b/src/server/games/roulette.rs
@@ -0,0 +1,151 @@
1use super::traits::{Game, GameError, GamePhase, GameResult, GameState};
2use crate::roulette::{color, Bets};
3use rand::Rng;
4use std::collections::HashMap;
5
6pub struct RouletteGame {
7 fixed_number: Option<u8>,
8}
9
10impl RouletteGame {
11 pub fn new() -> Self {
12 Self { fixed_number: None }
13 }
14
15 pub fn with_result(number: u8) -> Self {
16 Self {
17 fixed_number: Some(number),
18 }
19 }
20
21 fn spin(&self) -> u8 {
22 match self.fixed_number {
23 Some(number) => number,
24 None => rand::thread_rng().gen_range(0..37),
25 }
26 }
27}
28
29impl Game for RouletteGame {
30 fn game_type(&self) -> &'static str {
31 "roulette"
32 }
33
34 fn get_name(&self) -> &'static str {
35 "Roulette"
36 }
37
38 fn get_description(&self) -> &'static str {
39 "European roulette. Bet on red/black, even/odd, or a straight number."
40 }
41
42 fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState {
43 let mut data = HashMap::new();
44 data.insert("username".to_string(), username.to_string());
45 data.insert("status".to_string(), "ready".to_string());
46
47 GameState {
48 session_id: String::new(),
49 game_type: "roulette".to_string(),
50 bet_amount,
51 phase: GamePhase::WaitingToStart,
52 data,
53 }
54 }
55
56 fn process_action(
57 &self,
58 state: &GameState,
59 action: &str,
60 params: &[&str],
61 ) -> Result<GameState, GameError> {
62 if state.phase == GamePhase::Finished {
63 return Err(GameError::GameOver);
64 }
65
66 match action {
67 "spin" => {
68 if state.phase != GamePhase::WaitingToStart
69 && state.phase != GamePhase::Playing
70 {
71 return Err(GameError::InvalidAction(
72 "Cannot spin in current phase".to_string(),
73 ));
74 }
75
76 let payload = params
77 .first()
78 .ok_or(GameError::InvalidAction("Missing roulette bets".to_string()))?;
79 let bets: Bets = serde_json::from_str(payload)
80 .map_err(|error| GameError::InvalidAction(format!("Invalid bets: {error}")))?;
81
82 if bets.is_empty() {
83 return Err(GameError::InvalidBet);
84 }
85 if let Err(message) = bets.validate(state.bet_amount) {
86 return Err(GameError::InvalidAction(message));
87 }
88 if bets.total() != state.bet_amount {
89 return Err(GameError::InvalidBet);
90 }
91
92 let number = self.spin();
93 let bets_json = serde_json::to_string(&bets)
94 .map_err(|error| GameError::InternalError(error.to_string()))?;
95
96 let mut new_data = state.data.clone();
97 new_data.insert("status".to_string(), "spun".to_string());
98 new_data.insert("bets".to_string(), bets_json);
99 new_data.insert("number".to_string(), number.to_string());
100 new_data.insert("color".to_string(), color(number).to_string());
101
102 Ok(GameState {
103 session_id: state.session_id.clone(),
104 game_type: state.game_type.clone(),
105 bet_amount: state.bet_amount,
106 phase: GamePhase::Finished,
107 data: new_data,
108 })
109 }
110 _ => Err(GameError::InvalidAction(format!(
111 "Unknown action: {action}"
112 ))),
113 }
114 }
115
116 fn calculate_result(&self, state: &GameState) -> Result<GameResult, GameError> {
117 if state.phase != GamePhase::Finished {
118 return Err(GameError::InternalError("Game not finished".to_string()));
119 }
120
121 let number: u8 = state
122 .data
123 .get("number")
124 .ok_or(GameError::InternalError("No winning number".to_string()))?
125 .parse()
126 .map_err(|_| GameError::InternalError("Invalid winning number".to_string()))?;
127 let bets_json = state
128 .data
129 .get("bets")
130 .ok_or(GameError::InternalError("No bets".to_string()))?
131 .clone();
132 let bets: Bets = serde_json::from_str(&bets_json)
133 .map_err(|error| GameError::InternalError(format!("Invalid bets: {error}")))?;
134
135 let winnings = bets.winnings(number);
136
137 let mut result_data = HashMap::new();
138 result_data.insert("number".to_string(), number.to_string());
139 result_data.insert("color".to_string(), color(number).to_string());
140 result_data.insert("bets".to_string(), bets_json);
141
142 Ok(GameResult {
143 session_id: state.session_id.clone(),
144 game_type: state.game_type.clone(),
145 bet_amount: state.bet_amount,
146 winnings,
147 final_balance: 0,
148 result_data,
149 })
150 }
151}
diff --git a/tests/client_core.rs b/tests/client_core.rs
index 4749d99..7fb7e29 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -47,6 +47,15 @@ impl Screen {
47 self.region_start = None; 47 self.region_start = None;
48 self.region_len = 0; 48 self.region_len = 0;
49 } 49 }
50 Effect::AnimateRoulette { final_lines, .. } => {
51 if let Some(start) = self.region_start {
52 self.lines.truncate(start);
53 } else {
54 self.region_start = Some(self.lines.len());
55 }
56 self.lines.extend(final_lines.iter().cloned());
57 self.region_len = final_lines.len();
58 }
50 Effect::SetPrompt { text, cursor_col } => { 59 Effect::SetPrompt { text, cursor_col } => {
51 self.prompt = text.clone(); 60 self.prompt = text.clone();
52 self.prompt_cursor = *cursor_col; 61 self.prompt_cursor = *cursor_col;
@@ -125,6 +134,11 @@ impl Harness {
125 self.line(&format!("play slots {bet}")); 134 self.line(&format!("play slots {bet}"));
126 self.reply("INFO alice 1 1000"); 135 self.reply("INFO alice 1 1000");
127 } 136 }
137
138 fn enter_roulette(&mut self) {
139 self.line("play roulette");
140 self.reply("INFO alice 1 1000");
141 }
128} 142}
129 143
130#[test] 144#[test]
@@ -559,3 +573,77 @@ fn rejects_a_grid_with_the_wrong_size() {
559 573
560 assert!(parse_spin_response(response).is_err()); 574 assert!(parse_spin_response(response).is_err());
561} 575}
576
577#[test]
578fn roulette_split_bets_spin_and_show_the_result() {
579 let mut h = Harness::new();
580 h.enter_roulette();
581
582 assert_eq!(h.board().len(), 8);
583 assert_eq!(h.board()[0], "=== ROULETTE ===");
584 assert!(h.board()[1].contains("Balance: 1000 PLN"));
585 assert!(h.board()[1].contains("Chip: 10 PLN"));
586 assert!(h.board()[2].contains("Bets: (none)"));
587
588 h.key(Key::Char('r'));
589 h.key(Key::Char('e'));
590 h.key(Key::Char('1'));
591 h.key(Key::Char('7'));
592 h.key(Key::Enter);
593
594 assert!(h.board()[2].contains("RED 10"));
595 assert!(h.board()[2].contains("EVEN 10"));
596 assert!(h.board()[2].contains("#17 10"));
597 assert!(h.board()[2].contains("Total: 30 PLN"));
598
599 h.key(Key::Char(' '));
600 let request = h
601 .sent
602 .iter()
603 .find(|line| line.starts_with("PLAY_START roulette"))
604 .expect("roulette request");
605 assert_eq!(
606 request,
607 "PLAY_START roulette 30 {\"red\":10,\"black\":0,\"even\":10,\"odd\":0,\"numbers\":[{\"number\":17,\"amount\":10}]}"
608 );
609
610 let bets =
611 r#"{"red":10,"black":0,"even":10,"odd":0,"numbers":[{"number":17,"amount":10}]}"#;
612 h.reply(&format!(
613 "ROULETTE_RESULT session-1 360 30 1330 17 black {bets}"
614 ));
615
616 assert_eq!(h.board().len(), 8);
617 assert!(h.board()[1].contains("Balance: 1330 PLN"));
618 assert!(h.board()[4].contains("Winning: 17 BLACK ODD"));
619 assert!(h.board()[5].contains("Won 360 PLN"));
620}
621
622#[test]
623fn roulette_rejects_an_empty_spin_and_accepts_the_equals_chip_key() {
624 let mut h = Harness::new();
625 h.enter_roulette();
626
627 h.key(Key::Char(' '));
628 assert!(h.board()[4].contains("ERROR: Place at least one bet"));
629 assert!(!h.sent.iter().any(|line| line.starts_with("PLAY_START roulette")));
630
631 h.key(Key::Char('='));
632 assert!(h.board()[1].contains("Chip: 20 PLN"));
633 h.key(Key::Char('r'));
634 assert!(h.board()[2].contains("RED 20"));
635 assert!(h.board()[2].contains("Total: 20 PLN"));
636}
637
638#[test]
639fn roulette_rejects_an_out_of_range_number() {
640 let mut h = Harness::new();
641 h.enter_roulette();
642
643 h.key(Key::Char('9'));
644 h.key(Key::Char('9'));
645 h.key(Key::Enter);
646
647 assert!(h.board()[4].contains("ERROR: Number must be 0-36"));
648 assert!(h.board()[2].contains("Bets: (none)"));
649}
diff --git a/tests/roulette.rs b/tests/roulette.rs
new file mode 100644
index 0000000..6b99d17
--- /dev/null
+++ b/tests/roulette.rs
@@ -0,0 +1,184 @@
1use zagan_bet::roulette::{
2 color, is_black, is_even, is_odd, is_red, rtp, Bets, NumberBet, WHEEL,
3};
4use zagan_bet::server::games::roulette::RouletteGame;
5use zagan_bet::server::games::traits::Game;
6
7#[test]
8fn the_european_wheel_has_all_37_unique_pockets() {
9 let mut numbers = WHEEL.to_vec();
10 numbers.sort_unstable();
11 assert_eq!(numbers, (0..=36).collect::<Vec<_>>());
12 assert_eq!(color(0), "green");
13 assert_eq!(WHEEL.len(), 37);
14}
15
16#[test]
17fn colors_and_even_odd_are_correct() {
18 assert!(is_red(1));
19 assert!(is_black(2));
20 assert!(!is_red(0));
21 assert!(!is_black(0));
22 assert!(is_even(2));
23 assert!(is_odd(3));
24 assert!(!is_even(0));
25 assert!(!is_odd(0));
26 assert_eq!(color(17), "black");
27 assert_eq!(color(18), "red");
28}
29
30#[test]
31fn european_rtp_is_36_over_37() {
32 assert!((rtp() - 36.0 / 37.0).abs() < 1e-12);
33}
34
35#[test]
36fn bets_total_and_winning_number_payouts() {
37 let bets = Bets {
38 red: 10,
39 black: 10,
40 even: 10,
41 odd: 10,
42 numbers: vec![NumberBet {
43 number: 17,
44 amount: 5,
45 }],
46 };
47
48 assert_eq!(bets.total(), 45);
49 // 17 is black and odd: 10*2 + 10*2 + 5*36.
50 assert_eq!(bets.winnings(17), 220);
51 // 18 is red and even.
52 assert_eq!(bets.winnings(18), 40);
53}
54
55#[test]
56fn zero_loses_all_outside_bets_but_pays_a_straight_zero() {
57 let outside = Bets {
58 red: 10,
59 black: 10,
60 even: 10,
61 odd: 10,
62 numbers: vec![],
63 };
64 assert_eq!(outside.winnings(0), 0);
65
66 let straight = Bets {
67 red: 10,
68 black: 10,
69 even: 10,
70 odd: 10,
71 numbers: vec![NumberBet {
72 number: 0,
73 amount: 5,
74 }],
75 };
76 assert_eq!(straight.total(), 45);
77 assert_eq!(straight.winnings(0), 180);
78}
79
80#[test]
81fn bets_validate_empty_balance_and_invalid_numbers() {
82 assert!(Bets::default().validate(1000).is_err());
83
84 let too_big = Bets {
85 red: 2000,
86 ..Bets::default()
87 };
88 assert!(too_big.validate(1000).is_err());
89
90 let bad_number = Bets {
91 numbers: vec![NumberBet {
92 number: 37,
93 amount: 10,
94 }],
95 ..Bets::default()
96 };
97 assert!(bad_number.validate(1000).is_err());
98}
99
100#[test]
101fn the_server_settles_a_split_bet() {
102 let bets = Bets {
103 red: 10,
104 black: 10,
105 even: 10,
106 odd: 10,
107 numbers: vec![NumberBet {
108 number: 17,
109 amount: 5,
110 }],
111 };
112 let payload = serde_json::to_string(&bets).unwrap();
113 let game = RouletteGame::with_result(17);
114 let state = game.prepare_state("alice", bets.total());
115
116 let state = game.process_action(&state, "spin", &[&payload]).unwrap();
117 let result = game.calculate_result(&state).unwrap();
118
119 assert_eq!(result.winnings, 220);
120 assert_eq!(result.result_data.get("number").unwrap(), "17");
121 assert_eq!(result.result_data.get("color").unwrap(), "black");
122 assert_eq!(result.result_data.get("bets").unwrap(), &payload);
123}
124
125#[test]
126fn the_server_rejects_a_total_that_does_not_match_the_bets() {
127 let payload = serde_json::to_string(&Bets {
128 red: 10,
129 ..Bets::default()
130 })
131 .unwrap();
132 let game = RouletteGame::with_result(17);
133 let state = game.prepare_state("alice", 99);
134
135 assert!(game.process_action(&state, "spin", &[&payload]).is_err());
136}
137
138#[test]
139fn the_server_rejects_an_invalid_number() {
140 let payload = serde_json::to_string(&Bets {
141 numbers: vec![NumberBet {
142 number: 37,
143 amount: 10,
144 }],
145 ..Bets::default()
146 })
147 .unwrap();
148 let game = RouletteGame::with_result(17);
149 let state = game.prepare_state("alice", 10);
150
151 assert!(game.process_action(&state, "spin", &[&payload]).is_err());
152}
153
154#[test]
155fn parses_a_roulette_result() {
156 let bets = r#"{"red":10,"black":0,"even":10,"odd":0,"numbers":[{"number":17,"amount":10}]}"#;
157 let response = format!("ROULETTE_RESULT session-1 360 30 1330 17 black {bets}");
158 let result = zagan_bet::client::games::response::parse_roulette_response(&response).unwrap();
159
160 assert_eq!(result.session_id, "session-1");
161 assert_eq!(result.winnings, 360);
162 assert_eq!(result.bet_amount, 30);
163 assert_eq!(result.balance, 1330);
164 assert_eq!(result.number, 17);
165 assert_eq!(result.color, "black");
166 assert_eq!(result.bets, bets);
167}
168
169#[test]
170fn rejects_bad_roulette_results() {
171 use zagan_bet::client::games::response::parse_roulette_response;
172
173 let valid_bets = r#"{"red":10}"#;
174 assert!(parse_roulette_response(&format!(
175 "ROULETTE_RESULT s 1 1 1 37 green {valid_bets}"
176 ))
177 .is_err());
178 assert!(parse_roulette_response(&format!(
179 "ROULETTE_RESULT s 1 1 1 17 purple {valid_bets}"
180 ))
181 .is_err());
182 assert!(parse_roulette_response("ROULETTE_RESULT s 1 1 1 17 black nope").is_err());
183 assert!(parse_roulette_response("").is_err());
184}
diff --git a/tests/server_core.rs b/tests/server_core.rs
index cab74a7..cf05102 100644
--- a/tests/server_core.rs
+++ b/tests/server_core.rs
@@ -4,7 +4,9 @@ use rusqlite::Connection;
4use zagan_bet::server::core::{ConnectionState, ServerCore}; 4use zagan_bet::server::core::{ConnectionState, ServerCore};
5use zagan_bet::server::database; 5use zagan_bet::server::database;
6use zagan_bet::server::games::registry::GameRegistry; 6use zagan_bet::server::games::registry::GameRegistry;
7use zagan_bet::server::games::roulette::RouletteGame;
7use zagan_bet::server::games::slots::SlotsGame; 8use zagan_bet::server::games::slots::SlotsGame;
9use zagan_bet::roulette::Bets;
8 10
9fn setup(name: &str, grid: &str) -> (ServerCore, String) { 11fn setup(name: &str, grid: &str) -> (ServerCore, String) {
10 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) 12 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
@@ -29,6 +31,22 @@ const LOSS: &str = "CLOOGDSLCDCOGLS";
29/// Top line is C C C D D; it pays 2x total bet. 31/// Top line is C C C D D; it pays 2x total bet.
30const WIN3: &str = "CCCCLLCLODGSDGS"; 32const WIN3: &str = "CCCCLLCLODGSDGS";
31 33
34fn setup_roulette(name: &str, number: u8) -> (ServerCore, String) {
35 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
36 .join(format!("server-core-{name}.db"))
37 .to_string_lossy()
38 .into_owned();
39 let _ = std::fs::remove_file(&path);
40
41 let conn = Connection::open(&path).unwrap();
42 database::init_db(&conn);
43
44 let registry = GameRegistry::new();
45 registry.register_game(RouletteGame::with_result(number));
46
47 (ServerCore::with_registry(conn, registry, 4), path)
48}
49
32fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { 50fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
33 assert_eq!( 51 assert_eq!(
34 core.handle_message(state, &format!("REGISTER {user} credential")), 52 core.handle_message(state, &format!("REGISTER {user} credential")),
@@ -164,6 +182,47 @@ fn plays_record_the_game_type_and_game_specific_details() {
164} 182}
165 183
166#[test] 184#[test]
185fn roulette_settles_split_bets_and_records_details() {
186 let (mut core, path) = setup_roulette("roulette", 17);
187 let mut state = ConnectionState::default();
188 register(&mut core, &mut state, "alice");
189 login(&mut core, &mut state, "alice");
190
191 let bets =
192 r#"{"red":10,"black":10,"even":10,"odd":10,"numbers":[{"number":17,"amount":5}]}"#;
193 let response = core.handle_message(&mut state, &format!("PLAY_START roulette 45 {bets}"));
194
195 assert!(
196 response.starts_with("ROULETTE_RESULT"),
197 "response: {response}"
198 );
199 assert!(
200 response.contains(" 220 45 1175 17 black "),
201 "response: {response}"
202 );
203 assert!(response.ends_with(&format!(" {bets}")), "response: {response}");
204 assert_eq!(core.balance("alice"), Some(1175));
205 assert_eq!(core.play_count("alice"), 1);
206 assert_eq!(core.session_count(), 0);
207
208 let conn = Connection::open(&path).unwrap();
209 let (game_type, details): (String, String) = conn
210 .query_row(
211 "SELECT game_type, details FROM plays WHERE username = 'alice'",
212 [],
213 |row| Ok((row.get(0)?, row.get(1)?)),
214 )
215 .unwrap();
216
217 assert_eq!(game_type, "roulette");
218 let details: serde_json::Value = serde_json::from_str(&details).unwrap();
219 assert_eq!(details["number"], "17");
220 assert_eq!(details["color"], "black");
221 let stored: Bets = serde_json::from_str(details["bets"].as_str().unwrap()).unwrap();
222 assert_eq!(stored.total(), 45);
223}
224
225#[test]
167fn a_failed_audit_insert_rolls_back_the_balance_change() { 226fn a_failed_audit_insert_rolls_back_the_balance_change() {
168 let (mut core, path) = setup("rollback", LOSS); 227 let (mut core, path) = setup("rollback", LOSS);
169 let mut state = ConnectionState::default(); 228 let mut state = ConnectionState::default();