diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 16:17:19 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 16:17:19 +0100 |
| commit | f07221b4e1d8aa464a44fe566884ccc0565406e9 (patch) | |
| tree | 48020a68cee1c95521d594dc6803c1f7ec01c801 | |
| parent | add nginx + php-fpm deployment example (diff) | |
| download | zagan-bet-f07221b4e1d8aa464a44fe566884ccc0565406e9.tar.gz zagan-bet-f07221b4e1d8aa464a44fe566884ccc0565406e9.zip | |
v3.2.0: straight and split bets anywhere on the felt; add history command
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GAMES.md | 19 | ||||
| -rw-r--r-- | docs/TESTING.md | 17 | ||||
| -rw-r--r-- | src/client/core.rs | 34 | ||||
| -rw-r--r-- | src/client/games/response.rs | 20 | ||||
| -rw-r--r-- | src/client/games/roulette.rs | 141 | ||||
| -rw-r--r-- | src/client/ui.rs | 29 | ||||
| -rw-r--r-- | src/history.rs | 54 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/roulette.rs | 11 | ||||
| -rw-r--r-- | src/server/core.rs | 34 | ||||
| -rw-r--r-- | src/server/database.rs | 27 | ||||
| -rw-r--r-- | tests/client_core.rs | 90 | ||||
| -rw-r--r-- | tests/crash.rs | 2 | ||||
| -rw-r--r-- | tests/roulette.rs | 97 | ||||
| -rw-r--r-- | tests/server_core.rs | 57 | ||||
| -rw-r--r-- | web/index.php | 3 |
17 files changed, 544 insertions, 94 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "3.1.1" | 3 | version = "3.2.0" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | 5 | ||
| 6 | [features] | 6 | [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 | |||
| 80 | 80 | ||
| 81 | - European wheel: 0-36, one green zero. | 81 | - European wheel: 0-36, one green zero. |
| 82 | - The felt is drawn in full and bet with a cursor. Over the numbers the cursor | 82 | - The felt is drawn in full and bet with a cursor. Over the numbers the cursor |
| 83 | sits on a *lattice point*, the way a chip sits between numbers on a real | 83 | walks a grid twice as fine as the felt: on a cell it is a straight, on the |
| 84 | felt: an inner point covers four numbers (corner), an edge point covers two | 84 | line between two cells it is a split, and on a crossing it is a corner. Every |
| 85 | (split), and an outer corner is a straight. | 85 | number and every pair of adjacent numbers is reachable, as on a real felt. |
| 86 | - The status line names the current bet and its odds, for example | ||
| 87 | `Bet: 16/17 ยท pays 18x`. | ||
| 86 | - Bet types and returns (per unit staked, stake included): | 88 | - Bet types and returns (per unit staked, stake included): |
| 87 | 89 | ||
| 88 | | Bet | Covers | Return | | 90 | | Bet | Covers | Return | |
| @@ -202,6 +204,17 @@ ROULETTE_RESULT <session> <winnings> <bet> <balance> <number> <color> <bets-json | |||
| 202 | `black`). The server rejects unknown, out-of-range or duplicate-number | 204 | `black`). The server rejects unknown, out-of-range or duplicate-number |
| 203 | targets, and requires the total to equal the sum of the chips. | 205 | targets, and requires the total to equal the sum of the chips. |
| 204 | 206 | ||
| 207 | The client can read back its settled plays: | ||
| 208 | |||
| 209 | ``` | ||
| 210 | HISTORY [count] | ||
| 211 | HISTORY <json-array> | ||
| 212 | ``` | ||
| 213 | |||
| 214 | `count` defaults to 20 and is capped at 50. Each record carries `game`, `bet`, | ||
| 215 | `winnings`, `balance_after`, `at` (UTC) and the game's own `details` object, | ||
| 216 | newest first. `HISTORY` without a session returns `ERROR Not logged in`. | ||
| 217 | |||
| 205 | Blackjack is a multi-step session: | 218 | Blackjack is a multi-step session: |
| 206 | 219 | ||
| 207 | ``` | 220 | ``` |
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. | |||
| 40 | | `tests/blackjack.rs` | Card totals, naturals, hit/stand/double, dealer bust, S17, state parsing. | | 40 | | `tests/blackjack.rs` | Card totals, naturals, hit/stand/double, dealer bust, S17, state parsing. | |
| 41 | | `tests/crash.rs` | Multiplier curve, payout floor, crash-point distribution, house edge, cap. | | 41 | | `tests/crash.rs` | Multiplier curve, payout floor, crash-point distribution, house edge, cap. | |
| 42 | | `tests/protocol.rs` | Newline buffering and timeout/error classification. | | 42 | | `tests/protocol.rs` | Newline buffering and timeout/error classification. | |
| 43 | | `tests/roulette.rs` | Wheel, every bet type and payout, zero handling, cursor lattice, result parser. | | 43 | | `tests/roulette.rs` | Wheel, every bet type and payout, zero handling, cursor grid (straight/split/corner), result parser. | |
| 44 | | `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | | 44 | | `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | |
| 45 | | `tests/server_setup.rs` | `--init` identity generation, PKCS#12 loadability, overwrite refusal. | | 45 | | `tests/server_setup.rs` | `--init` identity generation, PKCS#12 loadability, overwrite refusal. | |
| 46 | | `tests/slots.rs` | Server slots payout rules and overflow handling. | | 46 | | `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. | |||
| 98 | 1. `play roulette` draws the full felt: 0, the 3x12 numbers, dozens, columns, | 98 | 1. `play roulette` draws the full felt: 0, the 3x12 numbers, dozens, columns, |
| 99 | and the six outside cells, with the cursor on number 1. | 99 | and the six outside cells, with the cursor on number 1. |
| 100 | 2. Arrows move the cursor. On the numbers the highlighted cells show the bet: | 100 | 2. Arrows move the cursor. On the numbers the highlighted cells show the bet: |
| 101 | one number (straight), two (split), four (corner). Down leaves the felt into | 101 | one number (straight), two (split), four (corner). Every number and every |
| 102 | the dozens, then columns, then outside; Up walks back. | 102 | adjacent pair is reachable: from 1, Right is the 1/4 split and Right again is |
| 103 | number 4; Up is the 1/2 split and Up again is number 2. The status line names | ||
| 104 | the bet and its odds. Down leaves the felt into the dozens, then columns, | ||
| 105 | then outside; Up walks back. | ||
| 103 | 3. ENTER places the chip where the cursor is; `C` clears; `+`/`=`/`-` change the | 106 | 3. ENTER places the chip where the cursor is; `C` clears; `+`/`=`/`-` change the |
| 104 | chip size. The bets line lists every chip and the total. | 107 | chip size. The bets line lists every chip and the total. |
| 105 | 4. Space scrolls the wheel under a fixed ball marker and ends on the winning | 108 | 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. | |||
| 113 | 6. The result line names the number, color and parity, the total returned and | 116 | 6. The result line names the number, color and parity, the total returned and |
| 114 | the profit. The next spin reuses the placed chips. | 117 | the profit. The next spin reuses the placed chips. |
| 115 | 118 | ||
| 119 | ## Manual history check | ||
| 120 | |||
| 121 | 1. Play a few rounds across games, then run `history`. | ||
| 122 | 2. The table is newest first and shows the time, game, bet, winnings, balance | ||
| 123 | and a one-line result (`17 black`, `cashed 1.12x`, `none`, `win`). | ||
| 124 | 3. `history 2` shows two rows; `history 0` and `history nope` are rejected | ||
| 125 | without sending anything. A fresh account says `No plays recorded yet.` | ||
| 126 | |||
| 116 | ## Manual terminal size check | 127 | ## Manual terminal size check |
| 117 | 128 | ||
| 118 | 1. Start the client in a terminal smaller than 80x24: it must print | 129 | 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 @@ | |||
| 1 | use crate::client::games::manager::GameManager; | 1 | use crate::client::games::manager::GameManager; |
| 2 | use crate::client::games::response::{ | 2 | use crate::client::games::response::{ |
| 3 | parse_blackjack_response, parse_crash_response, parse_roulette_response, parse_spin_response, | 3 | parse_blackjack_response, parse_crash_response, parse_history_response, |
| 4 | parse_roulette_response, parse_spin_response, | ||
| 4 | }; | 5 | }; |
| 5 | use crate::client::games::roulette::Direction; | 6 | use crate::client::games::roulette::Direction; |
| 6 | use crate::client::ui::{ | 7 | use crate::client::ui::{ |
| 7 | command_names, completion_lines, display_width, help_lines, input_cursor_column, parse_bet_arg, | 8 | command_names, completion_lines, display_width, help_lines, history_lines, |
| 8 | record_history, | 9 | input_cursor_column, parse_bet_arg, record_history, |
| 9 | }; | 10 | }; |
| 10 | use crate::crash::{CrashState, multiplier_at}; | 11 | use crate::crash::{CrashState, multiplier_at}; |
| 12 | use crate::history::MAX_HISTORY; | ||
| 11 | use crate::roulette::Bets; | 13 | use crate::roulette::Bets; |
| 12 | use std::time::{Duration, Instant}; | 14 | use std::time::{Duration, Instant}; |
| 13 | 15 | ||
| @@ -74,6 +76,7 @@ enum Pending { | |||
| 74 | CrashStart, | 76 | CrashStart, |
| 75 | CrashCashout, | 77 | CrashCashout, |
| 76 | CrashForfeit, | 78 | CrashForfeit, |
| 79 | History, | ||
| 77 | Ping, | 80 | Ping, |
| 78 | Echo, | 81 | Echo, |
| 79 | Info, | 82 | Info, |
| @@ -290,6 +293,10 @@ impl ClientCore { | |||
| 290 | self.board_effects() | 293 | self.board_effects() |
| 291 | } | 294 | } |
| 292 | }, | 295 | }, |
| 296 | Some(Pending::History) => match parse_history_response(reply) { | ||
| 297 | Ok(records) => vec![Effect::Print(history_lines(&records))], | ||
| 298 | Err(message) => print_line(message), | ||
| 299 | }, | ||
| 293 | Some(Pending::Ping) => { | 300 | Some(Pending::Ping) => { |
| 294 | if reply.trim() == "PONG" { | 301 | if reply.trim() == "PONG" { |
| 295 | print_line("Pong") | 302 | print_line("Pong") |
| @@ -782,6 +789,7 @@ impl ClientCore { | |||
| 782 | } | 789 | } |
| 783 | } | 790 | } |
| 784 | "info" => self.cmd_info(), | 791 | "info" => self.cmd_info(), |
| 792 | "history" => self.cmd_history(&parts), | ||
| 785 | "hosts" => vec![Effect::ListHosts], | 793 | "hosts" => vec![Effect::ListHosts], |
| 786 | "forget-host" => { | 794 | "forget-host" => { |
| 787 | let target = parts | 795 | let target = parts |
| @@ -851,6 +859,26 @@ impl ClientCore { | |||
| 851 | vec![Effect::Print(lines)] | 859 | vec![Effect::Print(lines)] |
| 852 | } | 860 | } |
| 853 | 861 | ||
| 862 | fn cmd_history(&mut self, parts: &[&str]) -> Vec<Effect> { | ||
| 863 | if !self.connected { | ||
| 864 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | ||
| 865 | } | ||
| 866 | if self.logged_in.is_none() { | ||
| 867 | return print_line("Not logged in. Use 'login' first."); | ||
| 868 | } | ||
| 869 | |||
| 870 | let request = match parts.get(1) { | ||
| 871 | Some(raw) => match raw.parse::<u32>() { | ||
| 872 | Ok(count) if (1..=MAX_HISTORY).contains(&count) => format!("HISTORY {count}"), | ||
| 873 | _ => return print_line(format!("Usage: history [1-{MAX_HISTORY}]")), | ||
| 874 | }, | ||
| 875 | None => "HISTORY".to_string(), | ||
| 876 | }; | ||
| 877 | |||
| 878 | self.pending = Some(Pending::History); | ||
| 879 | vec![Effect::SendLine(request)] | ||
| 880 | } | ||
| 881 | |||
| 854 | fn cmd_info(&mut self) -> Vec<Effect> { | 882 | fn cmd_info(&mut self) -> Vec<Effect> { |
| 855 | if !self.connected { | 883 | if !self.connected { |
| 856 | return print_line("Not connected to any host."); | 884 | 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<crate::crash::CrashState, | |||
| 184 | .ok_or_else(|| format!("Unexpected server response: {response}"))?; | 184 | .ok_or_else(|| format!("Unexpected server response: {response}"))?; |
| 185 | serde_json::from_str(raw).map_err(|error| format!("Invalid crash state: {error}")) | 185 | serde_json::from_str(raw).map_err(|error| format!("Invalid crash state: {error}")) |
| 186 | } | 186 | } |
| 187 | |||
| 188 | pub fn parse_history_response(response: &str) -> Result<Vec<crate::history::PlayRecord>, String> { | ||
| 189 | let response = response.trim(); | ||
| 190 | if response.is_empty() { | ||
| 191 | return Err("No response from server".to_string()); | ||
| 192 | } | ||
| 193 | if let Some(message) = response.strip_prefix("ERROR") { | ||
| 194 | let message = message.trim(); | ||
| 195 | return Err(if message.is_empty() { | ||
| 196 | "Server error".to_string() | ||
| 197 | } else { | ||
| 198 | message.to_string() | ||
| 199 | }); | ||
| 200 | } | ||
| 201 | |||
| 202 | let raw = response | ||
| 203 | .strip_prefix("HISTORY ") | ||
| 204 | .ok_or_else(|| format!("Unexpected server response: {response}"))?; | ||
| 205 | serde_json::from_str(raw).map_err(|error| format!("Invalid history: {error}")) | ||
| 206 | } | ||
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 { | |||
| 30 | 30 | ||
| 31 | /// Where the betting cursor is. | 31 | /// Where the betting cursor is. |
| 32 | /// | 32 | /// |
| 33 | /// Over the numbers the cursor sits on a *lattice point*, the way a chip sits | 33 | /// Over the numbers the cursor walks a grid twice as fine as the felt itself: |
| 34 | /// between numbers on a real felt: an inner point covers four numbers (corner), | 34 | /// odd coordinates are the centre of a number cell and even coordinates are the |
| 35 | /// an edge point covers two (split), and an outer corner is a straight number. | 35 | /// line between cells, exactly where a chip sits on a real felt. |
| 36 | /// | ||
| 37 | /// ```text | ||
| 38 | /// odd,odd the number straight | ||
| 39 | /// even,odd line between columns split of the two numbers | ||
| 40 | /// odd,even line between rows split of the two numbers | ||
| 41 | /// even,even corner of four cells corner | ||
| 42 | /// ``` | ||
| 36 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 43 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 37 | pub enum Cursor { | 44 | pub enum Cursor { |
| 38 | Zero, | 45 | Zero, |
| @@ -42,10 +49,14 @@ pub enum Cursor { | |||
| 42 | Outside(usize), | 49 | Outside(usize), |
| 43 | } | 50 | } |
| 44 | 51 | ||
| 52 | /// Fine-grid bounds: 12 columns and 3 rows of cells, plus the lines between. | ||
| 53 | const FELT_X_MAX: usize = 2 * NUM_COLUMNS - 1; | ||
| 54 | const FELT_Y_MAX: usize = 2 * 3 - 1; | ||
| 55 | |||
| 45 | impl Default for Cursor { | 56 | impl Default for Cursor { |
| 46 | fn default() -> Self { | 57 | fn default() -> Self { |
| 47 | // Bottom-left number (1). | 58 | // Centre of the bottom-left number (1). |
| 48 | Cursor::Lattice { x: 0, y: 3 } | 59 | Cursor::Lattice { x: 1, y: 5 } |
| 49 | } | 60 | } |
| 50 | } | 61 | } |
| 51 | 62 | ||
| @@ -55,7 +66,9 @@ impl Cursor { | |||
| 55 | match parts.as_slice() { | 66 | match parts.as_slice() { |
| 56 | ["zero"] => Cursor::Zero, | 67 | ["zero"] => Cursor::Zero, |
| 57 | ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) { | 68 | ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) { |
| 58 | (Ok(x), Ok(y)) if x <= NUM_COLUMNS && y <= 3 => Cursor::Lattice { x, y }, | 69 | (Ok(x), Ok(y)) if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) => { |
| 70 | Cursor::Lattice { x, y } | ||
| 71 | } | ||
| 59 | _ => Cursor::default(), | 72 | _ => Cursor::default(), |
| 60 | }, | 73 | }, |
| 61 | ["dozen", index] => parse_index(index).map(Cursor::Dozen).unwrap_or_default(), | 74 | ["dozen", index] => parse_index(index).map(Cursor::Dozen).unwrap_or_default(), |
| @@ -81,7 +94,7 @@ impl Cursor { | |||
| 81 | pub fn target(&self) -> Option<BetTarget> { | 94 | pub fn target(&self) -> Option<BetTarget> { |
| 82 | match self { | 95 | match self { |
| 83 | Cursor::Zero => Some(BetTarget::Straight { number: 0 }), | 96 | Cursor::Zero => Some(BetTarget::Straight { number: 0 }), |
| 84 | Cursor::Lattice { x, y } => lattice_target(*x, *y), | 97 | Cursor::Lattice { x, y } => felt_target(*x, *y), |
| 85 | Cursor::Dozen(index) => Some(BetTarget::Dozen { | 98 | Cursor::Dozen(index) => Some(BetTarget::Dozen { |
| 86 | index: *index as u8, | 99 | index: *index as u8, |
| 87 | }), | 100 | }), |
| @@ -103,27 +116,29 @@ impl Cursor { | |||
| 103 | 116 | ||
| 104 | pub fn moved(&self, direction: Direction) -> Cursor { | 117 | pub fn moved(&self, direction: Direction) -> Cursor { |
| 105 | match (*self, direction) { | 118 | match (*self, direction) { |
| 106 | (Cursor::Zero, Direction::Down) => Cursor::Lattice { x: 0, y: 0 }, | 119 | (Cursor::Zero, Direction::Down) => Cursor::Lattice { x: 1, y: 1 }, |
| 107 | (Cursor::Zero, _) => Cursor::Zero, | 120 | (Cursor::Zero, _) => Cursor::Zero, |
| 108 | 121 | ||
| 109 | (Cursor::Lattice { x, y }, Direction::Left) => Cursor::Lattice { | 122 | (Cursor::Lattice { x, y }, Direction::Left) => Cursor::Lattice { |
| 110 | x: x.saturating_sub(1), | 123 | x: if x > 1 { x - 1 } else { x }, |
| 111 | y, | 124 | y, |
| 112 | }, | 125 | }, |
| 113 | (Cursor::Lattice { x, y }, Direction::Right) => Cursor::Lattice { | 126 | (Cursor::Lattice { x, y }, Direction::Right) => Cursor::Lattice { |
| 114 | x: (x + 1).min(NUM_COLUMNS), | 127 | x: (x + 1).min(FELT_X_MAX), |
| 115 | y, | 128 | y, |
| 116 | }, | 129 | }, |
| 117 | (Cursor::Lattice { x: _, y: 0 }, Direction::Up) => Cursor::Zero, | 130 | (Cursor::Lattice { x: _, y: 1 }, Direction::Up) => Cursor::Zero, |
| 118 | (Cursor::Lattice { x, y }, Direction::Up) => Cursor::Lattice { x, y: y - 1 }, | 131 | (Cursor::Lattice { x, y }, Direction::Up) => Cursor::Lattice { x, y: y - 1 }, |
| 119 | (Cursor::Lattice { x, y: 3 }, Direction::Down) => Cursor::Dozen((x / 4).min(2)), | 132 | (Cursor::Lattice { x, y: FELT_Y_MAX }, Direction::Down) => { |
| 133 | Cursor::Dozen((x.saturating_sub(1) / 8).min(2)) | ||
| 134 | } | ||
| 120 | (Cursor::Lattice { x, y }, Direction::Down) => Cursor::Lattice { x, y: y + 1 }, | 135 | (Cursor::Lattice { x, y }, Direction::Down) => Cursor::Lattice { x, y: y + 1 }, |
| 121 | 136 | ||
| 122 | (Cursor::Dozen(index), Direction::Left) => Cursor::Dozen(index.saturating_sub(1)), | 137 | (Cursor::Dozen(index), Direction::Left) => Cursor::Dozen(index.saturating_sub(1)), |
| 123 | (Cursor::Dozen(index), Direction::Right) => Cursor::Dozen((index + 1).min(2)), | 138 | (Cursor::Dozen(index), Direction::Right) => Cursor::Dozen((index + 1).min(2)), |
| 124 | (Cursor::Dozen(index), Direction::Up) => Cursor::Lattice { | 139 | (Cursor::Dozen(index), Direction::Up) => Cursor::Lattice { |
| 125 | x: 4 * index + 1, | 140 | x: 8 * index + 1, |
| 126 | y: 3, | 141 | y: FELT_Y_MAX, |
| 127 | }, | 142 | }, |
| 128 | (Cursor::Dozen(index), Direction::Down) => Cursor::Column(index), | 143 | (Cursor::Dozen(index), Direction::Down) => Cursor::Column(index), |
| 129 | 144 | ||
| @@ -146,30 +161,69 @@ fn parse_index(raw: &str) -> Option<usize> { | |||
| 146 | raw.parse::<usize>().ok().filter(|index| *index < 3) | 161 | raw.parse::<usize>().ok().filter(|index| *index < 3) |
| 147 | } | 162 | } |
| 148 | 163 | ||
| 149 | /// Numbers under a lattice point: 1 (outer corner), 2 (edge) or 4 (inner). | 164 | /// Number at a felt cell (column 0-11, row 0-2 counted from the top). |
| 150 | fn lattice_target(x: usize, y: usize) -> Option<BetTarget> { | 165 | fn cell_number(column: usize, row: usize) -> Option<u8> { |
| 151 | let cell = |column: isize, row: isize| -> Option<u8> { | 166 | if column >= NUM_COLUMNS || row >= 3 { |
| 152 | if !(0..NUM_COLUMNS as isize).contains(&column) || !(0..3isize).contains(&row) { | 167 | return None; |
| 153 | return None; | 168 | } |
| 169 | Some((3 * column + (3 - row)) as u8) | ||
| 170 | } | ||
| 171 | |||
| 172 | /// Bet under a point of the fine grid. | ||
| 173 | fn felt_target(x: usize, y: usize) -> Option<BetTarget> { | ||
| 174 | if !(1..=FELT_X_MAX).contains(&x) || !(1..=FELT_Y_MAX).contains(&y) { | ||
| 175 | return None; | ||
| 176 | } | ||
| 177 | |||
| 178 | let column = (x - 1) / 2; | ||
| 179 | let row = (y - 1) / 2; | ||
| 180 | |||
| 181 | match (x % 2, y % 2) { | ||
| 182 | // Centre of a cell: straight. | ||
| 183 | (1, 1) => cell_number(column, row).map(|number| BetTarget::Straight { number }), | ||
| 184 | // Vertical line: the two cells left and right of it. | ||
| 185 | (0, 1) => { | ||
| 186 | let left = cell_number(x / 2 - 1, row); | ||
| 187 | let right = cell_number(x / 2, row); | ||
| 188 | match (left, right) { | ||
| 189 | (Some(a), Some(b)) => Some(BetTarget::Split { | ||
| 190 | a: a.min(b), | ||
| 191 | b: a.max(b), | ||
| 192 | }), | ||
| 193 | _ => None, | ||
| 194 | } | ||
| 195 | } | ||
| 196 | // Horizontal line: the two cells above and below it. | ||
| 197 | (1, 0) => { | ||
| 198 | let above = cell_number(column, y / 2 - 1); | ||
| 199 | let below = cell_number(column, y / 2); | ||
| 200 | match (above, below) { | ||
| 201 | (Some(a), Some(b)) => Some(BetTarget::Split { | ||
| 202 | a: a.min(b), | ||
| 203 | b: a.max(b), | ||
| 204 | }), | ||
| 205 | _ => None, | ||
| 206 | } | ||
| 207 | } | ||
| 208 | // Crossing: the four cells around it. | ||
| 209 | _ => { | ||
| 210 | let mut numbers: Vec<u8> = [ | ||
| 211 | (x / 2 - 1, y / 2 - 1), | ||
| 212 | (x / 2, y / 2 - 1), | ||
| 213 | (x / 2 - 1, y / 2), | ||
| 214 | (x / 2, y / 2), | ||
| 215 | ] | ||
| 216 | .into_iter() | ||
| 217 | .filter_map(|(column, row)| cell_number(column, row)) | ||
| 218 | .collect(); | ||
| 219 | numbers.sort_unstable(); | ||
| 220 | match numbers.as_slice() { | ||
| 221 | [a, b, c, d] => Some(BetTarget::Corner { | ||
| 222 | numbers: [*a, *b, *c, *d], | ||
| 223 | }), | ||
| 224 | _ => None, | ||
| 225 | } | ||
| 154 | } | 226 | } |
| 155 | Some((3 * column + (3 - row)) as u8) | ||
| 156 | }; | ||
| 157 | |||
| 158 | let x = x as isize; | ||
| 159 | let y = y as isize; | ||
| 160 | let mut numbers: Vec<u8> = [(x - 1, y - 1), (x, y - 1), (x - 1, y), (x, y)] | ||
| 161 | .into_iter() | ||
| 162 | .filter_map(|(column, row)| cell(column, row)) | ||
| 163 | .collect(); | ||
| 164 | numbers.sort_unstable(); | ||
| 165 | |||
| 166 | match numbers.as_slice() { | ||
| 167 | [number] => Some(BetTarget::Straight { number: *number }), | ||
| 168 | [a, b] => Some(BetTarget::Split { a: *a, b: *b }), | ||
| 169 | [a, b, c, d] => Some(BetTarget::Corner { | ||
| 170 | numbers: [*a, *b, *c, *d], | ||
| 171 | }), | ||
| 172 | _ => None, | ||
| 173 | } | 227 | } |
| 174 | } | 228 | } |
| 175 | 229 | ||
| @@ -317,7 +371,18 @@ impl RouletteGame { | |||
| 317 | } | 371 | } |
| 318 | } | 372 | } |
| 319 | Some("spinning") => "Spinning...".to_string(), | 373 | Some("spinning") => "Spinning...".to_string(), |
| 320 | _ => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), | 374 | _ => { |
| 375 | let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); | ||
| 376 | match Self::cursor(state).target() { | ||
| 377 | Some(target) => format!( | ||
| 378 | "Bet: {} ยท pays {} ยท ENTER places {} Kr", | ||
| 379 | target.label(), | ||
| 380 | target.return_label(), | ||
| 381 | chip | ||
| 382 | ), | ||
| 383 | None => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), | ||
| 384 | } | ||
| 385 | } | ||
| 321 | } | 386 | } |
| 322 | } | 387 | } |
| 323 | } | 388 | } |
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 | |||
| @@ -81,6 +81,11 @@ const COMMANDS: &[CommandHelp] = &[ | |||
| 81 | description: "list remembered hosts", | 81 | description: "list remembered hosts", |
| 82 | }, | 82 | }, |
| 83 | CommandHelp { | 83 | CommandHelp { |
| 84 | name: "history", | ||
| 85 | usage: "history [count]", | ||
| 86 | description: "show your recent wins and losses", | ||
| 87 | }, | ||
| 88 | CommandHelp { | ||
| 84 | name: "quit", | 89 | name: "quit", |
| 85 | usage: "quit", | 90 | usage: "quit", |
| 86 | description: "quit the program", | 91 | description: "quit the program", |
| @@ -267,6 +272,30 @@ pub fn terminal_size_warning(columns: u16, rows: u16) -> Option<String> { | |||
| 267 | )) | 272 | )) |
| 268 | } | 273 | } |
| 269 | 274 | ||
| 275 | /// One line per settled play, newest first. | ||
| 276 | pub fn history_lines(records: &[crate::history::PlayRecord]) -> Vec<String> { | ||
| 277 | if records.is_empty() { | ||
| 278 | return vec!["No plays recorded yet.".to_string()]; | ||
| 279 | } | ||
| 280 | |||
| 281 | let mut lines = vec![ | ||
| 282 | format!("Recent plays ({}):", records.len()), | ||
| 283 | " when game bet won balance result".to_string(), | ||
| 284 | ]; | ||
| 285 | for record in records { | ||
| 286 | lines.push(format!( | ||
| 287 | " {:<16} {:<8} {:>6} {:>6} {:>7} {}", | ||
| 288 | record.short_time(), | ||
| 289 | record.game, | ||
| 290 | record.bet, | ||
| 291 | record.winnings, | ||
| 292 | record.balance_after, | ||
| 293 | record.summary() | ||
| 294 | )); | ||
| 295 | } | ||
| 296 | lines | ||
| 297 | } | ||
| 298 | |||
| 270 | /// Render remembered hosts. | 299 | /// Render remembered hosts. |
| 271 | /// | 300 | /// |
| 272 | /// Two addresses that present the same certificate key are the same server, so | 301 | /// 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 @@ | |||
| 1 | //! Play history as sent to the client. | ||
| 2 | |||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | |||
| 5 | pub const DEFAULT_HISTORY: u32 = 20; | ||
| 6 | pub const MAX_HISTORY: u32 = 50; | ||
| 7 | |||
| 8 | /// One settled play. `details` is the game's own audit blob. | ||
| 9 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 10 | pub struct PlayRecord { | ||
| 11 | pub game: String, | ||
| 12 | pub bet: i64, | ||
| 13 | pub winnings: i64, | ||
| 14 | pub balance_after: i64, | ||
| 15 | /// Timestamp as stored in SQLite (`YYYY-MM-DD HH:MM:SS`, UTC). | ||
| 16 | pub at: String, | ||
| 17 | #[serde(default)] | ||
| 18 | pub details: serde_json::Value, | ||
| 19 | } | ||
| 20 | |||
| 21 | impl PlayRecord { | ||
| 22 | /// Short human summary of the game-specific details. | ||
| 23 | pub fn summary(&self) -> String { | ||
| 24 | let field = |name: &str| -> Option<String> { | ||
| 25 | self.details.get(name).map(|value| match value.as_str() { | ||
| 26 | Some(text) => text.to_string(), | ||
| 27 | None => value.to_string(), | ||
| 28 | }) | ||
| 29 | }; | ||
| 30 | |||
| 31 | match self.game.as_str() { | ||
| 32 | "slots" => field("win_type").unwrap_or_else(|| "spin".to_string()), | ||
| 33 | "roulette" => match (field("number"), field("color")) { | ||
| 34 | (Some(number), Some(color)) => format!("{number} {color}"), | ||
| 35 | (Some(number), None) => number, | ||
| 36 | _ => String::new(), | ||
| 37 | }, | ||
| 38 | "blackjack" => field("result").unwrap_or_default(), | ||
| 39 | "crash" => { | ||
| 40 | let result = field("result").unwrap_or_default(); | ||
| 41 | match self.details.get("multiplier").and_then(|value| value.as_f64()) { | ||
| 42 | Some(multiplier) => format!("{result} {multiplier:.2}x"), | ||
| 43 | None => result, | ||
| 44 | } | ||
| 45 | } | ||
| 46 | _ => String::new(), | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | /// `YYYY-MM-DD HH:MM`, dropping the seconds. | ||
| 51 | pub fn short_time(&self) -> &str { | ||
| 52 | self.at.get(..16).unwrap_or(&self.at) | ||
| 53 | } | ||
| 54 | } | ||
| @@ -8,6 +8,7 @@ | |||
| 8 | pub mod blackjack; | 8 | pub mod blackjack; |
| 9 | pub mod client; | 9 | pub mod client; |
| 10 | pub mod crash; | 10 | pub mod crash; |
| 11 | pub mod history; | ||
| 11 | pub mod protocol; | 12 | pub mod protocol; |
| 12 | pub mod roulette; | 13 | pub mod roulette; |
| 13 | pub mod server; | 14 | 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 { | |||
| 140 | } | 140 | } |
| 141 | } | 141 | } |
| 142 | 142 | ||
| 143 | /// Total return for a winning version of this bet. | ||
| 144 | pub fn return_label(&self) -> &'static str { | ||
| 145 | match self { | ||
| 146 | BetTarget::Straight { .. } => "36x", | ||
| 147 | BetTarget::Split { .. } => "18x", | ||
| 148 | BetTarget::Corner { .. } => "9x", | ||
| 149 | BetTarget::Dozen { .. } | BetTarget::Column { .. } => "3x", | ||
| 150 | BetTarget::Outside { .. } => "2x", | ||
| 151 | } | ||
| 152 | } | ||
| 153 | |||
| 143 | pub fn label(&self) -> String { | 154 | pub fn label(&self) -> String { |
| 144 | match self { | 155 | match self { |
| 145 | BetTarget::Straight { number } => number.to_string(), | 156 | 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 { | |||
| 172 | self.handle_auth(state, parts[1], parts[2]) | 172 | self.handle_auth(state, parts[1], parts[2]) |
| 173 | } | 173 | } |
| 174 | } | 174 | } |
| 175 | "HISTORY" => { | ||
| 176 | if parts.len() > 2 { | ||
| 177 | "ERROR Invalid HISTORY format. Usage: HISTORY [count]".to_string() | ||
| 178 | } else { | ||
| 179 | match parts.get(1) { | ||
| 180 | Some(raw) => match raw.parse::<u32>() { | ||
| 181 | Ok(count) if count > 0 => { | ||
| 182 | self.handle_history(state, count.min(crate::history::MAX_HISTORY)) | ||
| 183 | } | ||
| 184 | _ => "ERROR Invalid HISTORY count".to_string(), | ||
| 185 | }, | ||
| 186 | None => self.handle_history(state, crate::history::DEFAULT_HISTORY), | ||
| 187 | } | ||
| 188 | } | ||
| 189 | } | ||
| 175 | "PING" => "PONG".to_string(), | 190 | "PING" => "PONG".to_string(), |
| 176 | "INFO" => self.handle_info(state), | 191 | "INFO" => self.handle_info(state), |
| 177 | _ => msg.to_string(), | 192 | _ => msg.to_string(), |
| @@ -234,6 +249,25 @@ impl ServerCore { | |||
| 234 | } | 249 | } |
| 235 | } | 250 | } |
| 236 | 251 | ||
| 252 | fn handle_history(&mut self, state: &ConnectionState, limit: u32) -> String { | ||
| 253 | let Some(user) = state.user.as_deref() else { | ||
| 254 | return "ERROR Not logged in".to_string(); | ||
| 255 | }; | ||
| 256 | match database::recent_plays(&self.conn, user, limit) { | ||
| 257 | Ok(records) => match serde_json::to_string(&records) { | ||
| 258 | Ok(json) => format!("HISTORY {json}"), | ||
| 259 | Err(error) => { | ||
| 260 | eprintln!("History serialization failed for {user}: {error}"); | ||
| 261 | "ERROR Could not read history".to_string() | ||
| 262 | } | ||
| 263 | }, | ||
| 264 | Err(error) => { | ||
| 265 | eprintln!("History query failed for {user}: {error}"); | ||
| 266 | "ERROR Could not read history".to_string() | ||
| 267 | } | ||
| 268 | } | ||
| 269 | } | ||
| 270 | |||
| 237 | fn handle_info(&self, state: &ConnectionState) -> String { | 271 | fn handle_info(&self, state: &ConnectionState) -> String { |
| 238 | let Some(user) = state.user.as_deref() else { | 272 | let Some(user) = state.user.as_deref() else { |
| 239 | return "ERROR Not logged in".to_string(); | 273 | 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 { | |||
| 399 | } | 399 | } |
| 400 | value.to_string() | 400 | value.to_string() |
| 401 | } | 401 | } |
| 402 | |||
| 403 | /// Most recent settled plays for a user, newest first. | ||
| 404 | pub fn recent_plays( | ||
| 405 | conn: &Connection, | ||
| 406 | username: &str, | ||
| 407 | limit: u32, | ||
| 408 | ) -> rusqlite::Result<Vec<crate::history::PlayRecord>> { | ||
| 409 | let mut statement = conn.prepare( | ||
| 410 | "SELECT game_type, bet_amount, winnings, balance_after, details, created_at | ||
| 411 | FROM plays | ||
| 412 | WHERE username = ?1 | ||
| 413 | ORDER BY created_at DESC, rowid DESC | ||
| 414 | LIMIT ?2", | ||
| 415 | )?; | ||
| 416 | let rows = statement.query_map(params![username, limit], |row| { | ||
| 417 | let details: String = row.get(4)?; | ||
| 418 | Ok(crate::history::PlayRecord { | ||
| 419 | game: row.get(0)?, | ||
| 420 | bet: row.get(1)?, | ||
| 421 | winnings: row.get(2)?, | ||
| 422 | balance_after: row.get(3)?, | ||
| 423 | at: row.get(5)?, | ||
| 424 | details: serde_json::from_str(&details).unwrap_or(serde_json::Value::Null), | ||
| 425 | }) | ||
| 426 | })?; | ||
| 427 | rows.collect() | ||
| 428 | } | ||
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() { | |||
| 608 | assert!(h.board()[1].contains("Balance: 1000 Kr")); | 608 | assert!(h.board()[1].contains("Balance: 1000 Kr")); |
| 609 | assert!(h.board()[1].contains("Chip: 10 Kr")); | 609 | assert!(h.board()[1].contains("Chip: 10 Kr")); |
| 610 | assert!(h.board()[9].contains("Bets: (none)")); | 610 | assert!(h.board()[9].contains("Bets: (none)")); |
| 611 | // The cursor starts on number 1, the bottom-left corner. | 611 | // The cursor starts on number 1, the bottom-left cell. |
| 612 | assert!(h.board()[5].contains("[ 1 ]")); | 612 | assert!(h.board()[5].contains("[ 1 ]")); |
| 613 | assert!(h.board()[10].contains("Bet: 1 ยท pays 36x")); | ||
| 613 | 614 | ||
| 614 | // Up twice: straight 2, then the split between 2 and 3. | 615 | // One step up is the split between 1 and 2, two steps is number 2. |
| 615 | h.key(Key::Up); | 616 | h.key(Key::Up); |
| 617 | assert!(h.board()[5].contains("[ 1 ]")); | ||
| 618 | assert!(h.board()[4].contains("[ 2 ]")); | ||
| 619 | assert!(h.board()[10].contains("Bet: 1/2 ยท pays 18x")); | ||
| 620 | |||
| 616 | h.key(Key::Up); | 621 | h.key(Key::Up); |
| 622 | assert!(!h.board()[5].contains("[ 1 ]")); | ||
| 617 | assert!(h.board()[4].contains("[ 2 ]")); | 623 | assert!(h.board()[4].contains("[ 2 ]")); |
| 618 | assert!(h.board()[3].contains("[ 3 ]")); | 624 | assert!(h.board()[10].contains("Bet: 2 ยท pays 36x")); |
| 619 | 625 | ||
| 620 | h.key(Key::Enter); | 626 | h.key(Key::Enter); |
| 621 | assert!(h.board()[9].contains("2/3 10")); | 627 | assert!(h.board()[9].contains("2 10")); |
| 622 | assert!(h.board()[9].contains("Total: 10 Kr")); | ||
| 623 | 628 | ||
| 624 | // Back down to number 1 and place a straight. | 629 | // Right from 2 is the split with 5. |
| 625 | h.key(Key::Down); | 630 | h.key(Key::Right); |
| 626 | h.key(Key::Down); | 631 | assert!(h.board()[10].contains("Bet: 2/5 ยท pays 18x")); |
| 627 | h.key(Key::Enter); | 632 | h.key(Key::Enter); |
| 628 | assert!(h.board()[9].contains("1 10")); | 633 | assert!(h.board()[9].contains("2/5 10")); |
| 629 | assert!(h.board()[9].contains("Total: 20 Kr")); | 634 | assert!(h.board()[9].contains("Total: 20 Kr")); |
| 630 | 635 | ||
| 631 | h.key(Key::Char(' ')); | 636 | h.key(Key::Char(' ')); |
| @@ -636,18 +641,17 @@ fn roulette_cursor_places_bets_and_spins() { | |||
| 636 | .expect("roulette request"); | 641 | .expect("roulette request"); |
| 637 | assert_eq!( | 642 | assert_eq!( |
| 638 | request, | 643 | request, |
| 639 | "PLAY_START roulette 20 {\"chips\":[{\"target\":{\"bet\":\"split\",\"a\":2,\"b\":3},\"amount\":10},{\"target\":{\"bet\":\"straight\",\"number\":1},\"amount\":10}]}" | 644 | "PLAY_START roulette 20 {\"chips\":[{\"target\":{\"bet\":\"straight\",\"number\":2},\"amount\":10},{\"target\":{\"bet\":\"split\",\"a\":2,\"b\":5},\"amount\":10}]}" |
| 640 | ); | 645 | ); |
| 641 | 646 | ||
| 642 | let bets = r#"{"chips":[{"target":{"bet":"split","a":2,"b":3},"amount":10},{"target":{"bet":"straight","number":1},"amount":10}]}"#; | 647 | let bets = r#"{"chips":[{"target":{"bet":"straight","number":2},"amount":10},{"target":{"bet":"split","a":2,"b":5},"amount":10}]}"#; |
| 643 | h.reply(&format!( | 648 | h.reply(&format!( |
| 644 | "ROULETTE_RESULT session-1 180 20 1160 3 red {bets}" | 649 | "ROULETTE_RESULT session-1 540 20 1520 2 black {bets}" |
| 645 | )); | 650 | )); |
| 646 | 651 | ||
| 647 | assert_eq!(h.board().len(), 12); | 652 | assert!(h.board()[1].contains("Balance: 1520 Kr")); |
| 648 | assert!(h.board()[1].contains("Balance: 1160 Kr")); | 653 | assert!(h.board()[10].contains("Won 540 Kr at 2 BLACK EVEN")); |
| 649 | assert!(h.board()[10].contains("Won 180 Kr at 3 RED ODD")); | 654 | assert!(h.board()[10].contains("+520")); |
| 650 | assert!(h.board()[10].contains("+160")); | ||
| 651 | } | 655 | } |
| 652 | 656 | ||
| 653 | #[test] | 657 | #[test] |
| @@ -658,19 +662,18 @@ fn roulette_cursor_reaches_the_outside_rows() { | |||
| 658 | h.key(Key::Down); | 662 | h.key(Key::Down); |
| 659 | assert!(h.board()[6].starts_with("[") && h.board()[6].contains("1st 12")); | 663 | assert!(h.board()[6].starts_with("[") && h.board()[6].contains("1st 12")); |
| 660 | h.key(Key::Down); | 664 | h.key(Key::Down); |
| 661 | assert!(h.board()[7].starts_with("[") && h.board()[7].contains("2:1")); | 665 | assert!(h.board()[7].starts_with("[") && h.board()[7].contains("col 1 2:1")); |
| 662 | h.key(Key::Down); | 666 | h.key(Key::Down); |
| 663 | assert!(h.board()[8].starts_with("[") && h.board()[8].contains("1-18")); | 667 | assert!(h.board()[8].starts_with("[") && h.board()[8].contains("1-18")); |
| 664 | 668 | ||
| 665 | h.key(Key::Enter); | 669 | h.key(Key::Enter); |
| 666 | assert!(h.board()[9].contains("1-18 10")); | 670 | assert!(h.board()[9].contains("1-18 10")); |
| 667 | 671 | ||
| 668 | // Back up to the felt: the bottom edge of the first dozen is the 1/4 split. | 672 | // Back up to the felt: the dozens return to number 1. |
| 669 | h.key(Key::Up); | 673 | h.key(Key::Up); |
| 670 | h.key(Key::Up); | 674 | h.key(Key::Up); |
| 671 | h.key(Key::Up); | 675 | h.key(Key::Up); |
| 672 | assert!(h.board()[5].contains("[ 1 ]")); | 676 | assert!(h.board()[5].contains("[ 1 ]")); |
| 673 | assert!(h.board()[5].contains("[ 4 ]")); | ||
| 674 | } | 677 | } |
| 675 | 678 | ||
| 676 | #[test] | 679 | #[test] |
| @@ -1146,3 +1149,52 @@ fn a_round_is_live_only_while_money_is_reserved() { | |||
| 1146 | h.push(&crash_line("CRASH_BUSTED", &busted)); | 1149 | h.push(&crash_line("CRASH_BUSTED", &busted)); |
| 1147 | assert!(!h.core.is_live_game()); | 1150 | assert!(!h.core.is_live_game()); |
| 1148 | } | 1151 | } |
| 1152 | |||
| 1153 | #[test] | ||
| 1154 | fn history_prints_recent_plays() { | ||
| 1155 | let mut h = Harness::new(); | ||
| 1156 | h.line("history"); | ||
| 1157 | assert_eq!(h.sent.last().map(String::as_str), Some("HISTORY")); | ||
| 1158 | |||
| 1159 | let json = r#"[{"game":"roulette","bet":10,"winnings":20,"balance_after":1010,"at":"2026-10-30 01:20:11","details":{"number":"17","color":"black"}}]"#; | ||
| 1160 | h.reply(&format!("HISTORY {json}")); | ||
| 1161 | |||
| 1162 | assert!(h | ||
| 1163 | .screen | ||
| 1164 | .lines | ||
| 1165 | .iter() | ||
| 1166 | .any(|line| line.starts_with("Recent plays (1)"))); | ||
| 1167 | let row = h | ||
| 1168 | .screen | ||
| 1169 | .lines | ||
| 1170 | .iter() | ||
| 1171 | .find(|line| line.contains("roulette")) | ||
| 1172 | .expect("play row"); | ||
| 1173 | assert!(row.contains("2026-10-30 01:20"), "row: {row}"); | ||
| 1174 | assert!(row.contains("1010"), "row: {row}"); | ||
| 1175 | assert!(row.contains("17 black"), "row: {row}"); | ||
| 1176 | } | ||
| 1177 | |||
| 1178 | #[test] | ||
| 1179 | fn history_without_plays_says_so() { | ||
| 1180 | let mut h = Harness::new(); | ||
| 1181 | h.line("history"); | ||
| 1182 | h.reply("HISTORY []"); | ||
| 1183 | assert!(h | ||
| 1184 | .screen | ||
| 1185 | .lines | ||
| 1186 | .iter() | ||
| 1187 | .any(|line| line == "No plays recorded yet.")); | ||
| 1188 | } | ||
| 1189 | |||
| 1190 | #[test] | ||
| 1191 | fn history_rejects_a_bad_count_without_sending() { | ||
| 1192 | let mut h = Harness::new(); | ||
| 1193 | h.line("history 0"); | ||
| 1194 | assert!(!h.sent.iter().any(|line| line == "HISTORY 0")); | ||
| 1195 | assert!(h | ||
| 1196 | .screen | ||
| 1197 | .lines | ||
| 1198 | .iter() | ||
| 1199 | .any(|line| line.contains("Usage: history [1-50]"))); | ||
| 1200 | } | ||
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() { | |||
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | #[test] | 40 | #[test] |
| 41 | fn cashout_value_is_floored_to_whole_Kr() { | 41 | fn cashout_value_is_floored_to_whole_kr() { |
| 42 | assert_eq!(payout(10, 1.0), 10); | 42 | assert_eq!(payout(10, 1.0), 10); |
| 43 | assert_eq!(payout(10, 1.05), 10, "half a Kr is not paid out"); | 43 | assert_eq!(payout(10, 1.05), 10, "half a Kr is not paid out"); |
| 44 | assert_eq!(payout(10, 1.10), 11); | 44 | 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 { | |||
| 234 | } | 234 | } |
| 235 | 235 | ||
| 236 | #[test] | 236 | #[test] |
| 237 | fn lattice_points_become_straights_splits_and_corners() { | 237 | fn cell_centres_are_straights() { |
| 238 | assert_eq!( | 238 | assert_eq!(target_at(1, 5), BetTarget::Straight { number: 1 }); |
| 239 | target_at(0, 3), | 239 | assert_eq!(target_at(1, 1), BetTarget::Straight { number: 3 }); |
| 240 | BetTarget::Straight { number: 1 }, | 240 | assert_eq!(target_at(23, 1), BetTarget::Straight { number: 36 }); |
| 241 | "bottom-left corner is a straight" | 241 | assert_eq!(target_at(23, 5), BetTarget::Straight { number: 34 }); |
| 242 | ); | 242 | assert_eq!(target_at(5, 3), BetTarget::Straight { number: 8 }); |
| 243 | assert_eq!(target_at(0, 0), BetTarget::Straight { number: 3 }); | 243 | |
| 244 | assert_eq!(target_at(12, 0), BetTarget::Straight { number: 36 }); | 244 | // Every one of the 36 numbers can be bet straight. |
| 245 | assert_eq!(target_at(12, 3), BetTarget::Straight { number: 34 }); | 245 | let mut reachable = Vec::new(); |
| 246 | 246 | for column in 0..12usize { | |
| 247 | assert_eq!(target_at(0, 1), BetTarget::Split { a: 2, b: 3 }); | 247 | for row in 0..3usize { |
| 248 | assert_eq!(target_at(12, 1), BetTarget::Split { a: 35, b: 36 }); | 248 | let cursor = Cursor::Lattice { |
| 249 | assert_eq!(target_at(1, 0), BetTarget::Split { a: 3, b: 6 }); | 249 | x: 2 * column + 1, |
| 250 | y: 2 * row + 1, | ||
| 251 | }; | ||
| 252 | if let Some(BetTarget::Straight { number }) = cursor.target() { | ||
| 253 | reachable.push(number); | ||
| 254 | } | ||
| 255 | } | ||
| 256 | } | ||
| 257 | reachable.sort_unstable(); | ||
| 258 | assert_eq!(reachable, (1..=36).collect::<Vec<_>>()); | ||
| 259 | } | ||
| 250 | 260 | ||
| 261 | #[test] | ||
| 262 | fn lines_between_cells_are_splits_and_crossings_are_corners() { | ||
| 263 | // Vertical lines sit between two columns. | ||
| 264 | assert_eq!(target_at(2, 5), BetTarget::Split { a: 1, b: 4 }); | ||
| 265 | assert_eq!(target_at(2, 3), BetTarget::Split { a: 2, b: 5 }); | ||
| 266 | assert_eq!(target_at(22, 1), BetTarget::Split { a: 33, b: 36 }); | ||
| 267 | |||
| 268 | // Horizontal lines sit between two rows. | ||
| 269 | assert_eq!(target_at(1, 4), BetTarget::Split { a: 1, b: 2 }); | ||
| 270 | assert_eq!(target_at(3, 2), BetTarget::Split { a: 5, b: 6 }); | ||
| 271 | assert_eq!(target_at(23, 4), BetTarget::Split { a: 34, b: 35 }); | ||
| 272 | |||
| 273 | // Crossings cover four cells. | ||
| 251 | assert_eq!( | 274 | assert_eq!( |
| 252 | target_at(1, 1), | 275 | target_at(2, 4), |
| 253 | BetTarget::Corner { | 276 | BetTarget::Corner { |
| 254 | numbers: [2, 3, 5, 6] | 277 | numbers: [1, 2, 4, 5] |
| 255 | } | 278 | } |
| 256 | ); | 279 | ); |
| 257 | assert_eq!( | 280 | assert_eq!( |
| 258 | target_at(6, 2), | 281 | target_at(12, 4), |
| 259 | BetTarget::Corner { | 282 | BetTarget::Corner { |
| 260 | numbers: [16, 17, 19, 20] | 283 | numbers: [16, 17, 19, 20] |
| 261 | } | 284 | } |
| @@ -264,31 +287,54 @@ fn lattice_points_become_straights_splits_and_corners() { | |||
| 264 | 287 | ||
| 265 | #[test] | 288 | #[test] |
| 266 | fn the_cursor_walks_the_felt_and_the_outside_rows() { | 289 | fn the_cursor_walks_the_felt_and_the_outside_rows() { |
| 267 | let bottom = Cursor::Lattice { x: 0, y: 3 }; | 290 | let bottom = Cursor::Lattice { x: 1, y: 5 }; |
| 291 | // One step right is the split between 1 and 4, two steps is number 4. | ||
| 292 | assert_eq!( | ||
| 293 | bottom.moved(Direction::Right), | ||
| 294 | Cursor::Lattice { x: 2, y: 5 } | ||
| 295 | ); | ||
| 296 | assert_eq!( | ||
| 297 | bottom.moved(Direction::Right).moved(Direction::Right), | ||
| 298 | Cursor::Lattice { x: 3, y: 5 } | ||
| 299 | ); | ||
| 300 | // One step up is the split between 1 and 2. | ||
| 301 | assert_eq!(bottom.moved(Direction::Up), Cursor::Lattice { x: 1, y: 4 }); | ||
| 302 | |||
| 268 | assert_eq!(bottom.moved(Direction::Down), Cursor::Dozen(0)); | 303 | assert_eq!(bottom.moved(Direction::Down), Cursor::Dozen(0)); |
| 269 | assert_eq!( | 304 | assert_eq!( |
| 270 | Cursor::Lattice { x: 12, y: 3 }.moved(Direction::Down), | 305 | Cursor::Lattice { x: 23, y: 5 }.moved(Direction::Down), |
| 271 | Cursor::Dozen(2) | 306 | Cursor::Dozen(2) |
| 272 | ); | 307 | ); |
| 273 | assert_eq!( | 308 | assert_eq!( |
| 274 | Cursor::Lattice { x: 0, y: 0 }.moved(Direction::Up), | 309 | Cursor::Lattice { x: 1, y: 1 }.moved(Direction::Up), |
| 275 | Cursor::Zero | 310 | Cursor::Zero |
| 276 | ); | 311 | ); |
| 277 | assert_eq!(Cursor::Zero.moved(Direction::Down), Cursor::Lattice { x: 0, y: 0 }); | 312 | assert_eq!( |
| 313 | Cursor::Zero.moved(Direction::Down), | ||
| 314 | Cursor::Lattice { x: 1, y: 1 } | ||
| 315 | ); | ||
| 278 | 316 | ||
| 279 | assert_eq!(Cursor::Dozen(0).moved(Direction::Up), Cursor::Lattice { x: 1, y: 3 }); | 317 | assert_eq!( |
| 318 | Cursor::Dozen(0).moved(Direction::Up), | ||
| 319 | Cursor::Lattice { x: 1, y: 5 } | ||
| 320 | ); | ||
| 280 | assert_eq!(Cursor::Dozen(0).moved(Direction::Down), Cursor::Column(0)); | 321 | assert_eq!(Cursor::Dozen(0).moved(Direction::Down), Cursor::Column(0)); |
| 322 | assert_eq!(Cursor::Dozen(2).moved(Direction::Up), Cursor::Lattice { x: 17, y: 5 }); | ||
| 281 | assert_eq!(Cursor::Column(2).moved(Direction::Up), Cursor::Dozen(2)); | 323 | assert_eq!(Cursor::Column(2).moved(Direction::Up), Cursor::Dozen(2)); |
| 282 | assert_eq!(Cursor::Column(1).moved(Direction::Down), Cursor::Outside(2)); | 324 | assert_eq!(Cursor::Column(1).moved(Direction::Down), Cursor::Outside(2)); |
| 283 | assert_eq!(Cursor::Outside(3).moved(Direction::Up), Cursor::Column(1)); | 325 | assert_eq!(Cursor::Outside(3).moved(Direction::Up), Cursor::Column(1)); |
| 284 | 326 | ||
| 285 | // Edges stay put instead of wrapping. | 327 | // Edges stay put instead of wrapping. |
| 286 | assert_eq!(Cursor::Dozen(0).moved(Direction::Left), Cursor::Dozen(0)); | ||
| 287 | assert_eq!(Cursor::Outside(5).moved(Direction::Right), Cursor::Outside(5)); | ||
| 288 | assert_eq!( | 328 | assert_eq!( |
| 289 | Cursor::Lattice { x: 0, y: 2 }.moved(Direction::Left), | 329 | Cursor::Lattice { x: 1, y: 3 }.moved(Direction::Left), |
| 290 | Cursor::Lattice { x: 0, y: 2 } | 330 | Cursor::Lattice { x: 1, y: 3 } |
| 331 | ); | ||
| 332 | assert_eq!( | ||
| 333 | Cursor::Lattice { x: 23, y: 3 }.moved(Direction::Right), | ||
| 334 | Cursor::Lattice { x: 23, y: 3 } | ||
| 291 | ); | 335 | ); |
| 336 | assert_eq!(Cursor::Dozen(0).moved(Direction::Left), Cursor::Dozen(0)); | ||
| 337 | assert_eq!(Cursor::Outside(5).moved(Direction::Right), Cursor::Outside(5)); | ||
| 292 | } | 338 | } |
| 293 | 339 | ||
| 294 | #[test] | 340 | #[test] |
| @@ -305,6 +351,7 @@ fn every_cursor_round_trips_through_its_encoded_form() { | |||
| 305 | } | 351 | } |
| 306 | assert_eq!(Cursor::parse("nonsense"), Cursor::default()); | 352 | assert_eq!(Cursor::parse("nonsense"), Cursor::default()); |
| 307 | assert_eq!(Cursor::parse("lattice:99:0"), Cursor::default()); | 353 | assert_eq!(Cursor::parse("lattice:99:0"), Cursor::default()); |
| 354 | assert_eq!(Cursor::parse("lattice:0:1"), Cursor::default()); | ||
| 308 | assert_eq!(Cursor::parse("dozen:7"), Cursor::default()); | 355 | assert_eq!(Cursor::parse("dozen:7"), Cursor::default()); |
| 309 | } | 356 | } |
| 310 | 357 | ||
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() { | |||
| 871 | .unwrap(); | 871 | .unwrap(); |
| 872 | assert_eq!(pending, 0); | 872 | assert_eq!(pending, 0); |
| 873 | } | 873 | } |
| 874 | |||
| 875 | #[test] | ||
| 876 | fn history_requires_login_and_valid_counts() { | ||
| 877 | let (mut core, _path) = setup("history-guards", WIN3); | ||
| 878 | let mut state = ConnectionState::default(); | ||
| 879 | |||
| 880 | assert_eq!( | ||
| 881 | core.handle_message(&mut state, "HISTORY"), | ||
| 882 | "ERROR Not logged in" | ||
| 883 | ); | ||
| 884 | |||
| 885 | register(&mut core, &mut state, "alice"); | ||
| 886 | login(&mut core, &mut state, "alice"); | ||
| 887 | |||
| 888 | assert_eq!( | ||
| 889 | core.handle_message(&mut state, "HISTORY nope"), | ||
| 890 | "ERROR Invalid HISTORY count" | ||
| 891 | ); | ||
| 892 | assert_eq!( | ||
| 893 | core.handle_message(&mut state, "HISTORY 0"), | ||
| 894 | "ERROR Invalid HISTORY count" | ||
| 895 | ); | ||
| 896 | assert_eq!( | ||
| 897 | core.handle_message(&mut state, "HISTORY 1 2"), | ||
| 898 | "ERROR Invalid HISTORY format. Usage: HISTORY [count]" | ||
| 899 | ); | ||
| 900 | } | ||
| 901 | |||
| 902 | #[test] | ||
| 903 | fn history_lists_recent_plays_newest_first() { | ||
| 904 | let (mut core, _path) = setup("history-list", WIN3); | ||
| 905 | let mut state = ConnectionState::default(); | ||
| 906 | register(&mut core, &mut state, "alice"); | ||
| 907 | login(&mut core, &mut state, "alice"); | ||
| 908 | |||
| 909 | for _ in 0..3 { | ||
| 910 | assert!(core | ||
| 911 | .handle_message(&mut state, "PLAY_START slots 10") | ||
| 912 | .starts_with("GAME_RESULT")); | ||
| 913 | } | ||
| 914 | |||
| 915 | let response = core.handle_message(&mut state, "HISTORY 2"); | ||
| 916 | let json = response.strip_prefix("HISTORY ").expect("history response"); | ||
| 917 | let records: Vec<zagan_bet::history::PlayRecord> = serde_json::from_str(json).unwrap(); | ||
| 918 | |||
| 919 | assert_eq!(records.len(), 2, "the limit must be honoured"); | ||
| 920 | for record in &records { | ||
| 921 | assert_eq!(record.game, "slots"); | ||
| 922 | assert_eq!(record.bet, 10); | ||
| 923 | assert_eq!(record.winnings, 10, "three C pay the stake back"); | ||
| 924 | assert_eq!(record.summary(), "win"); | ||
| 925 | assert_eq!(record.details["win_type"], "win"); | ||
| 926 | } | ||
| 927 | |||
| 928 | // Newest first: the timestamps must not increase across the list. | ||
| 929 | assert!(records[0].at >= records[1].at); | ||
| 930 | } | ||
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) { | |||
| 318 | <?php $all = array_merge($releases['client'], $releases['server']); ?> | 318 | <?php $all = array_merge($releases['client'], $releases['server']); ?> |
| 319 | <?php if ($all !== []): ?> | 319 | <?php if ($all !== []): ?> |
| 320 | <h2>SHA-256</h2> | 320 | <h2>SHA-256</h2> |
| 321 | <p>Save this block as <code>SHA256SUMS</code> and run <code>sha256sum -c SHA256SUMS</code>. On Windows use <code>Get-FileHash <file></code>.</p> | 321 | <p>On Linux use <code>sha256sum <file></code>. On Windows use <code>Get-FileHash <file></code>.</p> |
| 322 | <p>Then simply check if the checksum matches with your file.</p> | ||
| 322 | <pre><?php | 323 | <pre><?php |
| 323 | $sums = ''; | 324 | $sums = ''; |
| 324 | foreach ($all as $release) { | 325 | foreach ($all as $release) { |
