From d9f1fe513e6f7696809d3a9b4d44ca485fe99c97 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 29 Oct 2026 17:18:34 +0100 Subject: v2.1.0: add European roulette with split bets and wheel animation --- Cargo.toml | 2 +- docs/GAMES.md | 63 ++++++++++----- docs/TESTING.md | 14 ++++ src/bin/client/repl.rs | 54 +++++++++++++ src/client/core.rs | 183 +++++++++++++++++++++++++++++++++++++++++- src/client/games/manager.rs | 131 +++++++++++++++++++++++++++++- src/client/games/mod.rs | 1 + src/client/games/response.rs | 64 +++++++++++++++ src/client/games/roulette.rs | 157 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/roulette.rs | 132 +++++++++++++++++++++++++++++++ src/server/core.rs | 77 +++++++++++------- src/server/games/mod.rs | 1 + src/server/games/registry.rs | 2 + src/server/games/roulette.rs | 151 +++++++++++++++++++++++++++++++++++ tests/client_core.rs | 88 +++++++++++++++++++++ tests/roulette.rs | 184 +++++++++++++++++++++++++++++++++++++++++++ tests/server_core.rs | 59 ++++++++++++++ 18 files changed, 1312 insertions(+), 52 deletions(-) create mode 100644 src/client/games/roulette.rs create mode 100644 src/roulette.rs create mode 100644 src/server/games/roulette.rs create mode 100644 tests/roulette.rs diff --git a/Cargo.toml b/Cargo.toml index 6b32e67..9432fcd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "2.0.0" +version = "2.1.0" edition = "2024" [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 @@ | Game | Status | Notes | | --- | --- | --- | | Slots | Done | 5 reels x 3 rows, 5 paylines, 98.6% RTP | +| Roulette | Done | European wheel, split bets, 97.3% RTP | ## Planned | Game | Shape | Main open decisions | | --- | --- | --- | -| Roulette | One spin, many bets | European/French/American wheel; which bet types; 35:1 straight and other payouts | | Blackjack | Multi-step hand | Dealer hits/stands soft 17; double/split/insurance; blackjack payout; shoe persistence | | Crash | Real-time round | Crash-point distribution; cashout latency; provable fairness | -Roulette fits the current one-request-settle model most easily. Blackjack needs -server-side hand state and player actions. Crash needs a timed round loop and a -cashout action, so it should come last unless we redesign the loop first. +Blackjack needs server-side hand state and player actions. Crash needs a timed +round loop and a cashout action, so it should come after blackjack unless we +redesign the loop first. + +## Roulette + +- European wheel: 0-36, one green zero. +- Bets: red, black, even, odd, and straight numbers. +- Bets can be split across all of those; every winning bet is added together. +- Straight pays 35:1 (36x returned), outside bets pay 1:1 (2x returned). +- 0 loses all outside bets. RTP is 36/37 = 97.30%. + +Client controls inside roulette: + +``` +R / B / E / O add the current chip to red/black/even/odd +digits + Enter add the current chip to that number +C clear all bets ++ or = / - change the chip size +SPACE spin +Q leave the table +``` + +The result is shown as a CS:GO-style horizontal strip: the ball marker stays +fixed and the wheel numbers scroll beneath it, ending on the winning number. ## Audit schema @@ -35,35 +57,36 @@ plays ( ) ``` -`game_type` is the game that was played. `details` is a JSON object whose shape -depends on the game: +`details` is game-specific JSON: ```json // slots {"reels":"CCCCLLCLODGSDGS","win_type":"win","win_lines":"1","win_cells":"0,3,6"} -// rumoured future examples -{"player":["AS","10H"],"dealer":["9D","7C"],"outcome":"blackjack"} -{"bets":[{"kind":"straight","number":17,"amount":10}],"winning":17} -{"crash_point":2.41,"cashout":1.80} +// roulette +{"number":"17","color":"black","bets":"{\"red\":10,\"black\":10,\"even\":10,\"odd\":10,\"numbers\":[{\"number\":17,\"amount\":5}]}"} ``` -The balance updates and the `plays` insert happen in one transaction -(`database::apply_play`). The slots-specific wire fields (`reels`, `win_lines`, -`win_cells`) are still in `GAME_RESULT`, but the database no longer assumes -them. +The balance update and the `plays` insert happen in one transaction +(`database::apply_play`). -## Protocol gap +## Wire protocol -The current exchange is slots-shaped: +Slots keeps its original exchange: ``` -PLAY_START +PLAY_START slots GAME_RESULT ``` -Before blackjack, roulette, or crash can be added, the game session needs a -generic state/action protocol. A possible shape: +Roulette adds a compact bets payload and a roulette-specific result: + +``` +PLAY_START roulette +ROULETTE_RESULT +``` + +Blackjack and crash still need a generic state/action exchange, for example: ``` GAME_START @@ -71,4 +94,4 @@ GAME_ACTION [json] GAME_STATE # cards, bets, multiplier, legal actions, result ``` -That is a wire-protocol change and will be its own major version. +That 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. | `tests/client_core.rs` | Client command/game flows via a headless screen applying logical effects. | | `tests/client_ui.rs` | Help, history, completion, bet parsing, display width, cursor math. | | `tests/protocol.rs` | Newline buffering and timeout/error classification. | +| `tests/roulette.rs` | European wheel, split-bet payouts, zero handling, result parser. | | `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | | `tests/server_setup.rs` | `--init` identity generation, PKCS#12 loadability, overwrite refusal. | | `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. fingerprint fits 80 columns. 10. Windows: repeat 2-9 and confirm keys are not duplicated or printed twice. +## Manual roulette checklist + +1. `play roulette` shows the balance, chip size, empty bets, and controls. +2. `R`/`B`/`E`/`O` add one chip; digits + Enter add a number; `C` clears; + `+`/`=`/`-` change the chip size. +3. Space scrolls the wheel under a fixed ball marker and ends on the winning + number. +4. Check a split result: + - `17 black odd`: black+odd pay, red+even lose, straight 17 pays 36x. + - `0`: all outside bets lose; a straight 0 pays 36x. +5. The result board shows the winning number/color/parity, the payout, and the + new balance. The next spin reuses the placed bets. + ## Manual server checks - 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::{ visual_line_count, }; use zagan_bet::protocol::io_failure_message; +use zagan_bet::roulette::WHEEL; use crate::connection::TlsStream; @@ -150,6 +151,16 @@ impl ClientState { } } Effect::ReplaceRegion(lines) => self.draw_region(&lines), + Effect::AnimateRoulette { + winning, + final_lines, + } => { + self.animate_wheel(winning); + self.draw_region(&final_lines); + while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { + let _ = crossterm::event::read(); + } + } Effect::ClearRegion => self.clear_region(), Effect::SetPrompt { text, cursor_col } => self.draw_prompt(&text, cursor_col), Effect::SendLine(line) => { @@ -276,6 +287,49 @@ impl ClientState { self.game_lines = 0; } + /// CS:GO-style wheel strip: the ball marker is fixed and the numbers move + /// left-to-right under it, ending with the winning number in the center. + fn animate_wheel(&mut self, winning: u8) { + use std::thread; + use std::time::Duration; + + let target = WHEEL + .iter() + .position(|number| *number == winning) + .unwrap_or(0) as f64; + let wheel_len = WHEEL.len() as f64; + let start = target - 4.0 * wheel_len - 7.0; + let frames = 32; + + for frame in 0..frames { + let t = frame as f64 / (frames - 1) as f64; + let ease = 1.0 - (1.0 - t).powi(3); + let center = (start + (target - start) * ease).round() as isize; + + let strip = (0..9) + .map(|offset| { + let index = (center - 4 + offset).rem_euclid(WHEEL.len() as isize) as usize; + format!("[ {:>2} ]", WHEEL[index]) + }) + .collect::>() + .join(" "); + let marker = format!("{}▼", " ".repeat(4 * 7 + 3)); + let lines = vec![ + "=== ROULETTE ===".to_string(), + strip, + marker, + "Spinning...".to_string(), + ]; + + self.draw_region(&lines); + thread::sleep(Duration::from_millis(35)); + } + + // Let the winning number sit under the static ball before the result + // board replaces the wheel. + thread::sleep(Duration::from_millis(450)); + } + fn draw_prompt(&mut self, text: &str, cursor_col: u16) { execute!(stdout(), MoveToColumn(0), Clear(ClearType::CurrentLine)).unwrap(); 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 @@ use crate::client::games::manager::GameManager; -use crate::client::games::response::parse_spin_response; +use crate::client::games::response::{parse_roulette_response, parse_spin_response}; use crate::client::ui::{ command_names, completion_lines, display_width, help_lines, input_cursor_column, parse_bet_arg, record_history, }; +use crate::roulette::{Bets, NumberBet}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Key { @@ -33,6 +34,11 @@ pub enum Effect { Print(Vec), /// Replace the game board region in place. ReplaceRegion(Vec), + /// Run the roulette wheel animation, then draw the final board lines. + AnimateRoulette { + winning: u8, + final_lines: Vec, + }, /// Remove the game board region. ClearRegion, /// Redraw the command prompt. `text` is the input without the `> ` prefix. @@ -54,11 +60,20 @@ pub enum Effect { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Pending { Spin, + RouletteSpin, Ping, Echo, Info, } +#[derive(Debug, Clone, Copy)] +enum RouletteSide { + Red, + Black, + Even, + Odd, +} + pub struct ClientCore { input: String, history: Vec, @@ -160,6 +175,22 @@ impl ClientCore { self.board_effects() } }, + Some(Pending::RouletteSpin) => match parse_roulette_response(reply) { + Ok(result) => { + self.balance = Some(result.balance); + let winning = result.number; + self.game.apply_roulette_result(result); + let final_lines = self.board_lines().unwrap_or_default(); + vec![Effect::AnimateRoulette { + winning, + final_lines, + }] + } + Err(message) => { + self.game.set_error(message); + self.board_effects() + } + }, Some(Pending::Ping) => { if reply.trim() == "PONG" { print_line("Pong") @@ -194,6 +225,15 @@ impl ClientCore { } fn handle_game_key(&mut self, key: Key) -> Vec { + if self + .game + .get_current_state() + .map(|state| state.game_type.as_str()) + == Some("roulette") + { + return self.handle_roulette_key(key); + } + match key { Key::CtrlC => vec![Effect::Quit], Key::Esc | Key::Char('q') => { @@ -230,6 +270,147 @@ impl ClientCore { effects } + fn handle_roulette_key(&mut self, key: Key) -> Vec { + match key { + Key::CtrlC => vec![Effect::Quit], + Key::Char('q') => { + self.game.end_game(); + vec![ + Effect::ClearRegion, + Effect::Print(vec!["Game exited.".to_string()]), + ] + } + Key::Esc => { + self.game.set_roulette_number_input(""); + self.board_effects() + } + Key::Char(' ') => self.spin_roulette(), + Key::Char('r') => self.add_roulette_side(RouletteSide::Red), + Key::Char('b') => self.add_roulette_side(RouletteSide::Black), + Key::Char('e') => self.add_roulette_side(RouletteSide::Even), + Key::Char('o') => self.add_roulette_side(RouletteSide::Odd), + Key::Char('c') => { + self.game.set_roulette_bets(&Bets::default()); + self.game.set_roulette_number_input(""); + self.game.set_roulette_status("place"); + self.board_effects() + } + Key::Char('+') | Key::Char('=') => { + let chip = self.game.roulette_chip().saturating_add(10); + self.game.set_roulette_chip(chip); + self.board_effects() + } + Key::Char('-') => { + let chip = self.game.roulette_chip().saturating_sub(10).max(10); + self.game.set_roulette_chip(chip); + self.board_effects() + } + Key::Char(c) if c.is_ascii_digit() => { + let mut input = self.roulette_input(); + if input.len() < 2 { + input.push(c); + self.game.set_roulette_number_input(&input); + } + self.board_effects() + } + Key::Backspace => { + let mut input = self.roulette_input(); + input.pop(); + self.game.set_roulette_number_input(&input); + self.board_effects() + } + Key::Enter => { + let input = self.roulette_input(); + if !input.is_empty() { + match input.parse::() { + Ok(number) if number <= 36 => { + self.add_roulette_number(number); + self.game.set_roulette_number_input(""); + } + _ => { + self.game.set_roulette_number_input(""); + self.game.set_error("Number must be 0-36"); + } + } + } + self.board_effects() + } + _ => Vec::new(), + } + } + + fn add_roulette_side(&mut self, side: RouletteSide) -> Vec { + let chip = self.game.roulette_chip(); + let mut bets = self.game.roulette_bets(); + match side { + RouletteSide::Red => bets.red = bets.red.saturating_add(chip), + RouletteSide::Black => bets.black = bets.black.saturating_add(chip), + RouletteSide::Even => bets.even = bets.even.saturating_add(chip), + RouletteSide::Odd => bets.odd = bets.odd.saturating_add(chip), + } + self.place_roulette_bets(bets) + } + + fn add_roulette_number(&mut self, number: u8) -> Vec { + let chip = self.game.roulette_chip(); + let mut bets = self.game.roulette_bets(); + if let Some(existing) = bets.numbers.iter_mut().find(|bet| bet.number == number) { + existing.amount = existing.amount.saturating_add(chip); + } else { + bets.numbers.push(NumberBet { + number, + amount: chip, + }); + } + self.place_roulette_bets(bets) + } + + fn place_roulette_bets(&mut self, bets: Bets) -> Vec { + if let Some(balance) = self.balance { + if bets.total() > balance.max(0) as u64 { + self.game.set_error("Insufficient balance"); + return self.board_effects(); + } + } + + self.game.set_roulette_bets(&bets); + self.game.set_roulette_status("place"); + self.board_effects() + } + + fn spin_roulette(&mut self) -> Vec { + let bets = self.game.roulette_bets(); + if bets.is_empty() { + self.game.set_error("Place at least one bet"); + return self.board_effects(); + } + if let Some(balance) = self.balance { + if bets.total() > balance.max(0) as u64 { + self.game.set_error("Insufficient balance"); + return self.board_effects(); + } + } + let Some(request) = self.game.spin_request() else { + return Vec::new(); + }; + if self.game.begin_spin().is_err() { + return Vec::new(); + } + self.pending = Some(Pending::RouletteSpin); + + let mut effects = self.board_effects(); + effects.push(Effect::SendLine(request)); + effects + } + + fn roulette_input(&self) -> String { + self.game + .get_current_state() + .and_then(|state| state.data.get("number_input")) + .cloned() + .unwrap_or_default() + } + fn submit(&mut self) -> Vec { let line = self.input.trim().to_string(); 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 @@ -use super::response::SpinResult; +use super::response::{RouletteResult, SpinResult}; +use super::roulette::RouletteGame; use super::slots::SlotsGame; use super::traits::{Game, GameError, GamePhase, GameState}; +use crate::roulette::Bets; use std::collections::HashMap; pub struct GameManager { @@ -12,6 +14,7 @@ impl GameManager { pub fn new() -> Self { let mut games: HashMap> = HashMap::new(); games.insert("slots".to_string(), Box::new(SlotsGame::new())); + games.insert("roulette".to_string(), Box::new(RouletteGame::new())); Self { current_session: None, @@ -38,11 +41,25 @@ impl GameManager { return Err(GameError::InvalidAction); } + let mut data = HashMap::new(); + let bet_amount = if game_type == "roulette" { + data.insert( + "bets".to_string(), + serde_json::to_string(&Bets::default()).unwrap_or_else(|_| "{}".to_string()), + ); + data.insert("chip".to_string(), "10".to_string()); + data.insert("status".to_string(), "place".to_string()); + data.insert("number_input".to_string(), String::new()); + 0 + } else { + 10 + }; + self.current_session = Some(GameState { game_type: game_type.to_string(), - bet_amount: 10, + bet_amount, phase: GamePhase::WaitingToStart, - data: HashMap::new(), + data, }); Ok(()) @@ -54,6 +71,17 @@ impl GameManager { if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { return None; } + if session.game_type == "roulette" { + let bets = session + .data + .get("bets") + .cloned() + .unwrap_or_else(|| "{}".to_string()); + return Some(format!( + "PLAY_START roulette {} {}", + session.bet_amount, bets + )); + } Some(format!( "PLAY_START {} {}", session.game_type, session.bet_amount @@ -78,6 +106,13 @@ impl GameManager { session.data.remove("win_lines"); session.data.remove("win_cells"); session.data.remove("winnings"); + session.data.remove("number"); + session.data.remove("color"); + if session.game_type == "roulette" { + session + .data + .insert("status".to_string(), "spinning".to_string()); + } Ok(()) } @@ -130,6 +165,13 @@ impl GameManager { session.data.remove("win_lines"); session.data.remove("win_cells"); session.data.remove("winnings"); + session.data.remove("number"); + session.data.remove("color"); + if session.game_type == "roulette" { + session + .data + .insert("status".to_string(), "place".to_string()); + } session.data.insert("error".to_string(), message.into()); } @@ -141,6 +183,83 @@ impl GameManager { } } + pub fn roulette_bets(&self) -> Bets { + self.current_session + .as_ref() + .and_then(|session| session.data.get("bets")) + .and_then(|raw| serde_json::from_str(raw).ok()) + .unwrap_or_default() + } + + pub fn set_roulette_bets(&mut self, bets: &Bets) { + if let Some(session) = self.current_session.as_mut() { + session.data.insert( + "bets".to_string(), + serde_json::to_string(bets).unwrap_or_else(|_| "{}".to_string()), + ); + session.bet_amount = bets.total(); + } + } + + pub fn roulette_chip(&self) -> u64 { + self.current_session + .as_ref() + .and_then(|session| session.data.get("chip")) + .and_then(|value| value.parse().ok()) + .unwrap_or(10) + } + + pub fn set_roulette_chip(&mut self, chip: u64) { + if let Some(session) = self.current_session.as_mut() { + session.data.insert("chip".to_string(), chip.to_string()); + } + } + + pub fn set_roulette_number_input(&mut self, value: &str) { + if let Some(session) = self.current_session.as_mut() { + session + .data + .insert("number_input".to_string(), value.to_string()); + } + } + + pub fn set_roulette_status(&mut self, status: &str) { + if let Some(session) = self.current_session.as_mut() { + session + .data + .insert("status".to_string(), status.to_string()); + if status != "error" { + session.data.remove("error"); + } + } + } + + pub fn apply_roulette_result(&mut self, result: RouletteResult) { + let Some(session) = self.current_session.as_mut() else { + return; + }; + session.phase = GamePhase::Finished; + session.bet_amount = result.bet_amount; + session.data.remove("error"); + session.data.insert("bets".to_string(), result.bets); + session + .data + .insert("number".to_string(), result.number.to_string()); + session.data.insert("color".to_string(), result.color); + session + .data + .insert("winnings".to_string(), result.winnings.to_string()); + session + .data + .insert("balance".to_string(), result.balance.to_string()); + session + .data + .insert("status".to_string(), "result".to_string()); + session + .data + .insert("number_input".to_string(), String::new()); + } + pub fn get_current_game(&self) -> Option<&dyn Game> { self.current_session .as_ref() @@ -167,6 +286,12 @@ impl GameManager { if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { return Err(GameError::InvalidAction); } + if session.game_type == "roulette" { + session + .data + .insert("chip".to_string(), bet.max(10).to_string()); + return Ok(bet.max(10)); + } session.bet_amount = bet.max(10); Ok(session.bet_amount) } 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 @@ pub mod manager; pub mod response; +pub mod roulette; pub mod slots; pub 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 { .filter_map(|value| value.trim().parse::().ok()) .collect() } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RouletteResult { + pub session_id: String, + pub winnings: u64, + pub bet_amount: u64, + pub balance: i64, + pub number: u8, + pub color: String, + pub bets: String, +} + +pub fn parse_roulette_response(response: &str) -> Result { + let response = response.trim(); + if response.is_empty() { + return Err("No response from server".to_string()); + } + if let Some(message) = response.strip_prefix("ERROR") { + let message = message.trim(); + return Err(if message.is_empty() { + "Server error".to_string() + } else { + message.to_string() + }); + } + + let parts: Vec<&str> = response.split_whitespace().collect(); + if parts.first() != Some(&"ROULETTE_RESULT") || parts.len() < 8 { + return Err(format!("Unexpected server response: {response}")); + } + + let winnings = parts[2] + .parse::() + .map_err(|_| format!("Invalid winnings from server: {}", parts[2]))?; + let bet_amount = parts[3] + .parse::() + .map_err(|_| format!("Invalid bet from server: {}", parts[3]))?; + let balance = parts[4] + .parse::() + .map_err(|_| format!("Invalid balance from server: {}", parts[4]))?; + let number = parts[5] + .parse::() + .map_err(|_| format!("Invalid roulette number: {}", parts[5]))?; + if number > 36 { + return Err(format!("Invalid roulette number: {number}")); + } + let color = parts[6].to_string(); + if !matches!(color.as_str(), "green" | "red" | "black") { + return Err(format!("Invalid roulette color: {color}")); + } + let bets = parts[7].to_string(); + let _: crate::roulette::Bets = serde_json::from_str(&bets) + .map_err(|error| format!("Invalid roulette bets: {error}"))?; + + Ok(RouletteResult { + session_id: parts[1].to_string(), + winnings, + bet_amount, + balance, + number, + color, + bets, + }) +} 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 @@ +use super::traits::{Game, GameState}; +use crate::roulette::{is_even, is_odd, Bets}; + +pub const ROULETTE_BOARD_LINES: usize = 8; + +pub struct RouletteGame; + +impl RouletteGame { + pub fn new() -> Self { + Self + } + + fn bets(state: &GameState) -> Bets { + state + .data + .get("bets") + .and_then(|raw| serde_json::from_str(raw).ok()) + .unwrap_or_default() + } + + fn bets_line(bets: &Bets) -> String { + let mut parts = Vec::new(); + if bets.red > 0 { + parts.push(format!("RED {}", bets.red)); + } + if bets.black > 0 { + parts.push(format!("BLACK {}", bets.black)); + } + if bets.even > 0 { + parts.push(format!("EVEN {}", bets.even)); + } + if bets.odd > 0 { + parts.push(format!("ODD {}", bets.odd)); + } + for bet in &bets.numbers { + parts.push(format!("#{} {}", bet.number, bet.amount)); + } + + let list = if parts.is_empty() { + "(none)".to_string() + } else { + parts.join(" ") + }; + format!("Bets: {} Total: {} PLN", list, bets.total()) + } + + fn result_lines(state: &GameState) -> (String, String) { + if let Some(error) = state.data.get("error") { + if !error.is_empty() { + return (format!("ERROR: {error}"), String::new()); + } + } + + match state.data.get("status").map(String::as_str) { + Some("result") => { + let number = state + .data + .get("number") + .map(String::as_str) + .unwrap_or("?"); + let color = state + .data + .get("color") + .map(|value| value.to_uppercase()) + .unwrap_or_default(); + let parity = number + .parse::() + .ok() + .map(|number| { + if is_even(number) { + " EVEN" + } else if is_odd(number) { + " ODD" + } else { + "" + } + }) + .unwrap_or(""); + let winnings = state + .data + .get("winnings") + .map(String::as_str) + .unwrap_or("0"); + let result = if winnings == "0" { + "No win.".to_string() + } else { + format!("Won {winnings} PLN") + }; + (format!("Winning: {number} {color}{parity}"), result) + } + Some("spinning") => ("Spinning...".to_string(), String::new()), + _ => ("Place bets, then SPACE to spin.".to_string(), String::new()), + } + } +} + +impl Game for RouletteGame { + fn get_name(&self) -> &'static str { + "Roulette" + } + + fn render(&self, state: &GameState) -> String { + let bets = Self::bets(state); + let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); + let balance = state + .data + .get("balance") + .map(String::as_str) + .unwrap_or("?"); + let input = state + .data + .get("number_input") + .map(String::as_str) + .unwrap_or(""); + let input_line = if input.is_empty() { + String::new() + } else { + format!("Number: {input}_") + }; + let (status, result) = Self::result_lines(state); + + format!( + "=== 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", + balance, + chip, + Self::bets_line(&bets), + input_line, + status, + result + ) + } + + fn render_lines(&self, _state: &GameState) -> usize { + ROULETTE_BOARD_LINES + } + + fn get_help(&self) -> String { + "ROULETTE (European)\n\ + ==================\n\ + 37 pockets: 0 plus 1-36. 0 is green and loses all outside bets.\n\ + \n\ + Betting:\n\ + R / B / E / O - add the current chip to red/black/even/odd\n\ + digits + Enter - add the current chip to that number\n\ + C - clear all bets\n\ + + or = / - - change the chip size\n\ + SPACE - spin when at least one bet is placed\n\ + Q - exit\n\ + \n\ + Payouts:\n\ + straight number: 35:1 (36x returned)\n\ + red/black: 1:1 (2x returned)\n\ + even/odd: 1:1 (2x returned)\n\ + Multiple bets are settled independently and added together." + .to_string() + } +} 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 @@ pub mod client; pub mod protocol; +pub mod roulette; pub mod server; pub mod slots; pub 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 @@ +//! European roulette rules shared by the server and client. + +use serde::{Deserialize, Serialize}; + +/// European wheel clockwise from zero. +pub const WHEEL: [u8; 37] = [ + 0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5, + 24, 16, 33, 1, 20, 14, 31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26, +]; + +pub const RED_NUMBERS: [u8; 18] = [ + 1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36, +]; + +/// Total return on a winning straight (35:1 plus the stake). +pub const STRAIGHT_RETURN: u64 = 36; +/// Total return on a winning outside bet (1:1 plus the stake). +pub const EVEN_MONEY_RETURN: u64 = 2; + +pub fn is_red(number: u8) -> bool { + RED_NUMBERS.contains(&number) +} + +pub fn is_black(number: u8) -> bool { + number != 0 && !is_red(number) +} + +pub fn color(number: u8) -> &'static str { + if number == 0 { + "green" + } else if is_red(number) { + "red" + } else { + "black" + } +} + +pub fn is_even(number: u8) -> bool { + number != 0 && number % 2 == 0 +} + +pub fn is_odd(number: u8) -> bool { + number != 0 && number % 2 == 1 +} + +pub fn rtp() -> f64 { + 36.0 / 37.0 +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NumberBet { + pub number: u8, + pub amount: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Bets { + #[serde(default)] + pub red: u64, + #[serde(default)] + pub black: u64, + #[serde(default)] + pub even: u64, + #[serde(default)] + pub odd: u64, + #[serde(default)] + pub numbers: Vec, +} + +impl Bets { + pub fn total(&self) -> u64 { + let mut total = self + .red + .saturating_add(self.black) + .saturating_add(self.even) + .saturating_add(self.odd); + for bet in &self.numbers { + total = total.saturating_add(bet.amount); + } + total + } + + pub fn is_empty(&self) -> bool { + self.total() == 0 + } + + /// Total returned for a winning number, including returned stakes. + pub fn winnings(&self, number: u8) -> u64 { + let mut winnings = 0u64; + + if number != 0 { + if is_red(number) { + winnings = winnings.saturating_add(self.red.saturating_mul(EVEN_MONEY_RETURN)); + } else { + winnings = + winnings.saturating_add(self.black.saturating_mul(EVEN_MONEY_RETURN)); + } + if is_even(number) { + winnings = winnings.saturating_add(self.even.saturating_mul(EVEN_MONEY_RETURN)); + } else { + winnings = winnings.saturating_add(self.odd.saturating_mul(EVEN_MONEY_RETURN)); + } + } + + for bet in &self.numbers { + if bet.number == number { + winnings = winnings + .saturating_add(bet.amount.saturating_mul(STRAIGHT_RETURN)); + } + } + + winnings + } + + pub fn validate(&self, balance: u64) -> Result<(), String> { + if self.is_empty() { + return Err("Place at least one bet".to_string()); + } + if self.total() > balance { + return Err("Insufficient balance".to_string()); + } + for bet in &self.numbers { + if bet.number > 36 { + return Err(format!("Invalid number: {}", bet.number)); + } + if bet.amount == 0 { + return Err("A bet amount must be positive".to_string()); + } + } + Ok(()) + } +} 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 { } } "PLAY_START" => { - if parts.len() != 3 { - "ERROR Invalid PLAY_START format. Usage: PLAY_START " + if parts.len() != 3 && parts.len() != 4 { + "ERROR Invalid PLAY_START format. Usage: PLAY_START [payload]" .to_string() } else { - self.handle_play_start(state, parts[1], parts[2]) + self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied()) } } "REGISTER" => { @@ -187,6 +187,7 @@ impl ServerCore { state: &ConnectionState, game_type: &str, bet_amount_str: &str, + payload: Option<&str>, ) -> String { let Some(user) = state.user.as_deref() else { return "ERROR Not logged in".to_string(); @@ -216,7 +217,8 @@ impl ServerCore { } let game_state = game.prepare_state(user, bet_amount as u64); - let new_state = match game.process_action(&game_state, "spin", &[]) { + let params: Vec<&str> = payload.into_iter().collect(); + let new_state = match game.process_action(&game_state, "spin", ¶ms) { Ok(state) => state, Err(error) => return game_error(error), }; @@ -239,26 +241,6 @@ impl ServerCore { } }; - let reels = result - .result_data - .get("reels") - .cloned() - .unwrap_or_default(); - let win_type = result - .result_data - .get("win_type") - .cloned() - .unwrap_or_default(); - let win_lines = result - .result_data - .get("win_lines") - .cloned() - .unwrap_or_else(|| "-".to_string()); - let win_cells = result - .result_data - .get("win_cells") - .cloned() - .unwrap_or_else(|| "-".to_string()); let details = serde_json::to_string(&result.result_data).unwrap_or_else(|_| "{}".to_string()); @@ -273,9 +255,50 @@ impl ServerCore { ) { Ok(new_balance) => { let _ = self.sessions.remove_session(&session_id); - format!( - "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type} {win_lines} {win_cells}" - ) + if game_type == "roulette" { + let number = result + .result_data + .get("number") + .cloned() + .unwrap_or_default(); + let color = result + .result_data + .get("color") + .cloned() + .unwrap_or_default(); + let bets = result + .result_data + .get("bets") + .cloned() + .unwrap_or_else(|| "{}".to_string()); + format!( + "ROULETTE_RESULT {session_id} {winnings} {bet_amount} {new_balance} {number} {color} {bets}" + ) + } else { + let reels = result + .result_data + .get("reels") + .cloned() + .unwrap_or_default(); + let win_type = result + .result_data + .get("win_type") + .cloned() + .unwrap_or_default(); + let win_lines = result + .result_data + .get("win_lines") + .cloned() + .unwrap_or_else(|| "-".to_string()); + let win_cells = result + .result_data + .get("win_cells") + .cloned() + .unwrap_or_else(|| "-".to_string()); + format!( + "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type} {win_lines} {win_cells}" + ) + } } Err(error) => { 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 @@ pub mod registry; +pub mod roulette; pub mod session; pub mod slots; pub 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 @@ #![allow(dead_code)] +use super::roulette::RouletteGame; use super::slots::SlotsGame; use super::traits::Game; use std::collections::HashMap; @@ -21,6 +22,7 @@ impl GameRegistry { pub fn with_defaults() -> Self { let registry = Self::new(); registry.register_game(SlotsGame::new()); + registry.register_game(RouletteGame::new()); registry } 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 @@ +use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; +use crate::roulette::{color, Bets}; +use rand::Rng; +use std::collections::HashMap; + +pub struct RouletteGame { + fixed_number: Option, +} + +impl RouletteGame { + pub fn new() -> Self { + Self { fixed_number: None } + } + + pub fn with_result(number: u8) -> Self { + Self { + fixed_number: Some(number), + } + } + + fn spin(&self) -> u8 { + match self.fixed_number { + Some(number) => number, + None => rand::thread_rng().gen_range(0..37), + } + } +} + +impl Game for RouletteGame { + fn game_type(&self) -> &'static str { + "roulette" + } + + fn get_name(&self) -> &'static str { + "Roulette" + } + + fn get_description(&self) -> &'static str { + "European roulette. Bet on red/black, even/odd, or a straight number." + } + + fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState { + let mut data = HashMap::new(); + data.insert("username".to_string(), username.to_string()); + data.insert("status".to_string(), "ready".to_string()); + + GameState { + session_id: String::new(), + game_type: "roulette".to_string(), + bet_amount, + phase: GamePhase::WaitingToStart, + data, + } + } + + fn process_action( + &self, + state: &GameState, + action: &str, + params: &[&str], + ) -> Result { + if state.phase == GamePhase::Finished { + return Err(GameError::GameOver); + } + + match action { + "spin" => { + if state.phase != GamePhase::WaitingToStart + && state.phase != GamePhase::Playing + { + return Err(GameError::InvalidAction( + "Cannot spin in current phase".to_string(), + )); + } + + let payload = params + .first() + .ok_or(GameError::InvalidAction("Missing roulette bets".to_string()))?; + let bets: Bets = serde_json::from_str(payload) + .map_err(|error| GameError::InvalidAction(format!("Invalid bets: {error}")))?; + + if bets.is_empty() { + return Err(GameError::InvalidBet); + } + if let Err(message) = bets.validate(state.bet_amount) { + return Err(GameError::InvalidAction(message)); + } + if bets.total() != state.bet_amount { + return Err(GameError::InvalidBet); + } + + let number = self.spin(); + let bets_json = serde_json::to_string(&bets) + .map_err(|error| GameError::InternalError(error.to_string()))?; + + let mut new_data = state.data.clone(); + new_data.insert("status".to_string(), "spun".to_string()); + new_data.insert("bets".to_string(), bets_json); + new_data.insert("number".to_string(), number.to_string()); + new_data.insert("color".to_string(), color(number).to_string()); + + Ok(GameState { + session_id: state.session_id.clone(), + game_type: state.game_type.clone(), + bet_amount: state.bet_amount, + phase: GamePhase::Finished, + data: new_data, + }) + } + _ => Err(GameError::InvalidAction(format!( + "Unknown action: {action}" + ))), + } + } + + fn calculate_result(&self, state: &GameState) -> Result { + if state.phase != GamePhase::Finished { + return Err(GameError::InternalError("Game not finished".to_string())); + } + + let number: u8 = state + .data + .get("number") + .ok_or(GameError::InternalError("No winning number".to_string()))? + .parse() + .map_err(|_| GameError::InternalError("Invalid winning number".to_string()))?; + let bets_json = state + .data + .get("bets") + .ok_or(GameError::InternalError("No bets".to_string()))? + .clone(); + let bets: Bets = serde_json::from_str(&bets_json) + .map_err(|error| GameError::InternalError(format!("Invalid bets: {error}")))?; + + let winnings = bets.winnings(number); + + let mut result_data = HashMap::new(); + result_data.insert("number".to_string(), number.to_string()); + result_data.insert("color".to_string(), color(number).to_string()); + result_data.insert("bets".to_string(), bets_json); + + Ok(GameResult { + session_id: state.session_id.clone(), + game_type: state.game_type.clone(), + bet_amount: state.bet_amount, + winnings, + final_balance: 0, + result_data, + }) + } +} 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 { self.region_start = None; self.region_len = 0; } + Effect::AnimateRoulette { final_lines, .. } => { + if let Some(start) = self.region_start { + self.lines.truncate(start); + } else { + self.region_start = Some(self.lines.len()); + } + self.lines.extend(final_lines.iter().cloned()); + self.region_len = final_lines.len(); + } Effect::SetPrompt { text, cursor_col } => { self.prompt = text.clone(); self.prompt_cursor = *cursor_col; @@ -125,6 +134,11 @@ impl Harness { self.line(&format!("play slots {bet}")); self.reply("INFO alice 1 1000"); } + + fn enter_roulette(&mut self) { + self.line("play roulette"); + self.reply("INFO alice 1 1000"); + } } #[test] @@ -559,3 +573,77 @@ fn rejects_a_grid_with_the_wrong_size() { assert!(parse_spin_response(response).is_err()); } + +#[test] +fn roulette_split_bets_spin_and_show_the_result() { + let mut h = Harness::new(); + h.enter_roulette(); + + assert_eq!(h.board().len(), 8); + assert_eq!(h.board()[0], "=== ROULETTE ==="); + assert!(h.board()[1].contains("Balance: 1000 PLN")); + assert!(h.board()[1].contains("Chip: 10 PLN")); + assert!(h.board()[2].contains("Bets: (none)")); + + h.key(Key::Char('r')); + h.key(Key::Char('e')); + h.key(Key::Char('1')); + h.key(Key::Char('7')); + h.key(Key::Enter); + + assert!(h.board()[2].contains("RED 10")); + assert!(h.board()[2].contains("EVEN 10")); + assert!(h.board()[2].contains("#17 10")); + assert!(h.board()[2].contains("Total: 30 PLN")); + + h.key(Key::Char(' ')); + let request = h + .sent + .iter() + .find(|line| line.starts_with("PLAY_START roulette")) + .expect("roulette request"); + assert_eq!( + request, + "PLAY_START roulette 30 {\"red\":10,\"black\":0,\"even\":10,\"odd\":0,\"numbers\":[{\"number\":17,\"amount\":10}]}" + ); + + let bets = + r#"{"red":10,"black":0,"even":10,"odd":0,"numbers":[{"number":17,"amount":10}]}"#; + h.reply(&format!( + "ROULETTE_RESULT session-1 360 30 1330 17 black {bets}" + )); + + assert_eq!(h.board().len(), 8); + assert!(h.board()[1].contains("Balance: 1330 PLN")); + assert!(h.board()[4].contains("Winning: 17 BLACK ODD")); + assert!(h.board()[5].contains("Won 360 PLN")); +} + +#[test] +fn roulette_rejects_an_empty_spin_and_accepts_the_equals_chip_key() { + let mut h = Harness::new(); + h.enter_roulette(); + + h.key(Key::Char(' ')); + assert!(h.board()[4].contains("ERROR: Place at least one bet")); + assert!(!h.sent.iter().any(|line| line.starts_with("PLAY_START roulette"))); + + h.key(Key::Char('=')); + assert!(h.board()[1].contains("Chip: 20 PLN")); + h.key(Key::Char('r')); + assert!(h.board()[2].contains("RED 20")); + assert!(h.board()[2].contains("Total: 20 PLN")); +} + +#[test] +fn roulette_rejects_an_out_of_range_number() { + let mut h = Harness::new(); + h.enter_roulette(); + + h.key(Key::Char('9')); + h.key(Key::Char('9')); + h.key(Key::Enter); + + assert!(h.board()[4].contains("ERROR: Number must be 0-36")); + assert!(h.board()[2].contains("Bets: (none)")); +} 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 @@ +use zagan_bet::roulette::{ + color, is_black, is_even, is_odd, is_red, rtp, Bets, NumberBet, WHEEL, +}; +use zagan_bet::server::games::roulette::RouletteGame; +use zagan_bet::server::games::traits::Game; + +#[test] +fn the_european_wheel_has_all_37_unique_pockets() { + let mut numbers = WHEEL.to_vec(); + numbers.sort_unstable(); + assert_eq!(numbers, (0..=36).collect::>()); + assert_eq!(color(0), "green"); + assert_eq!(WHEEL.len(), 37); +} + +#[test] +fn colors_and_even_odd_are_correct() { + assert!(is_red(1)); + assert!(is_black(2)); + assert!(!is_red(0)); + assert!(!is_black(0)); + assert!(is_even(2)); + assert!(is_odd(3)); + assert!(!is_even(0)); + assert!(!is_odd(0)); + assert_eq!(color(17), "black"); + assert_eq!(color(18), "red"); +} + +#[test] +fn european_rtp_is_36_over_37() { + assert!((rtp() - 36.0 / 37.0).abs() < 1e-12); +} + +#[test] +fn bets_total_and_winning_number_payouts() { + let bets = Bets { + red: 10, + black: 10, + even: 10, + odd: 10, + numbers: vec![NumberBet { + number: 17, + amount: 5, + }], + }; + + assert_eq!(bets.total(), 45); + // 17 is black and odd: 10*2 + 10*2 + 5*36. + assert_eq!(bets.winnings(17), 220); + // 18 is red and even. + assert_eq!(bets.winnings(18), 40); +} + +#[test] +fn zero_loses_all_outside_bets_but_pays_a_straight_zero() { + let outside = Bets { + red: 10, + black: 10, + even: 10, + odd: 10, + numbers: vec![], + }; + assert_eq!(outside.winnings(0), 0); + + let straight = Bets { + red: 10, + black: 10, + even: 10, + odd: 10, + numbers: vec![NumberBet { + number: 0, + amount: 5, + }], + }; + assert_eq!(straight.total(), 45); + assert_eq!(straight.winnings(0), 180); +} + +#[test] +fn bets_validate_empty_balance_and_invalid_numbers() { + assert!(Bets::default().validate(1000).is_err()); + + let too_big = Bets { + red: 2000, + ..Bets::default() + }; + assert!(too_big.validate(1000).is_err()); + + let bad_number = Bets { + numbers: vec![NumberBet { + number: 37, + amount: 10, + }], + ..Bets::default() + }; + assert!(bad_number.validate(1000).is_err()); +} + +#[test] +fn the_server_settles_a_split_bet() { + let bets = Bets { + red: 10, + black: 10, + even: 10, + odd: 10, + numbers: vec![NumberBet { + number: 17, + amount: 5, + }], + }; + let payload = serde_json::to_string(&bets).unwrap(); + let game = RouletteGame::with_result(17); + let state = game.prepare_state("alice", bets.total()); + + let state = game.process_action(&state, "spin", &[&payload]).unwrap(); + let result = game.calculate_result(&state).unwrap(); + + assert_eq!(result.winnings, 220); + assert_eq!(result.result_data.get("number").unwrap(), "17"); + assert_eq!(result.result_data.get("color").unwrap(), "black"); + assert_eq!(result.result_data.get("bets").unwrap(), &payload); +} + +#[test] +fn the_server_rejects_a_total_that_does_not_match_the_bets() { + let payload = serde_json::to_string(&Bets { + red: 10, + ..Bets::default() + }) + .unwrap(); + let game = RouletteGame::with_result(17); + let state = game.prepare_state("alice", 99); + + assert!(game.process_action(&state, "spin", &[&payload]).is_err()); +} + +#[test] +fn the_server_rejects_an_invalid_number() { + let payload = serde_json::to_string(&Bets { + numbers: vec![NumberBet { + number: 37, + amount: 10, + }], + ..Bets::default() + }) + .unwrap(); + let game = RouletteGame::with_result(17); + let state = game.prepare_state("alice", 10); + + assert!(game.process_action(&state, "spin", &[&payload]).is_err()); +} + +#[test] +fn parses_a_roulette_result() { + let bets = r#"{"red":10,"black":0,"even":10,"odd":0,"numbers":[{"number":17,"amount":10}]}"#; + let response = format!("ROULETTE_RESULT session-1 360 30 1330 17 black {bets}"); + let result = zagan_bet::client::games::response::parse_roulette_response(&response).unwrap(); + + assert_eq!(result.session_id, "session-1"); + assert_eq!(result.winnings, 360); + assert_eq!(result.bet_amount, 30); + assert_eq!(result.balance, 1330); + assert_eq!(result.number, 17); + assert_eq!(result.color, "black"); + assert_eq!(result.bets, bets); +} + +#[test] +fn rejects_bad_roulette_results() { + use zagan_bet::client::games::response::parse_roulette_response; + + let valid_bets = r#"{"red":10}"#; + assert!(parse_roulette_response(&format!( + "ROULETTE_RESULT s 1 1 1 37 green {valid_bets}" + )) + .is_err()); + assert!(parse_roulette_response(&format!( + "ROULETTE_RESULT s 1 1 1 17 purple {valid_bets}" + )) + .is_err()); + assert!(parse_roulette_response("ROULETTE_RESULT s 1 1 1 17 black nope").is_err()); + assert!(parse_roulette_response("").is_err()); +} 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; use zagan_bet::server::core::{ConnectionState, ServerCore}; use zagan_bet::server::database; use zagan_bet::server::games::registry::GameRegistry; +use zagan_bet::server::games::roulette::RouletteGame; use zagan_bet::server::games::slots::SlotsGame; +use zagan_bet::roulette::Bets; fn setup(name: &str, grid: &str) -> (ServerCore, String) { let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) @@ -29,6 +31,22 @@ const LOSS: &str = "CLOOGDSLCDCOGLS"; /// Top line is C C C D D; it pays 2x total bet. const WIN3: &str = "CCCCLLCLODGSDGS"; +fn setup_roulette(name: &str, number: u8) -> (ServerCore, String) { + let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(format!("server-core-{name}.db")) + .to_string_lossy() + .into_owned(); + let _ = std::fs::remove_file(&path); + + let conn = Connection::open(&path).unwrap(); + database::init_db(&conn); + + let registry = GameRegistry::new(); + registry.register_game(RouletteGame::with_result(number)); + + (ServerCore::with_registry(conn, registry, 4), path) +} + fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { assert_eq!( core.handle_message(state, &format!("REGISTER {user} credential")), @@ -163,6 +181,47 @@ fn plays_record_the_game_type_and_game_specific_details() { assert_eq!(details["win_cells"], "0,3,6"); } +#[test] +fn roulette_settles_split_bets_and_records_details() { + let (mut core, path) = setup_roulette("roulette", 17); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let bets = + r#"{"red":10,"black":10,"even":10,"odd":10,"numbers":[{"number":17,"amount":5}]}"#; + let response = core.handle_message(&mut state, &format!("PLAY_START roulette 45 {bets}")); + + assert!( + response.starts_with("ROULETTE_RESULT"), + "response: {response}" + ); + assert!( + response.contains(" 220 45 1175 17 black "), + "response: {response}" + ); + assert!(response.ends_with(&format!(" {bets}")), "response: {response}"); + assert_eq!(core.balance("alice"), Some(1175)); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); + + let conn = Connection::open(&path).unwrap(); + let (game_type, details): (String, String) = conn + .query_row( + "SELECT game_type, details FROM plays WHERE username = 'alice'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + + assert_eq!(game_type, "roulette"); + let details: serde_json::Value = serde_json::from_str(&details).unwrap(); + assert_eq!(details["number"], "17"); + assert_eq!(details["color"], "black"); + let stored: Bets = serde_json::from_str(details["bets"].as_str().unwrap()).unwrap(); + assert_eq!(stored.total(), 45); +} + #[test] fn a_failed_audit_insert_rolls_back_the_balance_change() { let (mut core, path) = setup("rollback", LOSS); -- cgit v1.2.3