diff options
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/core.rs | 183 | ||||
| -rw-r--r-- | src/client/games/manager.rs | 131 | ||||
| -rw-r--r-- | src/client/games/mod.rs | 1 | ||||
| -rw-r--r-- | src/client/games/response.rs | 64 | ||||
| -rw-r--r-- | src/client/games/roulette.rs | 157 |
5 files changed, 532 insertions, 4 deletions
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 @@ | |||
| 1 | use crate::client::games::manager::GameManager; | 1 | use crate::client::games::manager::GameManager; |
| 2 | use crate::client::games::response::parse_spin_response; | 2 | use crate::client::games::response::{parse_roulette_response, parse_spin_response}; |
| 3 | use crate::client::ui::{ | 3 | use 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 | }; |
| 7 | use crate::roulette::{Bets, NumberBet}; | ||
| 7 | 8 | ||
| 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 9 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 9 | pub enum Key { | 10 | pub 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)] |
| 55 | enum Pending { | 61 | enum 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)] | ||
| 70 | enum RouletteSide { | ||
| 71 | Red, | ||
| 72 | Black, | ||
| 73 | Even, | ||
| 74 | Odd, | ||
| 75 | } | ||
| 76 | |||
| 62 | pub struct ClientCore { | 77 | pub 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 @@ | |||
| 1 | use super::response::SpinResult; | 1 | use super::response::{RouletteResult, SpinResult}; |
| 2 | use super::roulette::RouletteGame; | ||
| 2 | use super::slots::SlotsGame; | 3 | use super::slots::SlotsGame; |
| 3 | use super::traits::{Game, GameError, GamePhase, GameState}; | 4 | use super::traits::{Game, GameError, GamePhase, GameState}; |
| 5 | use crate::roulette::Bets; | ||
| 4 | use std::collections::HashMap; | 6 | use std::collections::HashMap; |
| 5 | 7 | ||
| 6 | pub struct GameManager { | 8 | pub 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 @@ | |||
| 1 | pub mod manager; | 1 | pub mod manager; |
| 2 | pub mod response; | 2 | pub mod response; |
| 3 | pub mod roulette; | ||
| 3 | pub mod slots; | 4 | pub mod slots; |
| 4 | pub mod traits; | 5 | 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<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)] | ||
| 82 | pub 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 | |||
| 92 | pub 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 @@ | |||
| 1 | use super::traits::{Game, GameState}; | ||
| 2 | use crate::roulette::{is_even, is_odd, Bets}; | ||
| 3 | |||
| 4 | pub const ROULETTE_BOARD_LINES: usize = 8; | ||
| 5 | |||
| 6 | pub struct RouletteGame; | ||
| 7 | |||
| 8 | impl 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 | |||
| 97 | impl 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 | } | ||
