From 49df948de25d28601469e9ca993f33530989ebd0 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 29 Oct 2026 22:55:49 +0100 Subject: v3.0.0: cursor-driven roulette felt with splits and corners; drop schema migrations; require 80x24 --- src/bin/client/connection.rs | 2 +- src/bin/client/main.rs | 15 ++ src/bin/client/repl.rs | 2 + src/client/core.rs | 102 +++---------- src/client/database.rs | 41 ----- src/client/games/manager.rs | 17 ++- src/client/games/roulette.rs | 346 +++++++++++++++++++++++++++++++++++-------- src/client/ui.rs | 17 +++ src/roulette.rs | 215 +++++++++++++++++++++------ src/server/database.rs | 6 - src/server/games/roulette.rs | 2 +- 11 files changed, 526 insertions(+), 239 deletions(-) (limited to 'src') 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( Ok(Ok((tls, fp))) => (tls, fp), Ok(Err(e)) => return Err(Box::new(TrustError(format!("{e}")))), Err(mpsc::RecvTimeoutError::Timeout) => { - return Err("Connection timed out after 3 seconds".into()); + return Err("Connection timed out after 10 seconds".into()); } Err(_) => return Err("Connection attempt failed".into()), }; 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() { let args: Vec = std::env::args().collect(); let unsafe_mode = args.iter().any(|arg| arg == "--unsafe"); + // Every game board is drawn in place, so refuse a terminal that cannot fit + // them before touching the network or the terminal mode. + let size = crossterm::terminal::size(); + let (columns, rows) = match &size { + Ok((columns, rows)) => (*columns, *rows), + Err(_) => (0, 0), + }; + if let Some(message) = zagan_bet::client::ui::terminal_too_small(columns, rows) { + eprintln!("{message}"); + if size.is_err() { + eprintln!("Could not read the terminal size (is this a real terminal?)."); + } + std::process::exit(1); + } + if unsafe_mode { println!("WARNING: Running in UNSAFE mode - certificate verification disabled!"); } 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 { KeyCode::Backspace => Some(Key::Backspace), KeyCode::Up => Some(Key::Up), KeyCode::Down => Some(Key::Down), + KeyCode::Left => Some(Key::Left), + KeyCode::Right => Some(Key::Right), KeyCode::Tab => Some(Key::Tab), KeyCode::Esc => Some(Key::Esc), _ => 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::{ record_history, }; use crate::crash::{multiplier_at, CrashState}; -use crate::roulette::{Bets, NumberBet}; +use crate::client::games::roulette::Direction; +use crate::roulette::Bets; use std::time::{Duration, Instant}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -17,6 +18,8 @@ pub enum Key { Backspace, Up, Down, + Left, + Right, Tab, Esc, CtrlC, @@ -76,14 +79,6 @@ enum Pending { Info, } -#[derive(Debug, Clone, Copy)] -enum RouletteSide { - Red, - Black, - Even, - Odd, -} - pub struct ClientCore { input: String, history: Vec, @@ -171,6 +166,7 @@ impl ClientCore { } Key::Up => self.history_previous(), Key::Down => self.history_next(), + Key::Left | Key::Right => Vec::new(), Key::Tab => self.complete(), Key::Esc => Vec::new(), } @@ -355,6 +351,19 @@ impl ClientCore { } fn handle_roulette_key(&mut self, key: Key) -> Vec { + let direction = match key { + Key::Up => Some(Direction::Up), + Key::Down => Some(Direction::Down), + Key::Left => Some(Direction::Left), + Key::Right => Some(Direction::Right), + _ => None, + }; + if let Some(direction) = direction { + let cursor = self.game.roulette_cursor().moved(direction); + self.game.set_roulette_cursor(&cursor); + return self.board_effects(); + } + match key { Key::CtrlC => vec![Effect::Quit], Key::Char('q') => { @@ -364,18 +373,9 @@ impl ClientCore { 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() } @@ -389,63 +389,17 @@ impl ClientCore { 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() - } + Key::Enter => self.place_roulette_chip(), _ => 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(); + fn place_roulette_chip(&mut self) -> Vec { + let Some(target) = self.game.roulette_cursor().target() else { + return Vec::new(); + }; 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, - }); - } + bets.add(target, self.game.roulette_chip()); self.place_roulette_bets(bets) } @@ -487,14 +441,6 @@ impl ClientCore { effects } - fn roulette_input(&self) -> String { - self.game - .get_current_state() - .and_then(|state| state.data.get("number_input")) - .cloned() - .unwrap_or_default() - } - fn handle_blackjack_key(&mut self, key: Key) -> Vec { let status = self .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) { [], ) .unwrap(); - // Migration for databases created before TOFU trust. - let _ = conn.execute("ALTER TABLE hosts ADD COLUMN fingerprint TEXT", []); // Credentials belong to a server identity (the certificate key), not to a // single address, so every address that presents the same key shares them. - if has_column(conn, "credentials", "host") { - migrate_credentials_to_fingerprints(conn); - } conn.execute( "CREATE TABLE IF NOT EXISTS credentials ( fingerprint TEXT PRIMARY KEY, @@ -39,42 +34,6 @@ pub fn init_db(conn: &Connection) { .unwrap(); } -fn has_column(conn: &Connection, table: &str, column: &str) -> bool { - let Ok(mut statement) = conn.prepare(&format!("PRAGMA table_info({table})")) else { - return false; - }; - let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(1)) else { - return false; - }; - rows.flatten().any(|name| name == column) -} - -/// Re-key host-bound credentials by the fingerprint of their host. -/// -/// Rows whose host was already forgotten have no identity to attach to and are -/// dropped; those addresses have to re-authenticate once. -fn migrate_credentials_to_fingerprints(conn: &Connection) { - let tx = conn.unchecked_transaction().unwrap(); - tx.execute_batch( - "DROP TABLE IF EXISTS credentials_by_identity; - CREATE TABLE credentials_by_identity ( - fingerprint TEXT PRIMARY KEY, - username TEXT NOT NULL, - password TEXT NOT NULL - ); - INSERT OR REPLACE INTO credentials_by_identity (fingerprint, username, password) - SELECT h.fingerprint, c.username, c.password - FROM credentials c - JOIN hosts h ON h.host = c.host - WHERE h.fingerprint IS NOT NULL - ORDER BY h.last_used ASC, h.host ASC; - DROP TABLE credentials; - ALTER TABLE credentials_by_identity RENAME TO credentials;", - ) - .unwrap(); - tx.commit().unwrap(); -} - pub fn get_last_host(conn: &Connection) -> Option { let mut stmt = conn .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 { ); data.insert("chip".to_string(), "10".to_string()); data.insert("status".to_string(), "place".to_string()); - data.insert("number_input".to_string(), String::new()); + data.insert("cursor".to_string(), "lattice:0:3".to_string()); 0 } else if game_type == "blackjack" { data.insert("status".to_string(), "ready".to_string()); @@ -234,11 +234,19 @@ impl GameManager { } } - pub fn set_roulette_number_input(&mut self, value: &str) { + pub fn roulette_cursor(&self) -> crate::client::games::roulette::Cursor { + self.current_session + .as_ref() + .and_then(|session| session.data.get("cursor")) + .map(|raw| crate::client::games::roulette::Cursor::parse(raw)) + .unwrap_or_default() + } + + pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) { if let Some(session) = self.current_session.as_mut() { session .data - .insert("number_input".to_string(), value.to_string()); + .insert("cursor".to_string(), cursor.encoded()); } } @@ -274,9 +282,6 @@ impl GameManager { session .data .insert("status".to_string(), "result".to_string()); - session - .data - .insert("number_input".to_string(), String::new()); } 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 @@ use super::traits::{Game, GameState}; -use crate::roulette::{is_even, is_odd, Bets}; +use crate::roulette::{is_even, is_odd, BetTarget, Bets, Outside}; -pub const ROULETTE_BOARD_LINES: usize = 8; +pub const ROULETTE_BOARD_LINES: usize = 12; + +/// Width of one number cell on the felt. +const CELL: usize = 5; +const NUM_COLUMNS: usize = 12; +const NUM_WIDTH: usize = CELL * NUM_COLUMNS; +const THIRD_WIDTH: usize = NUM_WIDTH / 3; +const OUTSIDE_WIDTH: usize = NUM_WIDTH / 6; +const OUTSIDE_ORDER: [Outside; 6] = [ + Outside::Low, + Outside::Even, + Outside::Red, + Outside::Black, + Outside::Odd, + Outside::High, +]; pub struct RouletteGame; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Up, + Down, + Left, + Right, +} + +/// Where the betting cursor is. +/// +/// Over the numbers the cursor sits on a *lattice point*, the way a chip sits +/// between numbers on a real felt: an inner point covers four numbers (corner), +/// an edge point covers two (split), and an outer corner is a straight number. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Cursor { + Zero, + Lattice { x: usize, y: usize }, + Dozen(usize), + Column(usize), + Outside(usize), +} + +impl Default for Cursor { + fn default() -> Self { + // Bottom-left number (1). + Cursor::Lattice { x: 0, y: 3 } + } +} + +impl Cursor { + pub fn parse(raw: &str) -> Self { + let parts: Vec<&str> = raw.split(':').collect(); + match parts.as_slice() { + ["zero"] => Cursor::Zero, + ["lattice", x, y] => match (x.parse::(), y.parse::()) { + (Ok(x), Ok(y)) if x <= NUM_COLUMNS && y <= 3 => Cursor::Lattice { x, y }, + _ => Cursor::default(), + }, + ["dozen", index] => parse_index(index).map(Cursor::Dozen).unwrap_or_default(), + ["column", index] => parse_index(index).map(Cursor::Column).unwrap_or_default(), + ["outside", index] => match index.parse::() { + Ok(index) if index < OUTSIDE_ORDER.len() => Cursor::Outside(index), + _ => Cursor::default(), + }, + _ => Cursor::default(), + } + } + + pub fn encoded(&self) -> String { + match self { + Cursor::Zero => "zero".to_string(), + Cursor::Lattice { x, y } => format!("lattice:{x}:{y}"), + Cursor::Dozen(index) => format!("dozen:{index}"), + Cursor::Column(index) => format!("column:{index}"), + Cursor::Outside(index) => format!("outside:{index}"), + } + } + + pub fn target(&self) -> Option { + match self { + Cursor::Zero => Some(BetTarget::Straight { number: 0 }), + Cursor::Lattice { x, y } => lattice_target(*x, *y), + Cursor::Dozen(index) => Some(BetTarget::Dozen { + index: *index as u8, + }), + Cursor::Column(index) => Some(BetTarget::Column { + index: *index as u8, + }), + Cursor::Outside(index) => Some(BetTarget::Outside { + kind: OUTSIDE_ORDER[*index], + }), + } + } + + /// Numbers the cursor would cover, for highlighting the felt. + pub fn numbers(&self) -> Vec { + self.target().map(|target| target.numbers()).unwrap_or_default() + } + + pub fn moved(&self, direction: Direction) -> Cursor { + match (*self, direction) { + (Cursor::Zero, Direction::Down) => Cursor::Lattice { x: 0, y: 0 }, + (Cursor::Zero, _) => Cursor::Zero, + + (Cursor::Lattice { x, y }, Direction::Left) => Cursor::Lattice { + x: x.saturating_sub(1), + y, + }, + (Cursor::Lattice { x, y }, Direction::Right) => Cursor::Lattice { + x: (x + 1).min(NUM_COLUMNS), + y, + }, + (Cursor::Lattice { x: _, y: 0 }, Direction::Up) => Cursor::Zero, + (Cursor::Lattice { x, y }, Direction::Up) => Cursor::Lattice { x, y: y - 1 }, + (Cursor::Lattice { x, y: 3 }, Direction::Down) => Cursor::Dozen((x / 4).min(2)), + (Cursor::Lattice { x, y }, Direction::Down) => Cursor::Lattice { x, y: y + 1 }, + + (Cursor::Dozen(index), Direction::Left) => Cursor::Dozen(index.saturating_sub(1)), + (Cursor::Dozen(index), Direction::Right) => Cursor::Dozen((index + 1).min(2)), + (Cursor::Dozen(index), Direction::Up) => Cursor::Lattice { + x: 4 * index + 1, + y: 3, + }, + (Cursor::Dozen(index), Direction::Down) => Cursor::Column(index), + + (Cursor::Column(index), Direction::Left) => Cursor::Column(index.saturating_sub(1)), + (Cursor::Column(index), Direction::Right) => Cursor::Column((index + 1).min(2)), + (Cursor::Column(index), Direction::Up) => Cursor::Dozen(index), + (Cursor::Column(index), Direction::Down) => Cursor::Outside(index * 2), + + (Cursor::Outside(index), Direction::Left) => { + Cursor::Outside(index.saturating_sub(1)) + } + (Cursor::Outside(index), Direction::Right) => { + Cursor::Outside((index + 1).min(OUTSIDE_ORDER.len() - 1)) + } + (Cursor::Outside(index), Direction::Up) => Cursor::Column(index / 2), + (Cursor::Outside(index), Direction::Down) => Cursor::Outside(index), + } + } +} + +fn parse_index(raw: &str) -> Option { + raw.parse::().ok().filter(|index| *index < 3) +} + +/// Numbers under a lattice point: 1 (outer corner), 2 (edge) or 4 (inner). +fn lattice_target(x: usize, y: usize) -> Option { + let cell = |column: isize, row: isize| -> Option { + if !(0..NUM_COLUMNS as isize).contains(&column) || !(0..3isize).contains(&row) { + return None; + } + Some((3 * column + (3 - row)) as u8) + }; + + let x = x as isize; + let y = y as isize; + let mut numbers: Vec = [(x - 1, y - 1), (x, y - 1), (x - 1, y), (x, y)] + .into_iter() + .filter_map(|(column, row)| cell(column, row)) + .collect(); + numbers.sort_unstable(); + + match numbers.as_slice() { + [number] => Some(BetTarget::Straight { number: *number }), + [a, b] => Some(BetTarget::Split { a: *a, b: *b }), + [a, b, c, d] => Some(BetTarget::Corner { + numbers: [*a, *b, *c, *d], + }), + _ => None, + } +} + impl RouletteGame { pub fn new() -> Self { Self @@ -14,40 +182,99 @@ impl RouletteGame { state .data .get("bets") - .and_then(|raw| serde_json::from_str(raw).ok()) + .and_then(|raw| Bets::from_json(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)); - } + fn cursor(state: &GameState) -> Cursor { + state + .data + .get("cursor") + .map(|raw| Cursor::parse(raw)) + .unwrap_or_default() + } - let list = if parts.is_empty() { - "(none)".to_string() + fn zero_cell(cursor: Cursor) -> String { + let cell = if cursor == Cursor::Zero { + "[ 0 ]".to_string() } else { - parts.join(" ") + format!("{:^CELL$}", 0) }; - format!("Bets: {} Total: {} PLN", list, bets.total()) + format!("{cell: String { + (0..NUM_COLUMNS) + .map(|column| { + let number = (3 * column + (3 - row)) as u8; + if covered.contains(&number) { + format!("[{number:^3}]") + } else { + format!("{number:^CELL$}") + } + }) + .collect() + } + + fn wide_cell(label: &str, width: usize, active: bool) -> String { + if active { + format!("[{:^inner$}]", label, inner = width - 2) + } else { + format!("{label:^width$}") + } + } + + fn dozens_row(cursor: Cursor) -> String { + ["1st 12", "2nd 12", "3rd 12"] + .iter() + .enumerate() + .map(|(index, label)| { + Self::wide_cell(label, THIRD_WIDTH, cursor == Cursor::Dozen(index)) + }) + .collect() + } + + fn columns_row(cursor: Cursor) -> String { + ["col 1 2:1", "col 2 2:1", "col 3 2:1"] + .iter() + .enumerate() + .map(|(index, label)| { + Self::wide_cell(label, THIRD_WIDTH, cursor == Cursor::Column(index)) + }) + .collect() + } + + fn outside_row(cursor: Cursor) -> String { + OUTSIDE_ORDER + .iter() + .enumerate() + .map(|(index, kind)| { + Self::wide_cell(kind.label(), OUTSIDE_WIDTH, cursor == Cursor::Outside(index)) + }) + .collect() } - fn result_lines(state: &GameState) -> (String, String) { + fn bets_line(bets: &Bets) -> String { + let mut parts: Vec = bets + .chips + .iter() + .filter(|chip| chip.amount > 0) + .map(|chip| format!("{} {}", chip.target.label(), chip.amount)) + .collect(); + if parts.is_empty() { + parts.push("(none)".to_string()); + } + let mut list = parts.join(" "); + if list.chars().count() > 40 { + list = list.chars().take(39).collect::() + "…"; + } + format!("Bets: {list} Total: {} PLN", bets.total()) + } + + fn status_line(state: &GameState) -> String { if let Some(error) = state.data.get("error") { if !error.is_empty() { - return (format!("ERROR: {error}"), String::new()); + return format!("ERROR: {error}"); } } @@ -77,15 +304,16 @@ impl RouletteGame { .get("winnings") .map(String::as_str) .unwrap_or("0"); - let result = if winnings == "0" { - "No win.".to_string() + let stake = state.bet_amount; + if winnings == "0" { + format!("No win at {number} {color}{parity}") } else { - format!("Won {winnings} PLN") - }; - (format!("Winning: {number} {color}{parity}"), result) + let profit = winnings.parse::().unwrap_or(0).saturating_sub(stake); + format!("Won {winnings} PLN at {number} {color}{parity} (+{profit})") + } } - Some("spinning") => ("Spinning...".to_string(), String::new()), - _ => ("Place bets, then SPACE to spin.".to_string(), String::new()), + Some("spinning") => "Spinning...".to_string(), + _ => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), } } } @@ -99,27 +327,25 @@ impl Game for RouletteGame { 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); + let cursor = Self::cursor(state); + let covered = cursor.numbers(); - 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, + let lines = [ + "=== ROULETTE ===".to_string(), + format!("Balance: {balance} PLN Chip: {chip} PLN"), + Self::zero_cell(cursor), + Self::number_row(0, &covered), + Self::number_row(1, &covered), + Self::number_row(2, &covered), + Self::dozens_row(cursor), + Self::columns_row(cursor), + Self::outside_row(cursor), Self::bets_line(&bets), - input_line, - status, - result - ) + Self::status_line(state), + "[ARROWS] Move [ENTER] Bet [C]lear [+/-] Chip [SPACE] Spin [Q] Quit" + .to_string(), + ]; + lines.join("\r\n") } fn render_lines(&self, _state: &GameState) -> usize { @@ -129,20 +355,20 @@ impl Game for RouletteGame { fn get_help(&self) -> String { "ROULETTE (European)\n\ ==================\n\ - 37 pockets: 0 plus 1-36. 0 is green and loses all outside bets.\n\ + 37 pockets: 0 plus 1-36. 0 is green and loses every outside bet.\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\ + Betting with the cursor:\n\ + arrows - move over the felt; between numbers picks a split or corner\n\ + ENTER - place the current chip where the cursor is\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\ + Payouts (returned per unit staked):\n\ + straight 36x, split 18x, corner 9x\n\ + dozen 3x, column 3x\n\ + red/black, even/odd, 1-18/19-36: 2x\n\ Multiple bets are settled independently and added together." .to_string() } 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 { /// Format remembered hosts so a 64-character fingerprint does not wrap on an /// 80-column terminal. +/// Smallest terminal every game fits in. Roulette's felt is 12 lines and the +/// controls are ~72 columns, so anything smaller would wrap or hide the board. +pub const MIN_COLUMNS: u16 = 80; +pub const MIN_ROWS: u16 = 24; + +/// Refuse to start when the terminal cannot show every game. +pub fn terminal_too_small(columns: u16, rows: u16) -> Option { + if columns >= MIN_COLUMNS && rows >= MIN_ROWS { + return None; + } + Some(format!( + "zagan.bet needs a terminal of at least {MIN_COLUMNS}x{MIN_ROWS} cells, \ + but this one is {columns}x{rows}.\n\ + Resize it and start again." + )) +} + /// Render remembered hosts. /// /// 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] = [ /// Total return on a winning straight (35:1 plus the stake). pub const STRAIGHT_RETURN: u64 = 36; +/// Total return on a winning split (17:1 plus the stake). +pub const SPLIT_RETURN: u64 = 18; +/// Total return on a winning corner (8:1 plus the stake). +pub const CORNER_RETURN: u64 = 9; +/// Total return on a winning dozen or column (2:1 plus the stake). +pub const THIRD_RETURN: u64 = 3; /// Total return on a winning outside bet (1:1 plus the stake). pub const EVEN_MONEY_RETURN: u64 = 2; @@ -47,67 +53,176 @@ pub fn rtp() -> f64 { 36.0 / 37.0 } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Outside { + Low, + High, + Even, + Odd, + Red, + Black, +} + +impl Outside { + pub fn wins(self, number: u8) -> bool { + match self { + Outside::Low => (1..=18).contains(&number), + Outside::High => (19..=36).contains(&number), + Outside::Even => is_even(number), + Outside::Odd => is_odd(number), + Outside::Red => is_red(number), + Outside::Black => is_black(number), + } + } + + pub fn label(self) -> &'static str { + match self { + Outside::Low => "1-18", + Outside::High => "19-36", + Outside::Even => "EVEN", + Outside::Odd => "ODD", + Outside::Red => "RED", + Outside::Black => "BLACK", + } + } +} + +/// One place on the felt where a chip can sit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "bet", rename_all = "snake_case")] +pub enum BetTarget { + Straight { number: u8 }, + Split { a: u8, b: u8 }, + Corner { numbers: [u8; 4] }, + /// 0 = 1-12, 1 = 13-24, 2 = 25-36. + Dozen { index: u8 }, + /// 0 = 1,4,...,34; 1 = 2,5,...,35; 2 = 3,6,...,36. + Column { index: u8 }, + Outside { kind: Outside }, +} + +impl BetTarget { + /// Numbers this target covers on the number grid (empty for outside bets). + pub fn numbers(&self) -> Vec { + match self { + BetTarget::Straight { number } => vec![*number], + BetTarget::Split { a, b } => vec![*a, *b], + BetTarget::Corner { numbers } => numbers.to_vec(), + _ => Vec::new(), + } + } + + /// Total returned per unit staked, or 0 when the bet loses. + pub fn return_multiplier(&self, number: u8) -> u64 { + match self { + BetTarget::Straight { number: straight } => { + u64::from(*straight == number) * STRAIGHT_RETURN + } + BetTarget::Split { a, b } => u64::from(*a == number || *b == number) * SPLIT_RETURN, + BetTarget::Corner { numbers } => { + u64::from(numbers.contains(&number)) * CORNER_RETURN + } + BetTarget::Dozen { index } => { + let wins = number != 0 && (number - 1) / 12 == *index; + u64::from(wins) * THIRD_RETURN + } + BetTarget::Column { index } => { + let wins = match index { + 0 => number % 3 == 1, + 1 => number % 3 == 2, + 2 => number != 0 && number % 3 == 0, + _ => false, + }; + u64::from(wins) * THIRD_RETURN + } + BetTarget::Outside { kind } => u64::from(kind.wins(number)) * EVEN_MONEY_RETURN, + } + } + + pub fn label(&self) -> String { + match self { + BetTarget::Straight { number } => number.to_string(), + BetTarget::Split { a, b } => format!("{a}/{b}"), + BetTarget::Corner { numbers } => numbers + .iter() + .map(u8::to_string) + .collect::>() + .join("/"), + BetTarget::Dozen { index } => match index { + 0 => "1st12".to_string(), + 1 => "2nd12".to_string(), + _ => "3rd12".to_string(), + }, + BetTarget::Column { index } => format!("col{}", index + 1), + BetTarget::Outside { kind } => kind.label().to_string(), + } + } + + fn validates(&self) -> bool { + match self { + BetTarget::Straight { number } => *number <= 36, + BetTarget::Split { a, b } => *a <= 36 && *b <= 36 && a != b, + BetTarget::Corner { numbers } => { + numbers.iter().all(|number| *number <= 36) + && numbers + .iter() + .enumerate() + .all(|(index, number)| !numbers[..index].contains(number)) + } + BetTarget::Dozen { index } | BetTarget::Column { index } => *index < 3, + BetTarget::Outside { .. } => true, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct NumberBet { - pub number: u8, +pub struct Chip { + pub target: BetTarget, pub amount: u64, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Bets { #[serde(default)] - pub red: u64, - #[serde(default)] - pub black: u64, - #[serde(default)] - pub even: u64, - #[serde(default)] - pub odd: u64, - #[serde(default)] - pub numbers: Vec, + pub chips: Vec, } impl Bets { pub fn total(&self) -> u64 { - let mut total = self - .red - .saturating_add(self.black) - .saturating_add(self.even) - .saturating_add(self.odd); - for bet in &self.numbers { - total = total.saturating_add(bet.amount); - } - total + self.chips + .iter() + .fold(0u64, |total, chip| total.saturating_add(chip.amount)) } pub fn is_empty(&self) -> bool { - self.total() == 0 + self.chips.iter().all(|chip| chip.amount == 0) } - /// Total returned for a winning number, including returned stakes. - pub fn winnings(&self, number: u8) -> u64 { - let mut winnings = 0u64; - - if number != 0 { - if is_red(number) { - winnings = winnings.saturating_add(self.red.saturating_mul(EVEN_MONEY_RETURN)); - } else { - winnings = winnings.saturating_add(self.black.saturating_mul(EVEN_MONEY_RETURN)); - } - if is_even(number) { - winnings = winnings.saturating_add(self.even.saturating_mul(EVEN_MONEY_RETURN)); - } else { - winnings = winnings.saturating_add(self.odd.saturating_mul(EVEN_MONEY_RETURN)); - } - } + pub fn amount_on(&self, target: &BetTarget) -> u64 { + self.chips + .iter() + .filter(|chip| &chip.target == target) + .fold(0u64, |total, chip| total.saturating_add(chip.amount)) + } - for bet in &self.numbers { - if bet.number == number { - winnings = winnings.saturating_add(bet.amount.saturating_mul(STRAIGHT_RETURN)); - } + /// Put a chip on a target, merging with an existing one. + pub fn add(&mut self, target: BetTarget, amount: u64) { + if let Some(chip) = self.chips.iter_mut().find(|chip| chip.target == target) { + chip.amount = chip.amount.saturating_add(amount); + } else if amount > 0 { + self.chips.push(Chip { target, amount }); } + } - winnings + /// Total returned for a winning number, including returned stakes. + pub fn winnings(&self, number: u8) -> u64 { + self.chips.iter().fold(0u64, |total, chip| { + total.saturating_add( + chip.amount + .saturating_mul(chip.target.return_multiplier(number)), + ) + }) } pub fn validate(&self, balance: u64) -> Result<(), String> { @@ -117,14 +232,22 @@ impl Bets { if self.total() > balance { return Err("Insufficient balance".to_string()); } - for bet in &self.numbers { - if bet.number > 36 { - return Err(format!("Invalid number: {}", bet.number)); + for chip in &self.chips { + if !chip.target.validates() { + return Err(format!("Invalid bet: {}", chip.target.label())); } - if bet.amount == 0 { + if chip.amount == 0 { return Err("A bet amount must be positive".to_string()); } } Ok(()) } + + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap_or_else(|_| "{\"chips\":[]}".to_string()) + } + + pub fn from_json(raw: &str) -> Result { + serde_json::from_str(raw) + } } 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) { [], ) .unwrap(); - // Add column if not exists (for migration) - let _ = conn.execute("ALTER TABLE users ADD COLUMN account_id INTEGER UNIQUE", []); - let _ = conn.execute( - "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000", - [], - ); conn.execute( "CREATE TABLE IF NOT EXISTS plays ( 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 { } fn get_description(&self) -> &'static str { - "European roulette. Bet on red/black, even/odd, or a straight number." + "European roulette. Straights, splits, corners, dozens, columns and outside bets." } fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState { -- cgit v1.2.3