use super::traits::{Game, GameState}; use crate::client::view::{RoulettePhase, RouletteView}; use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; 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 walks a grid twice as fine as the felt itself: /// odd coordinates are the centre of a number cell and even coordinates are the /// line between cells, exactly where a chip sits on a real felt. /// /// ```text /// odd,odd the number straight /// even,odd line between columns split of the two numbers /// odd,even line between rows split of the two numbers /// even,even corner of four cells corner /// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Cursor { Zero, Lattice { x: usize, y: usize }, Dozen(usize), Column(usize), Outside(usize), } /// Fine-grid bounds: 12 columns and 3 rows of cells, plus the lines between. const FELT_X_MAX: usize = 2 * NUM_COLUMNS - 1; const FELT_Y_MAX: usize = 2 * 3 - 1; impl Default for Cursor { fn default() -> Self { // Centre of the bottom-left number (1). Cursor::Lattice { x: 1, y: 5 } } } 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 (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) => { 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 } => felt_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: 1, y: 1 }, (Cursor::Zero, _) => Cursor::Zero, (Cursor::Lattice { x, y }, Direction::Left) => Cursor::Lattice { x: if x > 1 { x - 1 } else { x }, y, }, (Cursor::Lattice { x, y }, Direction::Right) => Cursor::Lattice { x: (x + 1).min(FELT_X_MAX), y, }, (Cursor::Lattice { x: _, y: 1 }, Direction::Up) => Cursor::Zero, (Cursor::Lattice { x, y }, Direction::Up) => Cursor::Lattice { x, y: y - 1 }, (Cursor::Lattice { x, y: FELT_Y_MAX }, Direction::Down) => { Cursor::Dozen((x.saturating_sub(1) / 8).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: 8 * index + 1, y: FELT_Y_MAX, }, (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) } /// Number at a felt cell (column 0-11, row 0-2 counted from the top). fn cell_number(column: usize, row: usize) -> Option { if column >= NUM_COLUMNS || row >= 3 { return None; } Some((3 * column + (3 - row)) as u8) } /// Bet under a point of the fine grid. fn felt_target(x: usize, y: usize) -> Option { if !(1..=FELT_X_MAX).contains(&x) || !(1..=FELT_Y_MAX).contains(&y) { return None; } let column = (x - 1) / 2; let row = (y - 1) / 2; match (x % 2, y % 2) { // Centre of a cell: straight. (1, 1) => cell_number(column, row).map(|number| BetTarget::Straight { number }), // Vertical line: the two cells left and right of it. (0, 1) => { let left = cell_number(x / 2 - 1, row); let right = cell_number(x / 2, row); match (left, right) { (Some(a), Some(b)) => Some(BetTarget::Split { a: a.min(b), b: a.max(b), }), _ => None, } } // Horizontal line: the two cells above and below it. (1, 0) => { let above = cell_number(column, y / 2 - 1); let below = cell_number(column, y / 2); match (above, below) { (Some(a), Some(b)) => Some(BetTarget::Split { a: a.min(b), b: a.max(b), }), _ => None, } } // Crossing: the four cells around it. _ => { let mut numbers: Vec = [ (x / 2 - 1, y / 2 - 1), (x / 2, y / 2 - 1), (x / 2 - 1, y / 2), (x / 2, y / 2), ] .into_iter() .filter_map(|(column, row)| cell_number(column, row)) .collect(); numbers.sort_unstable(); match numbers.as_slice() { [a, b, c, d] => Some(BetTarget::Corner { numbers: [*a, *b, *c, *d], }), _ => None, } } } } impl RouletteGame { pub fn new() -> Self { Self } 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: {} Kr", bets.total()) } fn status_line(view: &RouletteView) -> String { if let Some(error) = &view.error { return format!("ERROR: {error}"); } match view.phase { RoulettePhase::Result => { let number = view.number.unwrap_or(0); let color = view .color .as_deref() .map(str::to_uppercase) .unwrap_or_default(); let parity = if is_even(number) { " EVEN" } else if is_odd(number) { " ODD" } else { "" }; match view.winnings.unwrap_or(0) { 0 => format!("No win at {number} {color}{parity}"), winnings => { let profit = winnings.saturating_sub(view.bet_amount); format!("Won {winnings} Kr at {number} {color}{parity} (+{profit})") } } } RoulettePhase::Spinning => "Spinning...".to_string(), RoulettePhase::Placing => match view.cursor.target() { Some(target) => format!( "Bet: {} · pays {} · ENTER places {} Kr", target.label(), target.return_label(), view.chip ), None => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), }, } } /// Render a roulette view to terminal lines. The GUI draws the same view /// with a painter instead. pub fn render_view(view: &RouletteView) -> String { let covered = view.cursor.numbers(); let balance = view .balance .map(|balance| balance.to_string()) .unwrap_or_else(|| "?".to_string()); let lines = [ "=== ROULETTE ===".to_string(), format!("Balance: {balance} Kr Chip: {} Kr", view.chip), Self::zero_cell(view.cursor), Self::number_row(0, &covered), Self::number_row(1, &covered), Self::number_row(2, &covered), Self::dozens_row(view.cursor), Self::columns_row(view.cursor), Self::outside_row(view.cursor), Self::bets_line(&view.bets), Self::status_line(view), "[ARROWS] Move [ENTER] Bet [C]lear [+/-] Chip [SPACE] Spin [Q] Quit".to_string(), ]; lines.join("\r\n") } } impl Game for RouletteGame { fn get_name(&self) -> &'static str { "Roulette" } fn render(&self, state: &GameState) -> String { Self::render_view(&RouletteView::from_state(state)) } 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() } }