diff options
Diffstat (limited to '')
| -rw-r--r-- | src/bin/client/connection.rs | 2 | ||||
| -rw-r--r-- | src/bin/client/main.rs | 15 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 2 | ||||
| -rw-r--r-- | src/client/core.rs | 102 | ||||
| -rw-r--r-- | src/client/database.rs | 41 | ||||
| -rw-r--r-- | src/client/games/manager.rs | 17 | ||||
| -rw-r--r-- | src/client/games/roulette.rs | 346 | ||||
| -rw-r--r-- | src/client/ui.rs | 17 | ||||
| -rw-r--r-- | src/roulette.rs | 215 | ||||
| -rw-r--r-- | src/server/database.rs | 6 | ||||
| -rw-r--r-- | src/server/games/roulette.rs | 2 |
11 files changed, 526 insertions, 239 deletions
diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs index ec5642c..7516836 100644 --- a/src/bin/client/connection.rs +++ b/src/bin/client/connection.rs | |||
| @@ -49,7 +49,7 @@ pub fn connect( | |||
| 49 | Ok(Ok((tls, fp))) => (tls, fp), | 49 | Ok(Ok((tls, fp))) => (tls, fp), |
| 50 | Ok(Err(e)) => return Err(Box::new(TrustError(format!("{e}")))), | 50 | Ok(Err(e)) => return Err(Box::new(TrustError(format!("{e}")))), |
| 51 | Err(mpsc::RecvTimeoutError::Timeout) => { | 51 | Err(mpsc::RecvTimeoutError::Timeout) => { |
| 52 | return Err("Connection timed out after 3 seconds".into()); | 52 | return Err("Connection timed out after 10 seconds".into()); |
| 53 | } | 53 | } |
| 54 | Err(_) => return Err("Connection attempt failed".into()), | 54 | Err(_) => return Err("Connection attempt failed".into()), |
| 55 | }; | 55 | }; |
diff --git a/src/bin/client/main.rs b/src/bin/client/main.rs index 1b701d8..9018026 100644 --- a/src/bin/client/main.rs +++ b/src/bin/client/main.rs | |||
| @@ -10,6 +10,21 @@ fn main() { | |||
| 10 | let args: Vec<String> = std::env::args().collect(); | 10 | let args: Vec<String> = std::env::args().collect(); |
| 11 | let unsafe_mode = args.iter().any(|arg| arg == "--unsafe"); | 11 | let unsafe_mode = args.iter().any(|arg| arg == "--unsafe"); |
| 12 | 12 | ||
| 13 | // Every game board is drawn in place, so refuse a terminal that cannot fit | ||
| 14 | // them before touching the network or the terminal mode. | ||
| 15 | let size = crossterm::terminal::size(); | ||
| 16 | let (columns, rows) = match &size { | ||
| 17 | Ok((columns, rows)) => (*columns, *rows), | ||
| 18 | Err(_) => (0, 0), | ||
| 19 | }; | ||
| 20 | if let Some(message) = zagan_bet::client::ui::terminal_too_small(columns, rows) { | ||
| 21 | eprintln!("{message}"); | ||
| 22 | if size.is_err() { | ||
| 23 | eprintln!("Could not read the terminal size (is this a real terminal?)."); | ||
| 24 | } | ||
| 25 | std::process::exit(1); | ||
| 26 | } | ||
| 27 | |||
| 13 | if unsafe_mode { | 28 | if unsafe_mode { |
| 14 | println!("WARNING: Running in UNSAFE mode - certificate verification disabled!"); | 29 | println!("WARNING: Running in UNSAFE mode - certificate verification disabled!"); |
| 15 | } | 30 | } |
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index d973596..2cf121d 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs | |||
| @@ -712,6 +712,8 @@ fn map_key(key: crossterm::event::KeyEvent) -> Option<Key> { | |||
| 712 | KeyCode::Backspace => Some(Key::Backspace), | 712 | KeyCode::Backspace => Some(Key::Backspace), |
| 713 | KeyCode::Up => Some(Key::Up), | 713 | KeyCode::Up => Some(Key::Up), |
| 714 | KeyCode::Down => Some(Key::Down), | 714 | KeyCode::Down => Some(Key::Down), |
| 715 | KeyCode::Left => Some(Key::Left), | ||
| 716 | KeyCode::Right => Some(Key::Right), | ||
| 715 | KeyCode::Tab => Some(Key::Tab), | 717 | KeyCode::Tab => Some(Key::Tab), |
| 716 | KeyCode::Esc => Some(Key::Esc), | 718 | KeyCode::Esc => Some(Key::Esc), |
| 717 | _ => None, | 719 | _ => None, |
diff --git a/src/client/core.rs b/src/client/core.rs index 06d9c42..8411d47 100644 --- a/src/client/core.rs +++ b/src/client/core.rs | |||
| @@ -7,7 +7,8 @@ use crate::client::ui::{ | |||
| 7 | record_history, | 7 | record_history, |
| 8 | }; | 8 | }; |
| 9 | use crate::crash::{multiplier_at, CrashState}; | 9 | use crate::crash::{multiplier_at, CrashState}; |
| 10 | use crate::roulette::{Bets, NumberBet}; | 10 | use crate::client::games::roulette::Direction; |
| 11 | use crate::roulette::Bets; | ||
| 11 | use std::time::{Duration, Instant}; | 12 | use std::time::{Duration, Instant}; |
| 12 | 13 | ||
| 13 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 14 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| @@ -17,6 +18,8 @@ pub enum Key { | |||
| 17 | Backspace, | 18 | Backspace, |
| 18 | Up, | 19 | Up, |
| 19 | Down, | 20 | Down, |
| 21 | Left, | ||
| 22 | Right, | ||
| 20 | Tab, | 23 | Tab, |
| 21 | Esc, | 24 | Esc, |
| 22 | CtrlC, | 25 | CtrlC, |
| @@ -76,14 +79,6 @@ enum Pending { | |||
| 76 | Info, | 79 | Info, |
| 77 | } | 80 | } |
| 78 | 81 | ||
| 79 | #[derive(Debug, Clone, Copy)] | ||
| 80 | enum RouletteSide { | ||
| 81 | Red, | ||
| 82 | Black, | ||
| 83 | Even, | ||
| 84 | Odd, | ||
| 85 | } | ||
| 86 | |||
| 87 | pub struct ClientCore { | 82 | pub struct ClientCore { |
| 88 | input: String, | 83 | input: String, |
| 89 | history: Vec<String>, | 84 | history: Vec<String>, |
| @@ -171,6 +166,7 @@ impl ClientCore { | |||
| 171 | } | 166 | } |
| 172 | Key::Up => self.history_previous(), | 167 | Key::Up => self.history_previous(), |
| 173 | Key::Down => self.history_next(), | 168 | Key::Down => self.history_next(), |
| 169 | Key::Left | Key::Right => Vec::new(), | ||
| 174 | Key::Tab => self.complete(), | 170 | Key::Tab => self.complete(), |
| 175 | Key::Esc => Vec::new(), | 171 | Key::Esc => Vec::new(), |
| 176 | } | 172 | } |
| @@ -355,6 +351,19 @@ impl ClientCore { | |||
| 355 | } | 351 | } |
| 356 | 352 | ||
| 357 | fn handle_roulette_key(&mut self, key: Key) -> Vec<Effect> { | 353 | fn handle_roulette_key(&mut self, key: Key) -> Vec<Effect> { |
| 354 | let direction = match key { | ||
| 355 | Key::Up => Some(Direction::Up), | ||
| 356 | Key::Down => Some(Direction::Down), | ||
| 357 | Key::Left => Some(Direction::Left), | ||
| 358 | Key::Right => Some(Direction::Right), | ||
| 359 | _ => None, | ||
| 360 | }; | ||
| 361 | if let Some(direction) = direction { | ||
| 362 | let cursor = self.game.roulette_cursor().moved(direction); | ||
| 363 | self.game.set_roulette_cursor(&cursor); | ||
| 364 | return self.board_effects(); | ||
| 365 | } | ||
| 366 | |||
| 358 | match key { | 367 | match key { |
| 359 | Key::CtrlC => vec![Effect::Quit], | 368 | Key::CtrlC => vec![Effect::Quit], |
| 360 | Key::Char('q') => { | 369 | Key::Char('q') => { |
| @@ -364,18 +373,9 @@ impl ClientCore { | |||
| 364 | Effect::Print(vec!["Game exited.".to_string()]), | 373 | Effect::Print(vec!["Game exited.".to_string()]), |
| 365 | ] | 374 | ] |
| 366 | } | 375 | } |
| 367 | Key::Esc => { | ||
| 368 | self.game.set_roulette_number_input(""); | ||
| 369 | self.board_effects() | ||
| 370 | } | ||
| 371 | Key::Char(' ') => self.spin_roulette(), | 376 | Key::Char(' ') => self.spin_roulette(), |
| 372 | Key::Char('r') => self.add_roulette_side(RouletteSide::Red), | ||
| 373 | Key::Char('b') => self.add_roulette_side(RouletteSide::Black), | ||
| 374 | Key::Char('e') => self.add_roulette_side(RouletteSide::Even), | ||
| 375 | Key::Char('o') => self.add_roulette_side(RouletteSide::Odd), | ||
| 376 | Key::Char('c') => { | 377 | Key::Char('c') => { |
| 377 | self.game.set_roulette_bets(&Bets::default()); | 378 | self.game.set_roulette_bets(&Bets::default()); |
| 378 | self.game.set_roulette_number_input(""); | ||
| 379 | self.game.set_roulette_status("place"); | 379 | self.game.set_roulette_status("place"); |
| 380 | self.board_effects() | 380 | self.board_effects() |
| 381 | } | 381 | } |
| @@ -389,63 +389,17 @@ impl ClientCore { | |||
| 389 | self.game.set_roulette_chip(chip); | 389 | self.game.set_roulette_chip(chip); |
| 390 | self.board_effects() | 390 | self.board_effects() |
| 391 | } | 391 | } |
| 392 | Key::Char(c) if c.is_ascii_digit() => { | 392 | Key::Enter => self.place_roulette_chip(), |
| 393 | let mut input = self.roulette_input(); | ||
| 394 | if input.len() < 2 { | ||
| 395 | input.push(c); | ||
| 396 | self.game.set_roulette_number_input(&input); | ||
| 397 | } | ||
| 398 | self.board_effects() | ||
| 399 | } | ||
| 400 | Key::Backspace => { | ||
| 401 | let mut input = self.roulette_input(); | ||
| 402 | input.pop(); | ||
| 403 | self.game.set_roulette_number_input(&input); | ||
| 404 | self.board_effects() | ||
| 405 | } | ||
| 406 | Key::Enter => { | ||
| 407 | let input = self.roulette_input(); | ||
| 408 | if !input.is_empty() { | ||
| 409 | match input.parse::<u8>() { | ||
| 410 | Ok(number) if number <= 36 => { | ||
| 411 | self.add_roulette_number(number); | ||
| 412 | self.game.set_roulette_number_input(""); | ||
| 413 | } | ||
| 414 | _ => { | ||
| 415 | self.game.set_roulette_number_input(""); | ||
| 416 | self.game.set_error("Number must be 0-36"); | ||
| 417 | } | ||
| 418 | } | ||
| 419 | } | ||
| 420 | self.board_effects() | ||
| 421 | } | ||
| 422 | _ => Vec::new(), | 393 | _ => Vec::new(), |
| 423 | } | 394 | } |
| 424 | } | 395 | } |
| 425 | 396 | ||
| 426 | fn add_roulette_side(&mut self, side: RouletteSide) -> Vec<Effect> { | 397 | fn place_roulette_chip(&mut self) -> Vec<Effect> { |
| 427 | let chip = self.game.roulette_chip(); | 398 | let Some(target) = self.game.roulette_cursor().target() else { |
| 428 | let mut bets = self.game.roulette_bets(); | 399 | return Vec::new(); |
| 429 | match side { | 400 | }; |
| 430 | RouletteSide::Red => bets.red = bets.red.saturating_add(chip), | ||
| 431 | RouletteSide::Black => bets.black = bets.black.saturating_add(chip), | ||
| 432 | RouletteSide::Even => bets.even = bets.even.saturating_add(chip), | ||
| 433 | RouletteSide::Odd => bets.odd = bets.odd.saturating_add(chip), | ||
| 434 | } | ||
| 435 | self.place_roulette_bets(bets) | ||
| 436 | } | ||
| 437 | |||
| 438 | fn add_roulette_number(&mut self, number: u8) -> Vec<Effect> { | ||
| 439 | let chip = self.game.roulette_chip(); | ||
| 440 | let mut bets = self.game.roulette_bets(); | 401 | let mut bets = self.game.roulette_bets(); |
| 441 | if let Some(existing) = bets.numbers.iter_mut().find(|bet| bet.number == number) { | 402 | bets.add(target, self.game.roulette_chip()); |
| 442 | existing.amount = existing.amount.saturating_add(chip); | ||
| 443 | } else { | ||
| 444 | bets.numbers.push(NumberBet { | ||
| 445 | number, | ||
| 446 | amount: chip, | ||
| 447 | }); | ||
| 448 | } | ||
| 449 | self.place_roulette_bets(bets) | 403 | self.place_roulette_bets(bets) |
| 450 | } | 404 | } |
| 451 | 405 | ||
| @@ -487,14 +441,6 @@ impl ClientCore { | |||
| 487 | effects | 441 | effects |
| 488 | } | 442 | } |
| 489 | 443 | ||
| 490 | fn roulette_input(&self) -> String { | ||
| 491 | self.game | ||
| 492 | .get_current_state() | ||
| 493 | .and_then(|state| state.data.get("number_input")) | ||
| 494 | .cloned() | ||
| 495 | .unwrap_or_default() | ||
| 496 | } | ||
| 497 | |||
| 498 | fn handle_blackjack_key(&mut self, key: Key) -> Vec<Effect> { | 444 | fn handle_blackjack_key(&mut self, key: Key) -> Vec<Effect> { |
| 499 | let status = self | 445 | let status = self |
| 500 | .game | 446 | .game |
diff --git a/src/client/database.rs b/src/client/database.rs index 04a271a..c4601b5 100644 --- a/src/client/database.rs +++ b/src/client/database.rs | |||
| @@ -20,14 +20,9 @@ pub fn init_db(conn: &Connection) { | |||
| 20 | [], | 20 | [], |
| 21 | ) | 21 | ) |
| 22 | .unwrap(); | 22 | .unwrap(); |
| 23 | // Migration for databases created before TOFU trust. | ||
| 24 | let _ = conn.execute("ALTER TABLE hosts ADD COLUMN fingerprint TEXT", []); | ||
| 25 | 23 | ||
| 26 | // Credentials belong to a server identity (the certificate key), not to a | 24 | // Credentials belong to a server identity (the certificate key), not to a |
| 27 | // single address, so every address that presents the same key shares them. | 25 | // single address, so every address that presents the same key shares them. |
| 28 | if has_column(conn, "credentials", "host") { | ||
| 29 | migrate_credentials_to_fingerprints(conn); | ||
| 30 | } | ||
| 31 | conn.execute( | 26 | conn.execute( |
| 32 | "CREATE TABLE IF NOT EXISTS credentials ( | 27 | "CREATE TABLE IF NOT EXISTS credentials ( |
| 33 | fingerprint TEXT PRIMARY KEY, | 28 | fingerprint TEXT PRIMARY KEY, |
| @@ -39,42 +34,6 @@ pub fn init_db(conn: &Connection) { | |||
| 39 | .unwrap(); | 34 | .unwrap(); |
| 40 | } | 35 | } |
| 41 | 36 | ||
| 42 | fn has_column(conn: &Connection, table: &str, column: &str) -> bool { | ||
| 43 | let Ok(mut statement) = conn.prepare(&format!("PRAGMA table_info({table})")) else { | ||
| 44 | return false; | ||
| 45 | }; | ||
| 46 | let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(1)) else { | ||
| 47 | return false; | ||
| 48 | }; | ||
| 49 | rows.flatten().any(|name| name == column) | ||
| 50 | } | ||
| 51 | |||
| 52 | /// Re-key host-bound credentials by the fingerprint of their host. | ||
| 53 | /// | ||
| 54 | /// Rows whose host was already forgotten have no identity to attach to and are | ||
| 55 | /// dropped; those addresses have to re-authenticate once. | ||
| 56 | fn migrate_credentials_to_fingerprints(conn: &Connection) { | ||
| 57 | let tx = conn.unchecked_transaction().unwrap(); | ||
| 58 | tx.execute_batch( | ||
| 59 | "DROP TABLE IF EXISTS credentials_by_identity; | ||
| 60 | CREATE TABLE credentials_by_identity ( | ||
| 61 | fingerprint TEXT PRIMARY KEY, | ||
| 62 | username TEXT NOT NULL, | ||
| 63 | password TEXT NOT NULL | ||
| 64 | ); | ||
| 65 | INSERT OR REPLACE INTO credentials_by_identity (fingerprint, username, password) | ||
| 66 | SELECT h.fingerprint, c.username, c.password | ||
| 67 | FROM credentials c | ||
| 68 | JOIN hosts h ON h.host = c.host | ||
| 69 | WHERE h.fingerprint IS NOT NULL | ||
| 70 | ORDER BY h.last_used ASC, h.host ASC; | ||
| 71 | DROP TABLE credentials; | ||
| 72 | ALTER TABLE credentials_by_identity RENAME TO credentials;", | ||
| 73 | ) | ||
| 74 | .unwrap(); | ||
| 75 | tx.commit().unwrap(); | ||
| 76 | } | ||
| 77 | |||
| 78 | pub fn get_last_host(conn: &Connection) -> Option<String> { | 37 | pub fn get_last_host(conn: &Connection) -> Option<String> { |
| 79 | let mut stmt = conn | 38 | let mut stmt = conn |
| 80 | .prepare("SELECT host FROM hosts ORDER BY last_used DESC LIMIT 1") | 39 | .prepare("SELECT host FROM hosts ORDER BY last_used DESC LIMIT 1") |
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs index b20ff44..bb2a8b5 100644 --- a/src/client/games/manager.rs +++ b/src/client/games/manager.rs | |||
| @@ -55,7 +55,7 @@ impl GameManager { | |||
| 55 | ); | 55 | ); |
| 56 | data.insert("chip".to_string(), "10".to_string()); | 56 | data.insert("chip".to_string(), "10".to_string()); |
| 57 | data.insert("status".to_string(), "place".to_string()); | 57 | data.insert("status".to_string(), "place".to_string()); |
| 58 | data.insert("number_input".to_string(), String::new()); | 58 | data.insert("cursor".to_string(), "lattice:0:3".to_string()); |
| 59 | 0 | 59 | 0 |
| 60 | } else if game_type == "blackjack" { | 60 | } else if game_type == "blackjack" { |
| 61 | data.insert("status".to_string(), "ready".to_string()); | 61 | data.insert("status".to_string(), "ready".to_string()); |
| @@ -234,11 +234,19 @@ impl GameManager { | |||
| 234 | } | 234 | } |
| 235 | } | 235 | } |
| 236 | 236 | ||
| 237 | pub fn set_roulette_number_input(&mut self, value: &str) { | 237 | pub fn roulette_cursor(&self) -> crate::client::games::roulette::Cursor { |
| 238 | self.current_session | ||
| 239 | .as_ref() | ||
| 240 | .and_then(|session| session.data.get("cursor")) | ||
| 241 | .map(|raw| crate::client::games::roulette::Cursor::parse(raw)) | ||
| 242 | .unwrap_or_default() | ||
| 243 | } | ||
| 244 | |||
| 245 | pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) { | ||
| 238 | if let Some(session) = self.current_session.as_mut() { | 246 | if let Some(session) = self.current_session.as_mut() { |
| 239 | session | 247 | session |
| 240 | .data | 248 | .data |
| 241 | .insert("number_input".to_string(), value.to_string()); | 249 | .insert("cursor".to_string(), cursor.encoded()); |
| 242 | } | 250 | } |
| 243 | } | 251 | } |
| 244 | 252 | ||
| @@ -274,9 +282,6 @@ impl GameManager { | |||
| 274 | session | 282 | session |
| 275 | .data | 283 | .data |
| 276 | .insert("status".to_string(), "result".to_string()); | 284 | .insert("status".to_string(), "result".to_string()); |
| 277 | session | ||
| 278 | .data | ||
| 279 | .insert("number_input".to_string(), String::new()); | ||
| 280 | } | 285 | } |
| 281 | 286 | ||
| 282 | pub fn apply_blackjack_state(&mut self, state: BlackjackState) { | 287 | pub fn apply_blackjack_state(&mut self, state: BlackjackState) { |
diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs index e80eca8..3cb1c58 100644 --- a/src/client/games/roulette.rs +++ b/src/client/games/roulette.rs | |||
| @@ -1,10 +1,178 @@ | |||
| 1 | use super::traits::{Game, GameState}; | 1 | use super::traits::{Game, GameState}; |
| 2 | use crate::roulette::{is_even, is_odd, Bets}; | 2 | use crate::roulette::{is_even, is_odd, BetTarget, Bets, Outside}; |
| 3 | 3 | ||
| 4 | pub const ROULETTE_BOARD_LINES: usize = 8; | 4 | pub const ROULETTE_BOARD_LINES: usize = 12; |
| 5 | |||
| 6 | /// Width of one number cell on the felt. | ||
| 7 | const CELL: usize = 5; | ||
| 8 | const NUM_COLUMNS: usize = 12; | ||
| 9 | const NUM_WIDTH: usize = CELL * NUM_COLUMNS; | ||
| 10 | const THIRD_WIDTH: usize = NUM_WIDTH / 3; | ||
| 11 | const OUTSIDE_WIDTH: usize = NUM_WIDTH / 6; | ||
| 12 | const OUTSIDE_ORDER: [Outside; 6] = [ | ||
| 13 | Outside::Low, | ||
| 14 | Outside::Even, | ||
| 15 | Outside::Red, | ||
| 16 | Outside::Black, | ||
| 17 | Outside::Odd, | ||
| 18 | Outside::High, | ||
| 19 | ]; | ||
| 5 | 20 | ||
| 6 | pub struct RouletteGame; | 21 | pub struct RouletteGame; |
| 7 | 22 | ||
| 23 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 24 | pub enum Direction { | ||
| 25 | Up, | ||
| 26 | Down, | ||
| 27 | Left, | ||
| 28 | Right, | ||
| 29 | } | ||
| 30 | |||
| 31 | /// Where the betting cursor is. | ||
| 32 | /// | ||
| 33 | /// Over the numbers the cursor sits on a *lattice point*, the way a chip sits | ||
| 34 | /// between numbers on a real felt: an inner point covers four numbers (corner), | ||
| 35 | /// an edge point covers two (split), and an outer corner is a straight number. | ||
| 36 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 37 | pub enum Cursor { | ||
| 38 | Zero, | ||
| 39 | Lattice { x: usize, y: usize }, | ||
| 40 | Dozen(usize), | ||
| 41 | Column(usize), | ||
| 42 | Outside(usize), | ||
| 43 | } | ||
| 44 | |||
| 45 | impl Default for Cursor { | ||
| 46 | fn default() -> Self { | ||
| 47 | // Bottom-left number (1). | ||
| 48 | Cursor::Lattice { x: 0, y: 3 } | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | impl Cursor { | ||
| 53 | pub fn parse(raw: &str) -> Self { | ||
| 54 | let parts: Vec<&str> = raw.split(':').collect(); | ||
| 55 | match parts.as_slice() { | ||
| 56 | ["zero"] => Cursor::Zero, | ||
| 57 | ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) { | ||
| 58 | (Ok(x), Ok(y)) if x <= NUM_COLUMNS && y <= 3 => Cursor::Lattice { x, y }, | ||
| 59 | _ => Cursor::default(), | ||
| 60 | }, | ||
| 61 | ["dozen", index] => parse_index(index).map(Cursor::Dozen).unwrap_or_default(), | ||
| 62 | ["column", index] => parse_index(index).map(Cursor::Column).unwrap_or_default(), | ||
| 63 | ["outside", index] => match index.parse::<usize>() { | ||
| 64 | Ok(index) if index < OUTSIDE_ORDER.len() => Cursor::Outside(index), | ||
| 65 | _ => Cursor::default(), | ||
| 66 | }, | ||
| 67 | _ => Cursor::default(), | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | pub fn encoded(&self) -> String { | ||
| 72 | match self { | ||
| 73 | Cursor::Zero => "zero".to_string(), | ||
| 74 | Cursor::Lattice { x, y } => format!("lattice:{x}:{y}"), | ||
| 75 | Cursor::Dozen(index) => format!("dozen:{index}"), | ||
| 76 | Cursor::Column(index) => format!("column:{index}"), | ||
| 77 | Cursor::Outside(index) => format!("outside:{index}"), | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | pub fn target(&self) -> Option<BetTarget> { | ||
| 82 | match self { | ||
| 83 | Cursor::Zero => Some(BetTarget::Straight { number: 0 }), | ||
| 84 | Cursor::Lattice { x, y } => lattice_target(*x, *y), | ||
| 85 | Cursor::Dozen(index) => Some(BetTarget::Dozen { | ||
| 86 | index: *index as u8, | ||
| 87 | }), | ||
| 88 | Cursor::Column(index) => Some(BetTarget::Column { | ||
| 89 | index: *index as u8, | ||
| 90 | }), | ||
| 91 | Cursor::Outside(index) => Some(BetTarget::Outside { | ||
| 92 | kind: OUTSIDE_ORDER[*index], | ||
| 93 | }), | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | /// Numbers the cursor would cover, for highlighting the felt. | ||
| 98 | pub fn numbers(&self) -> Vec<u8> { | ||
| 99 | self.target().map(|target| target.numbers()).unwrap_or_default() | ||
| 100 | } | ||
| 101 | |||
| 102 | pub fn moved(&self, direction: Direction) -> Cursor { | ||
| 103 | match (*self, direction) { | ||
| 104 | (Cursor::Zero, Direction::Down) => Cursor::Lattice { x: 0, y: 0 }, | ||
| 105 | (Cursor::Zero, _) => Cursor::Zero, | ||
| 106 | |||
| 107 | (Cursor::Lattice { x, y }, Direction::Left) => Cursor::Lattice { | ||
| 108 | x: x.saturating_sub(1), | ||
| 109 | y, | ||
| 110 | }, | ||
| 111 | (Cursor::Lattice { x, y }, Direction::Right) => Cursor::Lattice { | ||
| 112 | x: (x + 1).min(NUM_COLUMNS), | ||
| 113 | y, | ||
| 114 | }, | ||
| 115 | (Cursor::Lattice { x: _, y: 0 }, Direction::Up) => Cursor::Zero, | ||
| 116 | (Cursor::Lattice { x, y }, Direction::Up) => Cursor::Lattice { x, y: y - 1 }, | ||
| 117 | (Cursor::Lattice { x, y: 3 }, Direction::Down) => Cursor::Dozen((x / 4).min(2)), | ||
| 118 | (Cursor::Lattice { x, y }, Direction::Down) => Cursor::Lattice { x, y: y + 1 }, | ||
| 119 | |||
| 120 | (Cursor::Dozen(index), Direction::Left) => Cursor::Dozen(index.saturating_sub(1)), | ||
| 121 | (Cursor::Dozen(index), Direction::Right) => Cursor::Dozen((index + 1).min(2)), | ||
| 122 | (Cursor::Dozen(index), Direction::Up) => Cursor::Lattice { | ||
| 123 | x: 4 * index + 1, | ||
| 124 | y: 3, | ||
| 125 | }, | ||
| 126 | (Cursor::Dozen(index), Direction::Down) => Cursor::Column(index), | ||
| 127 | |||
| 128 | (Cursor::Column(index), Direction::Left) => Cursor::Column(index.saturating_sub(1)), | ||
| 129 | (Cursor::Column(index), Direction::Right) => Cursor::Column((index + 1).min(2)), | ||
| 130 | (Cursor::Column(index), Direction::Up) => Cursor::Dozen(index), | ||
| 131 | (Cursor::Column(index), Direction::Down) => Cursor::Outside(index * 2), | ||
| 132 | |||
| 133 | (Cursor::Outside(index), Direction::Left) => { | ||
| 134 | Cursor::Outside(index.saturating_sub(1)) | ||
| 135 | } | ||
| 136 | (Cursor::Outside(index), Direction::Right) => { | ||
| 137 | Cursor::Outside((index + 1).min(OUTSIDE_ORDER.len() - 1)) | ||
| 138 | } | ||
| 139 | (Cursor::Outside(index), Direction::Up) => Cursor::Column(index / 2), | ||
| 140 | (Cursor::Outside(index), Direction::Down) => Cursor::Outside(index), | ||
| 141 | } | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | fn parse_index(raw: &str) -> Option<usize> { | ||
| 146 | raw.parse::<usize>().ok().filter(|index| *index < 3) | ||
| 147 | } | ||
| 148 | |||
| 149 | /// Numbers under a lattice point: 1 (outer corner), 2 (edge) or 4 (inner). | ||
| 150 | fn lattice_target(x: usize, y: usize) -> Option<BetTarget> { | ||
| 151 | let cell = |column: isize, row: isize| -> Option<u8> { | ||
| 152 | if !(0..NUM_COLUMNS as isize).contains(&column) || !(0..3isize).contains(&row) { | ||
| 153 | return None; | ||
| 154 | } | ||
| 155 | Some((3 * column + (3 - row)) as u8) | ||
| 156 | }; | ||
| 157 | |||
| 158 | let x = x as isize; | ||
| 159 | let y = y as isize; | ||
| 160 | let mut numbers: Vec<u8> = [(x - 1, y - 1), (x, y - 1), (x - 1, y), (x, y)] | ||
| 161 | .into_iter() | ||
| 162 | .filter_map(|(column, row)| cell(column, row)) | ||
| 163 | .collect(); | ||
| 164 | numbers.sort_unstable(); | ||
| 165 | |||
| 166 | match numbers.as_slice() { | ||
| 167 | [number] => Some(BetTarget::Straight { number: *number }), | ||
| 168 | [a, b] => Some(BetTarget::Split { a: *a, b: *b }), | ||
| 169 | [a, b, c, d] => Some(BetTarget::Corner { | ||
| 170 | numbers: [*a, *b, *c, *d], | ||
| 171 | }), | ||
| 172 | _ => None, | ||
| 173 | } | ||
| 174 | } | ||
| 175 | |||
| 8 | impl RouletteGame { | 176 | impl RouletteGame { |
| 9 | pub fn new() -> Self { | 177 | pub fn new() -> Self { |
| 10 | Self | 178 | Self |
| @@ -14,40 +182,99 @@ impl RouletteGame { | |||
| 14 | state | 182 | state |
| 15 | .data | 183 | .data |
| 16 | .get("bets") | 184 | .get("bets") |
| 17 | .and_then(|raw| serde_json::from_str(raw).ok()) | 185 | .and_then(|raw| Bets::from_json(raw).ok()) |
| 18 | .unwrap_or_default() | 186 | .unwrap_or_default() |
| 19 | } | 187 | } |
| 20 | 188 | ||
| 21 | fn bets_line(bets: &Bets) -> String { | 189 | fn cursor(state: &GameState) -> Cursor { |
| 22 | let mut parts = Vec::new(); | 190 | state |
| 23 | if bets.red > 0 { | 191 | .data |
| 24 | parts.push(format!("RED {}", bets.red)); | 192 | .get("cursor") |
| 25 | } | 193 | .map(|raw| Cursor::parse(raw)) |
| 26 | if bets.black > 0 { | 194 | .unwrap_or_default() |
| 27 | parts.push(format!("BLACK {}", bets.black)); | 195 | } |
| 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 | 196 | ||
| 39 | let list = if parts.is_empty() { | 197 | fn zero_cell(cursor: Cursor) -> String { |
| 40 | "(none)".to_string() | 198 | let cell = if cursor == Cursor::Zero { |
| 199 | "[ 0 ]".to_string() | ||
| 41 | } else { | 200 | } else { |
| 42 | parts.join(" ") | 201 | format!("{:^CELL$}", 0) |
| 43 | }; | 202 | }; |
| 44 | format!("Bets: {} Total: {} PLN", list, bets.total()) | 203 | format!("{cell:<NUM_WIDTH$}") |
| 204 | } | ||
| 205 | |||
| 206 | fn number_row(row: usize, covered: &[u8]) -> String { | ||
| 207 | (0..NUM_COLUMNS) | ||
| 208 | .map(|column| { | ||
| 209 | let number = (3 * column + (3 - row)) as u8; | ||
| 210 | if covered.contains(&number) { | ||
| 211 | format!("[{number:^3}]") | ||
| 212 | } else { | ||
| 213 | format!("{number:^CELL$}") | ||
| 214 | } | ||
| 215 | }) | ||
| 216 | .collect() | ||
| 217 | } | ||
| 218 | |||
| 219 | fn wide_cell(label: &str, width: usize, active: bool) -> String { | ||
| 220 | if active { | ||
| 221 | format!("[{:^inner$}]", label, inner = width - 2) | ||
| 222 | } else { | ||
| 223 | format!("{label:^width$}") | ||
| 224 | } | ||
| 225 | } | ||
| 226 | |||
| 227 | fn dozens_row(cursor: Cursor) -> String { | ||
| 228 | ["1st 12", "2nd 12", "3rd 12"] | ||
| 229 | .iter() | ||
| 230 | .enumerate() | ||
| 231 | .map(|(index, label)| { | ||
| 232 | Self::wide_cell(label, THIRD_WIDTH, cursor == Cursor::Dozen(index)) | ||
| 233 | }) | ||
| 234 | .collect() | ||
| 235 | } | ||
| 236 | |||
| 237 | fn columns_row(cursor: Cursor) -> String { | ||
| 238 | ["col 1 2:1", "col 2 2:1", "col 3 2:1"] | ||
| 239 | .iter() | ||
| 240 | .enumerate() | ||
| 241 | .map(|(index, label)| { | ||
| 242 | Self::wide_cell(label, THIRD_WIDTH, cursor == Cursor::Column(index)) | ||
| 243 | }) | ||
| 244 | .collect() | ||
| 245 | } | ||
| 246 | |||
| 247 | fn outside_row(cursor: Cursor) -> String { | ||
| 248 | OUTSIDE_ORDER | ||
| 249 | .iter() | ||
| 250 | .enumerate() | ||
| 251 | .map(|(index, kind)| { | ||
| 252 | Self::wide_cell(kind.label(), OUTSIDE_WIDTH, cursor == Cursor::Outside(index)) | ||
| 253 | }) | ||
| 254 | .collect() | ||
| 45 | } | 255 | } |
| 46 | 256 | ||
| 47 | fn result_lines(state: &GameState) -> (String, String) { | 257 | fn bets_line(bets: &Bets) -> String { |
| 258 | let mut parts: Vec<String> = bets | ||
| 259 | .chips | ||
| 260 | .iter() | ||
| 261 | .filter(|chip| chip.amount > 0) | ||
| 262 | .map(|chip| format!("{} {}", chip.target.label(), chip.amount)) | ||
| 263 | .collect(); | ||
| 264 | if parts.is_empty() { | ||
| 265 | parts.push("(none)".to_string()); | ||
| 266 | } | ||
| 267 | let mut list = parts.join(" "); | ||
| 268 | if list.chars().count() > 40 { | ||
| 269 | list = list.chars().take(39).collect::<String>() + "…"; | ||
| 270 | } | ||
| 271 | format!("Bets: {list} Total: {} PLN", bets.total()) | ||
| 272 | } | ||
| 273 | |||
| 274 | fn status_line(state: &GameState) -> String { | ||
| 48 | if let Some(error) = state.data.get("error") { | 275 | if let Some(error) = state.data.get("error") { |
| 49 | if !error.is_empty() { | 276 | if !error.is_empty() { |
| 50 | return (format!("ERROR: {error}"), String::new()); | 277 | return format!("ERROR: {error}"); |
| 51 | } | 278 | } |
| 52 | } | 279 | } |
| 53 | 280 | ||
| @@ -77,15 +304,16 @@ impl RouletteGame { | |||
| 77 | .get("winnings") | 304 | .get("winnings") |
| 78 | .map(String::as_str) | 305 | .map(String::as_str) |
| 79 | .unwrap_or("0"); | 306 | .unwrap_or("0"); |
| 80 | let result = if winnings == "0" { | 307 | let stake = state.bet_amount; |
| 81 | "No win.".to_string() | 308 | if winnings == "0" { |
| 309 | format!("No win at {number} {color}{parity}") | ||
| 82 | } else { | 310 | } else { |
| 83 | format!("Won {winnings} PLN") | 311 | let profit = winnings.parse::<u64>().unwrap_or(0).saturating_sub(stake); |
| 84 | }; | 312 | format!("Won {winnings} PLN at {number} {color}{parity} (+{profit})") |
| 85 | (format!("Winning: {number} {color}{parity}"), result) | 313 | } |
| 86 | } | 314 | } |
| 87 | Some("spinning") => ("Spinning...".to_string(), String::new()), | 315 | Some("spinning") => "Spinning...".to_string(), |
| 88 | _ => ("Place bets, then SPACE to spin.".to_string(), String::new()), | 316 | _ => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), |
| 89 | } | 317 | } |
| 90 | } | 318 | } |
| 91 | } | 319 | } |
| @@ -99,27 +327,25 @@ impl Game for RouletteGame { | |||
| 99 | let bets = Self::bets(state); | 327 | let bets = Self::bets(state); |
| 100 | let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); | 328 | let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); |
| 101 | let balance = state.data.get("balance").map(String::as_str).unwrap_or("?"); | 329 | let balance = state.data.get("balance").map(String::as_str).unwrap_or("?"); |
| 102 | let input = state | 330 | let cursor = Self::cursor(state); |
| 103 | .data | 331 | let covered = cursor.numbers(); |
| 104 | .get("number_input") | ||
| 105 | .map(String::as_str) | ||
| 106 | .unwrap_or(""); | ||
| 107 | let input_line = if input.is_empty() { | ||
| 108 | String::new() | ||
| 109 | } else { | ||
| 110 | format!("Number: {input}_") | ||
| 111 | }; | ||
| 112 | let (status, result) = Self::result_lines(state); | ||
| 113 | 332 | ||
| 114 | format!( | 333 | let lines = [ |
| 115 | "=== 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", | 334 | "=== ROULETTE ===".to_string(), |
| 116 | balance, | 335 | format!("Balance: {balance} PLN Chip: {chip} PLN"), |
| 117 | chip, | 336 | Self::zero_cell(cursor), |
| 337 | Self::number_row(0, &covered), | ||
| 338 | Self::number_row(1, &covered), | ||
| 339 | Self::number_row(2, &covered), | ||
| 340 | Self::dozens_row(cursor), | ||
| 341 | Self::columns_row(cursor), | ||
| 342 | Self::outside_row(cursor), | ||
| 118 | Self::bets_line(&bets), | 343 | Self::bets_line(&bets), |
| 119 | input_line, | 344 | Self::status_line(state), |
| 120 | status, | 345 | "[ARROWS] Move [ENTER] Bet [C]lear [+/-] Chip [SPACE] Spin [Q] Quit" |
| 121 | result | 346 | .to_string(), |
| 122 | ) | 347 | ]; |
| 348 | lines.join("\r\n") | ||
| 123 | } | 349 | } |
| 124 | 350 | ||
| 125 | fn render_lines(&self, _state: &GameState) -> usize { | 351 | fn render_lines(&self, _state: &GameState) -> usize { |
| @@ -129,20 +355,20 @@ impl Game for RouletteGame { | |||
| 129 | fn get_help(&self) -> String { | 355 | fn get_help(&self) -> String { |
| 130 | "ROULETTE (European)\n\ | 356 | "ROULETTE (European)\n\ |
| 131 | ==================\n\ | 357 | ==================\n\ |
| 132 | 37 pockets: 0 plus 1-36. 0 is green and loses all outside bets.\n\ | 358 | 37 pockets: 0 plus 1-36. 0 is green and loses every outside bet.\n\ |
| 133 | \n\ | 359 | \n\ |
| 134 | Betting:\n\ | 360 | Betting with the cursor:\n\ |
| 135 | R / B / E / O - add the current chip to red/black/even/odd\n\ | 361 | arrows - move over the felt; between numbers picks a split or corner\n\ |
| 136 | digits + Enter - add the current chip to that number\n\ | 362 | ENTER - place the current chip where the cursor is\n\ |
| 137 | C - clear all bets\n\ | 363 | C - clear all bets\n\ |
| 138 | + or = / - - change the chip size\n\ | 364 | + or = / - - change the chip size\n\ |
| 139 | SPACE - spin when at least one bet is placed\n\ | 365 | SPACE - spin when at least one bet is placed\n\ |
| 140 | Q - exit\n\ | 366 | Q - exit\n\ |
| 141 | \n\ | 367 | \n\ |
| 142 | Payouts:\n\ | 368 | Payouts (returned per unit staked):\n\ |
| 143 | straight number: 35:1 (36x returned)\n\ | 369 | straight 36x, split 18x, corner 9x\n\ |
| 144 | red/black: 1:1 (2x returned)\n\ | 370 | dozen 3x, column 3x\n\ |
| 145 | even/odd: 1:1 (2x returned)\n\ | 371 | red/black, even/odd, 1-18/19-36: 2x\n\ |
| 146 | Multiple bets are settled independently and added together." | 372 | Multiple bets are settled independently and added together." |
| 147 | .to_string() | 373 | .to_string() |
| 148 | } | 374 | } |
diff --git a/src/client/ui.rs b/src/client/ui.rs index 9d65087..6161166 100644 --- a/src/client/ui.rs +++ b/src/client/ui.rs | |||
| @@ -244,6 +244,23 @@ pub fn first_contact_lines(addr: &str, fingerprint: &str) -> Vec<String> { | |||
| 244 | 244 | ||
| 245 | /// Format remembered hosts so a 64-character fingerprint does not wrap on an | 245 | /// Format remembered hosts so a 64-character fingerprint does not wrap on an |
| 246 | /// 80-column terminal. | 246 | /// 80-column terminal. |
| 247 | /// Smallest terminal every game fits in. Roulette's felt is 12 lines and the | ||
| 248 | /// controls are ~72 columns, so anything smaller would wrap or hide the board. | ||
| 249 | pub const MIN_COLUMNS: u16 = 80; | ||
| 250 | pub const MIN_ROWS: u16 = 24; | ||
| 251 | |||
| 252 | /// Refuse to start when the terminal cannot show every game. | ||
| 253 | pub fn terminal_too_small(columns: u16, rows: u16) -> Option<String> { | ||
| 254 | if columns >= MIN_COLUMNS && rows >= MIN_ROWS { | ||
| 255 | return None; | ||
| 256 | } | ||
| 257 | Some(format!( | ||
| 258 | "zagan.bet needs a terminal of at least {MIN_COLUMNS}x{MIN_ROWS} cells, \ | ||
| 259 | but this one is {columns}x{rows}.\n\ | ||
| 260 | Resize it and start again." | ||
| 261 | )) | ||
| 262 | } | ||
| 263 | |||
| 247 | /// Render remembered hosts. | 264 | /// Render remembered hosts. |
| 248 | /// | 265 | /// |
| 249 | /// Two addresses that present the same certificate key are the same server, so | 266 | /// Two addresses that present the same certificate key are the same server, so |
diff --git a/src/roulette.rs b/src/roulette.rs index 426ecc3..e7b3aad 100644 --- a/src/roulette.rs +++ b/src/roulette.rs | |||
| @@ -14,6 +14,12 @@ pub const RED_NUMBERS: [u8; 18] = [ | |||
| 14 | 14 | ||
| 15 | /// Total return on a winning straight (35:1 plus the stake). | 15 | /// Total return on a winning straight (35:1 plus the stake). |
| 16 | pub const STRAIGHT_RETURN: u64 = 36; | 16 | pub const STRAIGHT_RETURN: u64 = 36; |
| 17 | /// Total return on a winning split (17:1 plus the stake). | ||
| 18 | pub const SPLIT_RETURN: u64 = 18; | ||
| 19 | /// Total return on a winning corner (8:1 plus the stake). | ||
| 20 | pub const CORNER_RETURN: u64 = 9; | ||
| 21 | /// Total return on a winning dozen or column (2:1 plus the stake). | ||
| 22 | pub const THIRD_RETURN: u64 = 3; | ||
| 17 | /// Total return on a winning outside bet (1:1 plus the stake). | 23 | /// Total return on a winning outside bet (1:1 plus the stake). |
| 18 | pub const EVEN_MONEY_RETURN: u64 = 2; | 24 | pub const EVEN_MONEY_RETURN: u64 = 2; |
| 19 | 25 | ||
| @@ -47,67 +53,176 @@ pub fn rtp() -> f64 { | |||
| 47 | 36.0 / 37.0 | 53 | 36.0 / 37.0 |
| 48 | } | 54 | } |
| 49 | 55 | ||
| 56 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | ||
| 57 | #[serde(rename_all = "snake_case")] | ||
| 58 | pub enum Outside { | ||
| 59 | Low, | ||
| 60 | High, | ||
| 61 | Even, | ||
| 62 | Odd, | ||
| 63 | Red, | ||
| 64 | Black, | ||
| 65 | } | ||
| 66 | |||
| 67 | impl Outside { | ||
| 68 | pub fn wins(self, number: u8) -> bool { | ||
| 69 | match self { | ||
| 70 | Outside::Low => (1..=18).contains(&number), | ||
| 71 | Outside::High => (19..=36).contains(&number), | ||
| 72 | Outside::Even => is_even(number), | ||
| 73 | Outside::Odd => is_odd(number), | ||
| 74 | Outside::Red => is_red(number), | ||
| 75 | Outside::Black => is_black(number), | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | pub fn label(self) -> &'static str { | ||
| 80 | match self { | ||
| 81 | Outside::Low => "1-18", | ||
| 82 | Outside::High => "19-36", | ||
| 83 | Outside::Even => "EVEN", | ||
| 84 | Outside::Odd => "ODD", | ||
| 85 | Outside::Red => "RED", | ||
| 86 | Outside::Black => "BLACK", | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | /// One place on the felt where a chip can sit. | ||
| 92 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | ||
| 93 | #[serde(tag = "bet", rename_all = "snake_case")] | ||
| 94 | pub enum BetTarget { | ||
| 95 | Straight { number: u8 }, | ||
| 96 | Split { a: u8, b: u8 }, | ||
| 97 | Corner { numbers: [u8; 4] }, | ||
| 98 | /// 0 = 1-12, 1 = 13-24, 2 = 25-36. | ||
| 99 | Dozen { index: u8 }, | ||
| 100 | /// 0 = 1,4,...,34; 1 = 2,5,...,35; 2 = 3,6,...,36. | ||
| 101 | Column { index: u8 }, | ||
| 102 | Outside { kind: Outside }, | ||
| 103 | } | ||
| 104 | |||
| 105 | impl BetTarget { | ||
| 106 | /// Numbers this target covers on the number grid (empty for outside bets). | ||
| 107 | pub fn numbers(&self) -> Vec<u8> { | ||
| 108 | match self { | ||
| 109 | BetTarget::Straight { number } => vec![*number], | ||
| 110 | BetTarget::Split { a, b } => vec![*a, *b], | ||
| 111 | BetTarget::Corner { numbers } => numbers.to_vec(), | ||
| 112 | _ => Vec::new(), | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | /// Total returned per unit staked, or 0 when the bet loses. | ||
| 117 | pub fn return_multiplier(&self, number: u8) -> u64 { | ||
| 118 | match self { | ||
| 119 | BetTarget::Straight { number: straight } => { | ||
| 120 | u64::from(*straight == number) * STRAIGHT_RETURN | ||
| 121 | } | ||
| 122 | BetTarget::Split { a, b } => u64::from(*a == number || *b == number) * SPLIT_RETURN, | ||
| 123 | BetTarget::Corner { numbers } => { | ||
| 124 | u64::from(numbers.contains(&number)) * CORNER_RETURN | ||
| 125 | } | ||
| 126 | BetTarget::Dozen { index } => { | ||
| 127 | let wins = number != 0 && (number - 1) / 12 == *index; | ||
| 128 | u64::from(wins) * THIRD_RETURN | ||
| 129 | } | ||
| 130 | BetTarget::Column { index } => { | ||
| 131 | let wins = match index { | ||
| 132 | 0 => number % 3 == 1, | ||
| 133 | 1 => number % 3 == 2, | ||
| 134 | 2 => number != 0 && number % 3 == 0, | ||
| 135 | _ => false, | ||
| 136 | }; | ||
| 137 | u64::from(wins) * THIRD_RETURN | ||
| 138 | } | ||
| 139 | BetTarget::Outside { kind } => u64::from(kind.wins(number)) * EVEN_MONEY_RETURN, | ||
| 140 | } | ||
| 141 | } | ||
| 142 | |||
| 143 | pub fn label(&self) -> String { | ||
| 144 | match self { | ||
| 145 | BetTarget::Straight { number } => number.to_string(), | ||
| 146 | BetTarget::Split { a, b } => format!("{a}/{b}"), | ||
| 147 | BetTarget::Corner { numbers } => numbers | ||
| 148 | .iter() | ||
| 149 | .map(u8::to_string) | ||
| 150 | .collect::<Vec<_>>() | ||
| 151 | .join("/"), | ||
| 152 | BetTarget::Dozen { index } => match index { | ||
| 153 | 0 => "1st12".to_string(), | ||
| 154 | 1 => "2nd12".to_string(), | ||
| 155 | _ => "3rd12".to_string(), | ||
| 156 | }, | ||
| 157 | BetTarget::Column { index } => format!("col{}", index + 1), | ||
| 158 | BetTarget::Outside { kind } => kind.label().to_string(), | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | fn validates(&self) -> bool { | ||
| 163 | match self { | ||
| 164 | BetTarget::Straight { number } => *number <= 36, | ||
| 165 | BetTarget::Split { a, b } => *a <= 36 && *b <= 36 && a != b, | ||
| 166 | BetTarget::Corner { numbers } => { | ||
| 167 | numbers.iter().all(|number| *number <= 36) | ||
| 168 | && numbers | ||
| 169 | .iter() | ||
| 170 | .enumerate() | ||
| 171 | .all(|(index, number)| !numbers[..index].contains(number)) | ||
| 172 | } | ||
| 173 | BetTarget::Dozen { index } | BetTarget::Column { index } => *index < 3, | ||
| 174 | BetTarget::Outside { .. } => true, | ||
| 175 | } | ||
| 176 | } | ||
| 177 | } | ||
| 178 | |||
| 50 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | 179 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 51 | pub struct NumberBet { | 180 | pub struct Chip { |
| 52 | pub number: u8, | 181 | pub target: BetTarget, |
| 53 | pub amount: u64, | 182 | pub amount: u64, |
| 54 | } | 183 | } |
| 55 | 184 | ||
| 56 | #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] | 185 | #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 57 | pub struct Bets { | 186 | pub struct Bets { |
| 58 | #[serde(default)] | 187 | #[serde(default)] |
| 59 | pub red: u64, | 188 | pub chips: Vec<Chip>, |
| 60 | #[serde(default)] | ||
| 61 | pub black: u64, | ||
| 62 | #[serde(default)] | ||
| 63 | pub even: u64, | ||
| 64 | #[serde(default)] | ||
| 65 | pub odd: u64, | ||
| 66 | #[serde(default)] | ||
| 67 | pub numbers: Vec<NumberBet>, | ||
| 68 | } | 189 | } |
| 69 | 190 | ||
| 70 | impl Bets { | 191 | impl Bets { |
| 71 | pub fn total(&self) -> u64 { | 192 | pub fn total(&self) -> u64 { |
| 72 | let mut total = self | 193 | self.chips |
| 73 | .red | 194 | .iter() |
| 74 | .saturating_add(self.black) | 195 | .fold(0u64, |total, chip| total.saturating_add(chip.amount)) |
| 75 | .saturating_add(self.even) | ||
| 76 | .saturating_add(self.odd); | ||
| 77 | for bet in &self.numbers { | ||
| 78 | total = total.saturating_add(bet.amount); | ||
| 79 | } | ||
| 80 | total | ||
| 81 | } | 196 | } |
| 82 | 197 | ||
| 83 | pub fn is_empty(&self) -> bool { | 198 | pub fn is_empty(&self) -> bool { |
| 84 | self.total() == 0 | 199 | self.chips.iter().all(|chip| chip.amount == 0) |
| 85 | } | 200 | } |
| 86 | 201 | ||
| 87 | /// Total returned for a winning number, including returned stakes. | 202 | pub fn amount_on(&self, target: &BetTarget) -> u64 { |
| 88 | pub fn winnings(&self, number: u8) -> u64 { | 203 | self.chips |
| 89 | let mut winnings = 0u64; | 204 | .iter() |
| 90 | 205 | .filter(|chip| &chip.target == target) | |
| 91 | if number != 0 { | 206 | .fold(0u64, |total, chip| total.saturating_add(chip.amount)) |
| 92 | if is_red(number) { | 207 | } |
| 93 | winnings = winnings.saturating_add(self.red.saturating_mul(EVEN_MONEY_RETURN)); | ||
| 94 | } else { | ||
| 95 | winnings = winnings.saturating_add(self.black.saturating_mul(EVEN_MONEY_RETURN)); | ||
| 96 | } | ||
| 97 | if is_even(number) { | ||
| 98 | winnings = winnings.saturating_add(self.even.saturating_mul(EVEN_MONEY_RETURN)); | ||
| 99 | } else { | ||
| 100 | winnings = winnings.saturating_add(self.odd.saturating_mul(EVEN_MONEY_RETURN)); | ||
| 101 | } | ||
| 102 | } | ||
| 103 | 208 | ||
| 104 | for bet in &self.numbers { | 209 | /// Put a chip on a target, merging with an existing one. |
| 105 | if bet.number == number { | 210 | pub fn add(&mut self, target: BetTarget, amount: u64) { |
| 106 | winnings = winnings.saturating_add(bet.amount.saturating_mul(STRAIGHT_RETURN)); | 211 | if let Some(chip) = self.chips.iter_mut().find(|chip| chip.target == target) { |
| 107 | } | 212 | chip.amount = chip.amount.saturating_add(amount); |
| 213 | } else if amount > 0 { | ||
| 214 | self.chips.push(Chip { target, amount }); | ||
| 108 | } | 215 | } |
| 216 | } | ||
| 109 | 217 | ||
| 110 | winnings | 218 | /// Total returned for a winning number, including returned stakes. |
| 219 | pub fn winnings(&self, number: u8) -> u64 { | ||
| 220 | self.chips.iter().fold(0u64, |total, chip| { | ||
| 221 | total.saturating_add( | ||
| 222 | chip.amount | ||
| 223 | .saturating_mul(chip.target.return_multiplier(number)), | ||
| 224 | ) | ||
| 225 | }) | ||
| 111 | } | 226 | } |
| 112 | 227 | ||
| 113 | pub fn validate(&self, balance: u64) -> Result<(), String> { | 228 | pub fn validate(&self, balance: u64) -> Result<(), String> { |
| @@ -117,14 +232,22 @@ impl Bets { | |||
| 117 | if self.total() > balance { | 232 | if self.total() > balance { |
| 118 | return Err("Insufficient balance".to_string()); | 233 | return Err("Insufficient balance".to_string()); |
| 119 | } | 234 | } |
| 120 | for bet in &self.numbers { | 235 | for chip in &self.chips { |
| 121 | if bet.number > 36 { | 236 | if !chip.target.validates() { |
| 122 | return Err(format!("Invalid number: {}", bet.number)); | 237 | return Err(format!("Invalid bet: {}", chip.target.label())); |
| 123 | } | 238 | } |
| 124 | if bet.amount == 0 { | 239 | if chip.amount == 0 { |
| 125 | return Err("A bet amount must be positive".to_string()); | 240 | return Err("A bet amount must be positive".to_string()); |
| 126 | } | 241 | } |
| 127 | } | 242 | } |
| 128 | Ok(()) | 243 | Ok(()) |
| 129 | } | 244 | } |
| 245 | |||
| 246 | pub fn to_json(&self) -> String { | ||
| 247 | serde_json::to_string(self).unwrap_or_else(|_| "{\"chips\":[]}".to_string()) | ||
| 248 | } | ||
| 249 | |||
| 250 | pub fn from_json(raw: &str) -> Result<Self, serde_json::Error> { | ||
| 251 | serde_json::from_str(raw) | ||
| 252 | } | ||
| 130 | } | 253 | } |
diff --git a/src/server/database.rs b/src/server/database.rs index 3cd86e4..6dd71a3 100644 --- a/src/server/database.rs +++ b/src/server/database.rs | |||
| @@ -11,12 +11,6 @@ pub fn init_db(conn: &Connection) { | |||
| 11 | [], | 11 | [], |
| 12 | ) | 12 | ) |
| 13 | .unwrap(); | 13 | .unwrap(); |
| 14 | // Add column if not exists (for migration) | ||
| 15 | let _ = conn.execute("ALTER TABLE users ADD COLUMN account_id INTEGER UNIQUE", []); | ||
| 16 | let _ = conn.execute( | ||
| 17 | "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000", | ||
| 18 | [], | ||
| 19 | ); | ||
| 20 | conn.execute( | 14 | conn.execute( |
| 21 | "CREATE TABLE IF NOT EXISTS plays ( | 15 | "CREATE TABLE IF NOT EXISTS plays ( |
| 22 | session_id TEXT PRIMARY KEY, | 16 | session_id TEXT PRIMARY KEY, |
diff --git a/src/server/games/roulette.rs b/src/server/games/roulette.rs index 0a58323..de47dee 100644 --- a/src/server/games/roulette.rs +++ b/src/server/games/roulette.rs | |||
| @@ -36,7 +36,7 @@ impl Game for RouletteGame { | |||
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | fn get_description(&self) -> &'static str { | 38 | fn get_description(&self) -> &'static str { |
| 39 | "European roulette. Bet on red/black, even/odd, or a straight number." | 39 | "European roulette. Straights, splits, corners, dozens, columns and outside bets." |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState { | 42 | fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState { |
