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 +++++++++ src/history.rs | 54 +++++++++++++++++ src/lib.rs | 1 + src/roulette.rs | 11 ++++ src/server/core.rs | 34 +++++++++++ src/server/database.rs | 27 +++++++++ 9 files changed, 310 insertions(+), 41 deletions(-) create mode 100644 src/history.rs (limited to 'src') 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 diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..5ef375b --- /dev/null +++ b/src/history.rs @@ -0,0 +1,54 @@ +//! Play history as sent to the client. + +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_HISTORY: u32 = 20; +pub const MAX_HISTORY: u32 = 50; + +/// One settled play. `details` is the game's own audit blob. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlayRecord { + pub game: String, + pub bet: i64, + pub winnings: i64, + pub balance_after: i64, + /// Timestamp as stored in SQLite (`YYYY-MM-DD HH:MM:SS`, UTC). + pub at: String, + #[serde(default)] + pub details: serde_json::Value, +} + +impl PlayRecord { + /// Short human summary of the game-specific details. + pub fn summary(&self) -> String { + let field = |name: &str| -> Option { + self.details.get(name).map(|value| match value.as_str() { + Some(text) => text.to_string(), + None => value.to_string(), + }) + }; + + match self.game.as_str() { + "slots" => field("win_type").unwrap_or_else(|| "spin".to_string()), + "roulette" => match (field("number"), field("color")) { + (Some(number), Some(color)) => format!("{number} {color}"), + (Some(number), None) => number, + _ => String::new(), + }, + "blackjack" => field("result").unwrap_or_default(), + "crash" => { + let result = field("result").unwrap_or_default(); + match self.details.get("multiplier").and_then(|value| value.as_f64()) { + Some(multiplier) => format!("{result} {multiplier:.2}x"), + None => result, + } + } + _ => String::new(), + } + } + + /// `YYYY-MM-DD HH:MM`, dropping the seconds. + pub fn short_time(&self) -> &str { + self.at.get(..16).unwrap_or(&self.at) + } +} diff --git a/src/lib.rs b/src/lib.rs index ffc3d60..51afafc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod blackjack; pub mod client; pub mod crash; +pub mod history; pub mod protocol; pub mod roulette; pub mod server; diff --git a/src/roulette.rs b/src/roulette.rs index e7b3aad..6b73c2a 100644 --- a/src/roulette.rs +++ b/src/roulette.rs @@ -140,6 +140,17 @@ impl BetTarget { } } + /// Total return for a winning version of this bet. + pub fn return_label(&self) -> &'static str { + match self { + BetTarget::Straight { .. } => "36x", + BetTarget::Split { .. } => "18x", + BetTarget::Corner { .. } => "9x", + BetTarget::Dozen { .. } | BetTarget::Column { .. } => "3x", + BetTarget::Outside { .. } => "2x", + } + } + pub fn label(&self) -> String { match self { BetTarget::Straight { number } => number.to_string(), diff --git a/src/server/core.rs b/src/server/core.rs index 4b68ccb..89264f3 100644 --- a/src/server/core.rs +++ b/src/server/core.rs @@ -172,6 +172,21 @@ impl ServerCore { self.handle_auth(state, parts[1], parts[2]) } } + "HISTORY" => { + if parts.len() > 2 { + "ERROR Invalid HISTORY format. Usage: HISTORY [count]".to_string() + } else { + match parts.get(1) { + Some(raw) => match raw.parse::() { + Ok(count) if count > 0 => { + self.handle_history(state, count.min(crate::history::MAX_HISTORY)) + } + _ => "ERROR Invalid HISTORY count".to_string(), + }, + None => self.handle_history(state, crate::history::DEFAULT_HISTORY), + } + } + } "PING" => "PONG".to_string(), "INFO" => self.handle_info(state), _ => msg.to_string(), @@ -234,6 +249,25 @@ impl ServerCore { } } + fn handle_history(&mut self, state: &ConnectionState, limit: u32) -> String { + let Some(user) = state.user.as_deref() else { + return "ERROR Not logged in".to_string(); + }; + match database::recent_plays(&self.conn, user, limit) { + Ok(records) => match serde_json::to_string(&records) { + Ok(json) => format!("HISTORY {json}"), + Err(error) => { + eprintln!("History serialization failed for {user}: {error}"); + "ERROR Could not read history".to_string() + } + }, + Err(error) => { + eprintln!("History query failed for {user}: {error}"); + "ERROR Could not read history".to_string() + } + } + } + fn handle_info(&self, state: &ConnectionState) -> String { let Some(user) = state.user.as_deref() else { return "ERROR Not logged in".to_string(); diff --git a/src/server/database.rs b/src/server/database.rs index 6dd71a3..cfc48c1 100644 --- a/src/server/database.rs +++ b/src/server/database.rs @@ -399,3 +399,30 @@ fn abandoned_details(raw: &str) -> String { } value.to_string() } + +/// Most recent settled plays for a user, newest first. +pub fn recent_plays( + conn: &Connection, + username: &str, + limit: u32, +) -> rusqlite::Result> { + let mut statement = conn.prepare( + "SELECT game_type, bet_amount, winnings, balance_after, details, created_at + FROM plays + WHERE username = ?1 + ORDER BY created_at DESC, rowid DESC + LIMIT ?2", + )?; + let rows = statement.query_map(params![username, limit], |row| { + let details: String = row.get(4)?; + Ok(crate::history::PlayRecord { + game: row.get(0)?, + bet: row.get(1)?, + winnings: row.get(2)?, + balance_after: row.get(3)?, + at: row.get(5)?, + details: serde_json::from_str(&details).unwrap_or(serde_json::Value::Null), + }) + })?; + rows.collect() +} -- cgit v1.2.3