From f07221b4e1d8aa464a44fe566884ccc0565406e9 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 16:17:19 +0100 Subject: v3.2.0: straight and split bets anywhere on the felt; add history command --- src/client/core.rs | 34 ++++++++++- src/client/games/response.rs | 20 ++++++ src/client/games/roulette.rs | 141 +++++++++++++++++++++++++++++++------------ src/client/ui.rs | 29 +++++++++ 4 files changed, 183 insertions(+), 41 deletions(-) (limited to 'src/client') diff --git a/src/client/core.rs b/src/client/core.rs index 54a6434..0b2e683 100644 --- a/src/client/core.rs +++ b/src/client/core.rs @@ -1,13 +1,15 @@ use crate::client::games::manager::GameManager; use crate::client::games::response::{ - parse_blackjack_response, parse_crash_response, parse_roulette_response, parse_spin_response, + parse_blackjack_response, parse_crash_response, parse_history_response, + parse_roulette_response, parse_spin_response, }; use crate::client::games::roulette::Direction; use crate::client::ui::{ - command_names, completion_lines, display_width, help_lines, input_cursor_column, parse_bet_arg, - record_history, + command_names, completion_lines, display_width, help_lines, history_lines, + input_cursor_column, parse_bet_arg, record_history, }; use crate::crash::{CrashState, multiplier_at}; +use crate::history::MAX_HISTORY; use crate::roulette::Bets; use std::time::{Duration, Instant}; @@ -74,6 +76,7 @@ enum Pending { CrashStart, CrashCashout, CrashForfeit, + History, Ping, Echo, Info, @@ -290,6 +293,10 @@ impl ClientCore { self.board_effects() } }, + Some(Pending::History) => match parse_history_response(reply) { + Ok(records) => vec![Effect::Print(history_lines(&records))], + Err(message) => print_line(message), + }, Some(Pending::Ping) => { if reply.trim() == "PONG" { print_line("Pong") @@ -782,6 +789,7 @@ impl ClientCore { } } "info" => self.cmd_info(), + "history" => self.cmd_history(&parts), "hosts" => vec![Effect::ListHosts], "forget-host" => { let target = parts @@ -851,6 +859,26 @@ impl ClientCore { vec![Effect::Print(lines)] } + fn cmd_history(&mut self, parts: &[&str]) -> Vec { + if !self.connected { + return print_line("Not connected. Use 'connect :' first."); + } + if self.logged_in.is_none() { + return print_line("Not logged in. Use 'login' first."); + } + + let request = match parts.get(1) { + Some(raw) => match raw.parse::() { + Ok(count) if (1..=MAX_HISTORY).contains(&count) => format!("HISTORY {count}"), + _ => return print_line(format!("Usage: history [1-{MAX_HISTORY}]")), + }, + None => "HISTORY".to_string(), + }; + + self.pending = Some(Pending::History); + vec![Effect::SendLine(request)] + } + fn cmd_info(&mut self) -> Vec { if !self.connected { return print_line("Not connected to any host."); diff --git a/src/client/games/response.rs b/src/client/games/response.rs index 88df459..060ad44 100644 --- a/src/client/games/response.rs +++ b/src/client/games/response.rs @@ -184,3 +184,23 @@ pub fn parse_crash_response(response: &str) -> Result Result, String> { + let response = response.trim(); + if response.is_empty() { + return Err("No response from server".to_string()); + } + if let Some(message) = response.strip_prefix("ERROR") { + let message = message.trim(); + return Err(if message.is_empty() { + "Server error".to_string() + } else { + message.to_string() + }); + } + + let raw = response + .strip_prefix("HISTORY ") + .ok_or_else(|| format!("Unexpected server response: {response}"))?; + serde_json::from_str(raw).map_err(|error| format!("Invalid history: {error}")) +} diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs index 96f8791..b3a8d9c 100644 --- a/src/client/games/roulette.rs +++ b/src/client/games/roulette.rs @@ -30,9 +30,16 @@ pub enum Direction { /// 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. +/// 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, @@ -42,10 +49,14 @@ pub enum Cursor { 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 { - // Bottom-left number (1). - Cursor::Lattice { x: 0, y: 3 } + // Centre of the bottom-left number (1). + Cursor::Lattice { x: 1, y: 5 } } } @@ -55,7 +66,9 @@ impl Cursor { 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 }, + (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(), @@ -81,7 +94,7 @@ impl Cursor { pub fn target(&self) -> Option { match self { Cursor::Zero => Some(BetTarget::Straight { number: 0 }), - Cursor::Lattice { x, y } => lattice_target(*x, *y), + Cursor::Lattice { x, y } => felt_target(*x, *y), Cursor::Dozen(index) => Some(BetTarget::Dozen { index: *index as u8, }), @@ -103,27 +116,29 @@ impl Cursor { pub fn moved(&self, direction: Direction) -> Cursor { match (*self, direction) { - (Cursor::Zero, Direction::Down) => Cursor::Lattice { x: 0, y: 0 }, + (Cursor::Zero, Direction::Down) => Cursor::Lattice { x: 1, y: 1 }, (Cursor::Zero, _) => Cursor::Zero, (Cursor::Lattice { x, y }, Direction::Left) => Cursor::Lattice { - x: x.saturating_sub(1), + x: if x > 1 { x - 1 } else { x }, y, }, (Cursor::Lattice { x, y }, Direction::Right) => Cursor::Lattice { - x: (x + 1).min(NUM_COLUMNS), + x: (x + 1).min(FELT_X_MAX), y, }, - (Cursor::Lattice { x: _, y: 0 }, Direction::Up) => Cursor::Zero, + (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: 3 }, Direction::Down) => Cursor::Dozen((x / 4).min(2)), + (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: 4 * index + 1, - y: 3, + x: 8 * index + 1, + y: FELT_Y_MAX, }, (Cursor::Dozen(index), Direction::Down) => Cursor::Column(index), @@ -146,30 +161,69 @@ 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; +/// 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, + } } - 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, } } @@ -317,7 +371,18 @@ impl RouletteGame { } } Some("spinning") => "Spinning...".to_string(), - _ => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), + _ => { + let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); + match Self::cursor(state).target() { + Some(target) => format!( + "Bet: {} · pays {} · ENTER places {} Kr", + target.label(), + target.return_label(), + chip + ), + None => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), + } + } } } } diff --git a/src/client/ui.rs b/src/client/ui.rs index e7f510b..bb5b55f 100644 --- a/src/client/ui.rs +++ b/src/client/ui.rs @@ -80,6 +80,11 @@ const COMMANDS: &[CommandHelp] = &[ usage: "hosts", description: "list remembered hosts", }, + CommandHelp { + name: "history", + usage: "history [count]", + description: "show your recent wins and losses", + }, CommandHelp { name: "quit", usage: "quit", @@ -267,6 +272,30 @@ pub fn terminal_size_warning(columns: u16, rows: u16) -> Option { )) } +/// One line per settled play, newest first. +pub fn history_lines(records: &[crate::history::PlayRecord]) -> Vec { + if records.is_empty() { + return vec!["No plays recorded yet.".to_string()]; + } + + let mut lines = vec![ + format!("Recent plays ({}):", records.len()), + " when game bet won balance result".to_string(), + ]; + for record in records { + lines.push(format!( + " {:<16} {:<8} {:>6} {:>6} {:>7} {}", + record.short_time(), + record.game, + record.bet, + record.winnings, + record.balance_after, + record.summary() + )); + } + lines +} + /// Render remembered hosts. /// /// Two addresses that present the same certificate key are the same server, so -- cgit v1.2.3