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 --- src/server/core.rs | 77 ++++++++++++++-------- src/server/games/mod.rs | 1 + src/server/games/registry.rs | 2 + src/server/games/roulette.rs | 151 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 27 deletions(-) create mode 100644 src/server/games/roulette.rs (limited to 'src/server') 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, + }) + } +} -- cgit v1.2.3