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/roulette.rs | 215 ++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 169 insertions(+), 46 deletions(-) (limited to 'src/roulette.rs') 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) + } } -- cgit v1.2.3