use super::traits::{Game, GameState}; use crate::roulette::{is_even, is_odd, BetTarget, Bets, Outside}; 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 } fn bets(state: &GameState) -> Bets { state .data .get("bets") .and_then(|raw| Bets::from_json(raw).ok()) .unwrap_or_default() } fn cursor(state: &GameState) -> Cursor { state .data .get("cursor") .map(|raw| Cursor::parse(raw)) .unwrap_or_default() } fn zero_cell(cursor: Cursor) -> String { let cell = if cursor == Cursor::Zero { "[ 0 ]".to_string() } else { format!("{:^CELL$}", 0) }; 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 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}"); } } match state.data.get("status").map(String::as_str) { Some("result") => { let number = state.data.get("number").map(String::as_str).unwrap_or("?"); let color = state .data .get("color") .map(|value| value.to_uppercase()) .unwrap_or_default(); let parity = number .parse::() .ok() .map(|number| { if is_even(number) { " EVEN" } else if is_odd(number) { " ODD" } else { "" } }) .unwrap_or(""); let winnings = state .data .get("winnings") .map(String::as_str) .unwrap_or("0"); let stake = state.bet_amount; if winnings == "0" { format!("No win at {number} {color}{parity}") } else { let profit = winnings.parse::().unwrap_or(0).saturating_sub(stake); format!("Won {winnings} PLN at {number} {color}{parity} (+{profit})") } } Some("spinning") => "Spinning...".to_string(), _ => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), } } } impl Game for RouletteGame { fn get_name(&self) -> &'static str { "Roulette" } fn render(&self, state: &GameState) -> String { let bets = Self::bets(state); let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); let balance = state.data.get("balance").map(String::as_str).unwrap_or("?"); let cursor = Self::cursor(state); let covered = cursor.numbers(); 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), 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 { ROULETTE_BOARD_LINES } fn get_help(&self) -> String { "ROULETTE (European)\n\ ==================\n\ 37 pockets: 0 plus 1-36. 0 is green and loses every outside bet.\n\ \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 (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() } }