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 --- Cargo.toml | 2 +- docs/GAMES.md | 19 +++++- docs/TESTING.md | 17 +++++- 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 +++++++++ tests/client_core.rs | 90 +++++++++++++++++++++------ tests/crash.rs | 2 +- tests/roulette.rs | 97 +++++++++++++++++++++-------- tests/server_core.rs | 57 +++++++++++++++++ web/index.php | 3 +- 17 files changed, 544 insertions(+), 94 deletions(-) create mode 100644 src/history.rs diff --git a/Cargo.toml b/Cargo.toml index d13a16c..8bf04dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "3.1.1" +version = "3.2.0" edition = "2024" [features] diff --git a/docs/GAMES.md b/docs/GAMES.md index 8cc668f..bd372b1 100644 --- a/docs/GAMES.md +++ b/docs/GAMES.md @@ -80,9 +80,11 @@ Q forfeit an active hand (bet is lost), or leave when no hand is active - European wheel: 0-36, one green zero. - The felt is drawn in full and bet with a cursor. 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. + walks a grid twice as fine as the felt: on a cell it is a straight, on the + line between two cells it is a split, and on a crossing it is a corner. Every + number and every pair of adjacent numbers is reachable, as on a real felt. +- The status line names the current bet and its odds, for example + `Bet: 16/17 · pays 18x`. - Bet types and returns (per unit staked, stake included): | Bet | Covers | Return | @@ -202,6 +204,17 @@ ROULETTE_RESULT +``` + +`count` defaults to 20 and is capped at 50. Each record carries `game`, `bet`, +`winnings`, `balance_after`, `at` (UTC) and the game's own `details` object, +newest first. `HISTORY` without a session returns `ERROR Not logged in`. + Blackjack is a multi-step session: ``` diff --git a/docs/TESTING.md b/docs/TESTING.md index c68b6c4..ac07a3f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -40,7 +40,7 @@ or a vendored OpenSSL. | `tests/blackjack.rs` | Card totals, naturals, hit/stand/double, dealer bust, S17, state parsing. | | `tests/crash.rs` | Multiplier curve, payout floor, crash-point distribution, house edge, cap. | | `tests/protocol.rs` | Newline buffering and timeout/error classification. | -| `tests/roulette.rs` | Wheel, every bet type and payout, zero handling, cursor lattice, result parser. | +| `tests/roulette.rs` | Wheel, every bet type and payout, zero handling, cursor grid (straight/split/corner), result parser. | | `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | | `tests/server_setup.rs` | `--init` identity generation, PKCS#12 loadability, overwrite refusal. | | `tests/slots.rs` | Server slots payout rules and overflow handling. | @@ -98,8 +98,11 @@ Use an 80-column terminal because several output lines are width-sensitive. 1. `play roulette` draws the full felt: 0, the 3x12 numbers, dozens, columns, and the six outside cells, with the cursor on number 1. 2. Arrows move the cursor. On the numbers the highlighted cells show the bet: - one number (straight), two (split), four (corner). Down leaves the felt into - the dozens, then columns, then outside; Up walks back. + one number (straight), two (split), four (corner). Every number and every + adjacent pair is reachable: from 1, Right is the 1/4 split and Right again is + number 4; Up is the 1/2 split and Up again is number 2. The status line names + the bet and its odds. Down leaves the felt into the dozens, then columns, + then outside; Up walks back. 3. ENTER places the chip where the cursor is; `C` clears; `+`/`=`/`-` change the chip size. The bets line lists every chip and the total. 4. Space scrolls the wheel under a fixed ball marker and ends on the winning @@ -113,6 +116,14 @@ Use an 80-column terminal because several output lines are width-sensitive. 6. The result line names the number, color and parity, the total returned and the profit. The next spin reuses the placed chips. +## Manual history check + +1. Play a few rounds across games, then run `history`. +2. The table is newest first and shows the time, game, bet, winnings, balance + and a one-line result (`17 black`, `cashed 1.12x`, `none`, `win`). +3. `history 2` shows two rows; `history 0` and `history nope` are rejected + without sending anything. A fresh account says `No plays recorded yet.` + ## Manual terminal size check 1. Start the client in a terminal smaller than 80x24: it must print 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() +} diff --git a/tests/client_core.rs b/tests/client_core.rs index bb8772e..b0bba6f 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -608,24 +608,29 @@ fn roulette_cursor_places_bets_and_spins() { assert!(h.board()[1].contains("Balance: 1000 Kr")); assert!(h.board()[1].contains("Chip: 10 Kr")); assert!(h.board()[9].contains("Bets: (none)")); - // The cursor starts on number 1, the bottom-left corner. + // The cursor starts on number 1, the bottom-left cell. assert!(h.board()[5].contains("[ 1 ]")); + assert!(h.board()[10].contains("Bet: 1 · pays 36x")); - // Up twice: straight 2, then the split between 2 and 3. + // One step up is the split between 1 and 2, two steps is number 2. h.key(Key::Up); + assert!(h.board()[5].contains("[ 1 ]")); + assert!(h.board()[4].contains("[ 2 ]")); + assert!(h.board()[10].contains("Bet: 1/2 · pays 18x")); + h.key(Key::Up); + assert!(!h.board()[5].contains("[ 1 ]")); assert!(h.board()[4].contains("[ 2 ]")); - assert!(h.board()[3].contains("[ 3 ]")); + assert!(h.board()[10].contains("Bet: 2 · pays 36x")); h.key(Key::Enter); - assert!(h.board()[9].contains("2/3 10")); - assert!(h.board()[9].contains("Total: 10 Kr")); + assert!(h.board()[9].contains("2 10")); - // Back down to number 1 and place a straight. - h.key(Key::Down); - h.key(Key::Down); + // Right from 2 is the split with 5. + h.key(Key::Right); + assert!(h.board()[10].contains("Bet: 2/5 · pays 18x")); h.key(Key::Enter); - assert!(h.board()[9].contains("1 10")); + assert!(h.board()[9].contains("2/5 10")); assert!(h.board()[9].contains("Total: 20 Kr")); h.key(Key::Char(' ')); @@ -636,18 +641,17 @@ fn roulette_cursor_places_bets_and_spins() { .expect("roulette request"); assert_eq!( request, - "PLAY_START roulette 20 {\"chips\":[{\"target\":{\"bet\":\"split\",\"a\":2,\"b\":3},\"amount\":10},{\"target\":{\"bet\":\"straight\",\"number\":1},\"amount\":10}]}" + "PLAY_START roulette 20 {\"chips\":[{\"target\":{\"bet\":\"straight\",\"number\":2},\"amount\":10},{\"target\":{\"bet\":\"split\",\"a\":2,\"b\":5},\"amount\":10}]}" ); - let bets = r#"{"chips":[{"target":{"bet":"split","a":2,"b":3},"amount":10},{"target":{"bet":"straight","number":1},"amount":10}]}"#; + let bets = r#"{"chips":[{"target":{"bet":"straight","number":2},"amount":10},{"target":{"bet":"split","a":2,"b":5},"amount":10}]}"#; h.reply(&format!( - "ROULETTE_RESULT session-1 180 20 1160 3 red {bets}" + "ROULETTE_RESULT session-1 540 20 1520 2 black {bets}" )); - assert_eq!(h.board().len(), 12); - assert!(h.board()[1].contains("Balance: 1160 Kr")); - assert!(h.board()[10].contains("Won 180 Kr at 3 RED ODD")); - assert!(h.board()[10].contains("+160")); + assert!(h.board()[1].contains("Balance: 1520 Kr")); + assert!(h.board()[10].contains("Won 540 Kr at 2 BLACK EVEN")); + assert!(h.board()[10].contains("+520")); } #[test] @@ -658,19 +662,18 @@ fn roulette_cursor_reaches_the_outside_rows() { h.key(Key::Down); assert!(h.board()[6].starts_with("[") && h.board()[6].contains("1st 12")); h.key(Key::Down); - assert!(h.board()[7].starts_with("[") && h.board()[7].contains("2:1")); + assert!(h.board()[7].starts_with("[") && h.board()[7].contains("col 1 2:1")); h.key(Key::Down); assert!(h.board()[8].starts_with("[") && h.board()[8].contains("1-18")); h.key(Key::Enter); assert!(h.board()[9].contains("1-18 10")); - // Back up to the felt: the bottom edge of the first dozen is the 1/4 split. + // Back up to the felt: the dozens return to number 1. h.key(Key::Up); h.key(Key::Up); h.key(Key::Up); assert!(h.board()[5].contains("[ 1 ]")); - assert!(h.board()[5].contains("[ 4 ]")); } #[test] @@ -1146,3 +1149,52 @@ fn a_round_is_live_only_while_money_is_reserved() { h.push(&crash_line("CRASH_BUSTED", &busted)); assert!(!h.core.is_live_game()); } + +#[test] +fn history_prints_recent_plays() { + let mut h = Harness::new(); + h.line("history"); + assert_eq!(h.sent.last().map(String::as_str), Some("HISTORY")); + + let json = r#"[{"game":"roulette","bet":10,"winnings":20,"balance_after":1010,"at":"2026-10-30 01:20:11","details":{"number":"17","color":"black"}}]"#; + h.reply(&format!("HISTORY {json}")); + + assert!(h + .screen + .lines + .iter() + .any(|line| line.starts_with("Recent plays (1)"))); + let row = h + .screen + .lines + .iter() + .find(|line| line.contains("roulette")) + .expect("play row"); + assert!(row.contains("2026-10-30 01:20"), "row: {row}"); + assert!(row.contains("1010"), "row: {row}"); + assert!(row.contains("17 black"), "row: {row}"); +} + +#[test] +fn history_without_plays_says_so() { + let mut h = Harness::new(); + h.line("history"); + h.reply("HISTORY []"); + assert!(h + .screen + .lines + .iter() + .any(|line| line == "No plays recorded yet.")); +} + +#[test] +fn history_rejects_a_bad_count_without_sending() { + let mut h = Harness::new(); + h.line("history 0"); + assert!(!h.sent.iter().any(|line| line == "HISTORY 0")); + assert!(h + .screen + .lines + .iter() + .any(|line| line.contains("Usage: history [1-50]"))); +} diff --git a/tests/crash.rs b/tests/crash.rs index c985ebe..797874b 100644 --- a/tests/crash.rs +++ b/tests/crash.rs @@ -38,7 +38,7 @@ fn the_time_for_a_multiplier_is_the_inverse_of_the_curve() { } #[test] -fn cashout_value_is_floored_to_whole_Kr() { +fn cashout_value_is_floored_to_whole_kr() { assert_eq!(payout(10, 1.0), 10); assert_eq!(payout(10, 1.05), 10, "half a Kr is not paid out"); assert_eq!(payout(10, 1.10), 11); diff --git a/tests/roulette.rs b/tests/roulette.rs index 8f9155c..0c5e195 100644 --- a/tests/roulette.rs +++ b/tests/roulette.rs @@ -234,28 +234,51 @@ fn target_at(x: usize, y: usize) -> BetTarget { } #[test] -fn lattice_points_become_straights_splits_and_corners() { - assert_eq!( - target_at(0, 3), - BetTarget::Straight { number: 1 }, - "bottom-left corner is a straight" - ); - assert_eq!(target_at(0, 0), BetTarget::Straight { number: 3 }); - assert_eq!(target_at(12, 0), BetTarget::Straight { number: 36 }); - assert_eq!(target_at(12, 3), BetTarget::Straight { number: 34 }); - - assert_eq!(target_at(0, 1), BetTarget::Split { a: 2, b: 3 }); - assert_eq!(target_at(12, 1), BetTarget::Split { a: 35, b: 36 }); - assert_eq!(target_at(1, 0), BetTarget::Split { a: 3, b: 6 }); +fn cell_centres_are_straights() { + assert_eq!(target_at(1, 5), BetTarget::Straight { number: 1 }); + assert_eq!(target_at(1, 1), BetTarget::Straight { number: 3 }); + assert_eq!(target_at(23, 1), BetTarget::Straight { number: 36 }); + assert_eq!(target_at(23, 5), BetTarget::Straight { number: 34 }); + assert_eq!(target_at(5, 3), BetTarget::Straight { number: 8 }); + + // Every one of the 36 numbers can be bet straight. + let mut reachable = Vec::new(); + for column in 0..12usize { + for row in 0..3usize { + let cursor = Cursor::Lattice { + x: 2 * column + 1, + y: 2 * row + 1, + }; + if let Some(BetTarget::Straight { number }) = cursor.target() { + reachable.push(number); + } + } + } + reachable.sort_unstable(); + assert_eq!(reachable, (1..=36).collect::>()); +} +#[test] +fn lines_between_cells_are_splits_and_crossings_are_corners() { + // Vertical lines sit between two columns. + assert_eq!(target_at(2, 5), BetTarget::Split { a: 1, b: 4 }); + assert_eq!(target_at(2, 3), BetTarget::Split { a: 2, b: 5 }); + assert_eq!(target_at(22, 1), BetTarget::Split { a: 33, b: 36 }); + + // Horizontal lines sit between two rows. + assert_eq!(target_at(1, 4), BetTarget::Split { a: 1, b: 2 }); + assert_eq!(target_at(3, 2), BetTarget::Split { a: 5, b: 6 }); + assert_eq!(target_at(23, 4), BetTarget::Split { a: 34, b: 35 }); + + // Crossings cover four cells. assert_eq!( - target_at(1, 1), + target_at(2, 4), BetTarget::Corner { - numbers: [2, 3, 5, 6] + numbers: [1, 2, 4, 5] } ); assert_eq!( - target_at(6, 2), + target_at(12, 4), BetTarget::Corner { numbers: [16, 17, 19, 20] } @@ -264,31 +287,54 @@ fn lattice_points_become_straights_splits_and_corners() { #[test] fn the_cursor_walks_the_felt_and_the_outside_rows() { - let bottom = Cursor::Lattice { x: 0, y: 3 }; + let bottom = Cursor::Lattice { x: 1, y: 5 }; + // One step right is the split between 1 and 4, two steps is number 4. + assert_eq!( + bottom.moved(Direction::Right), + Cursor::Lattice { x: 2, y: 5 } + ); + assert_eq!( + bottom.moved(Direction::Right).moved(Direction::Right), + Cursor::Lattice { x: 3, y: 5 } + ); + // One step up is the split between 1 and 2. + assert_eq!(bottom.moved(Direction::Up), Cursor::Lattice { x: 1, y: 4 }); + assert_eq!(bottom.moved(Direction::Down), Cursor::Dozen(0)); assert_eq!( - Cursor::Lattice { x: 12, y: 3 }.moved(Direction::Down), + Cursor::Lattice { x: 23, y: 5 }.moved(Direction::Down), Cursor::Dozen(2) ); assert_eq!( - Cursor::Lattice { x: 0, y: 0 }.moved(Direction::Up), + Cursor::Lattice { x: 1, y: 1 }.moved(Direction::Up), Cursor::Zero ); - assert_eq!(Cursor::Zero.moved(Direction::Down), Cursor::Lattice { x: 0, y: 0 }); + assert_eq!( + Cursor::Zero.moved(Direction::Down), + Cursor::Lattice { x: 1, y: 1 } + ); - assert_eq!(Cursor::Dozen(0).moved(Direction::Up), Cursor::Lattice { x: 1, y: 3 }); + assert_eq!( + Cursor::Dozen(0).moved(Direction::Up), + Cursor::Lattice { x: 1, y: 5 } + ); assert_eq!(Cursor::Dozen(0).moved(Direction::Down), Cursor::Column(0)); + assert_eq!(Cursor::Dozen(2).moved(Direction::Up), Cursor::Lattice { x: 17, y: 5 }); assert_eq!(Cursor::Column(2).moved(Direction::Up), Cursor::Dozen(2)); assert_eq!(Cursor::Column(1).moved(Direction::Down), Cursor::Outside(2)); assert_eq!(Cursor::Outside(3).moved(Direction::Up), Cursor::Column(1)); // Edges stay put instead of wrapping. - assert_eq!(Cursor::Dozen(0).moved(Direction::Left), Cursor::Dozen(0)); - assert_eq!(Cursor::Outside(5).moved(Direction::Right), Cursor::Outside(5)); assert_eq!( - Cursor::Lattice { x: 0, y: 2 }.moved(Direction::Left), - Cursor::Lattice { x: 0, y: 2 } + Cursor::Lattice { x: 1, y: 3 }.moved(Direction::Left), + Cursor::Lattice { x: 1, y: 3 } + ); + assert_eq!( + Cursor::Lattice { x: 23, y: 3 }.moved(Direction::Right), + Cursor::Lattice { x: 23, y: 3 } ); + assert_eq!(Cursor::Dozen(0).moved(Direction::Left), Cursor::Dozen(0)); + assert_eq!(Cursor::Outside(5).moved(Direction::Right), Cursor::Outside(5)); } #[test] @@ -305,6 +351,7 @@ fn every_cursor_round_trips_through_its_encoded_form() { } assert_eq!(Cursor::parse("nonsense"), Cursor::default()); assert_eq!(Cursor::parse("lattice:99:0"), Cursor::default()); + assert_eq!(Cursor::parse("lattice:0:1"), Cursor::default()); assert_eq!(Cursor::parse("dozen:7"), Cursor::default()); } diff --git a/tests/server_core.rs b/tests/server_core.rs index 47e80e7..6d6cffd 100644 --- a/tests/server_core.rs +++ b/tests/server_core.rs @@ -871,3 +871,60 @@ fn an_insufficient_bet_leaves_no_reservation() { .unwrap(); assert_eq!(pending, 0); } + +#[test] +fn history_requires_login_and_valid_counts() { + let (mut core, _path) = setup("history-guards", WIN3); + let mut state = ConnectionState::default(); + + assert_eq!( + core.handle_message(&mut state, "HISTORY"), + "ERROR Not logged in" + ); + + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + assert_eq!( + core.handle_message(&mut state, "HISTORY nope"), + "ERROR Invalid HISTORY count" + ); + assert_eq!( + core.handle_message(&mut state, "HISTORY 0"), + "ERROR Invalid HISTORY count" + ); + assert_eq!( + core.handle_message(&mut state, "HISTORY 1 2"), + "ERROR Invalid HISTORY format. Usage: HISTORY [count]" + ); +} + +#[test] +fn history_lists_recent_plays_newest_first() { + let (mut core, _path) = setup("history-list", WIN3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + for _ in 0..3 { + assert!(core + .handle_message(&mut state, "PLAY_START slots 10") + .starts_with("GAME_RESULT")); + } + + let response = core.handle_message(&mut state, "HISTORY 2"); + let json = response.strip_prefix("HISTORY ").expect("history response"); + let records: Vec = serde_json::from_str(json).unwrap(); + + assert_eq!(records.len(), 2, "the limit must be honoured"); + for record in &records { + assert_eq!(record.game, "slots"); + assert_eq!(record.bet, 10); + assert_eq!(record.winnings, 10, "three C pay the stake back"); + assert_eq!(record.summary(), "win"); + assert_eq!(record.details["win_type"], "win"); + } + + // Newest first: the timestamps must not increase across the list. + assert!(records[0].at >= records[1].at); +} diff --git a/web/index.php b/web/index.php index fd0a396..7ed79da 100644 --- a/web/index.php +++ b/web/index.php @@ -318,7 +318,8 @@ if ($cacheDirty) {

SHA-256

-

Save this block as SHA256SUMS and run sha256sum -c SHA256SUMS. On Windows use Get-FileHash <file>.

+

On Linux use sha256sum <file>. On Windows use Get-FileHash <file>.

+

Then simply check if the checksum matches with your file.