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/client/games/roulette.rs | 346 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 286 insertions(+), 60 deletions(-) (limited to 'src/client/games/roulette.rs') 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() } -- cgit v1.2.3