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/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 +++++++++++++++++++++++++++++++++++++ 5 files changed, 532 insertions(+), 4 deletions(-) create mode 100644 src/client/games/roulette.rs (limited to 'src/client') 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() + } +} -- cgit v1.2.3