summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 16:17:19 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 16:17:19 +0100
commitf07221b4e1d8aa464a44fe566884ccc0565406e9 (patch)
tree48020a68cee1c95521d594dc6803c1f7ec01c801
parentadd nginx + php-fpm deployment example (diff)
downloadzagan-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.toml2
-rw-r--r--docs/GAMES.md19
-rw-r--r--docs/TESTING.md17
-rw-r--r--src/client/core.rs34
-rw-r--r--src/client/games/response.rs20
-rw-r--r--src/client/games/roulette.rs141
-rw-r--r--src/client/ui.rs29
-rw-r--r--src/history.rs54
-rw-r--r--src/lib.rs1
-rw-r--r--src/roulette.rs11
-rw-r--r--src/server/core.rs34
-rw-r--r--src/server/database.rs27
-rw-r--r--tests/client_core.rs90
-rw-r--r--tests/crash.rs2
-rw-r--r--tests/roulette.rs97
-rw-r--r--tests/server_core.rs57
-rw-r--r--web/index.php3
17 files changed, 544 insertions, 94 deletions
diff --git a/Cargo.toml b/Cargo.toml
index d13a16c..8bf04dc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "3.1.1" 3version = "3.2.0"
4edition = "2024" 4edition = "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
203targets, and requires the total to equal the sum of the chips. 205targets, and requires the total to equal the sum of the chips.
204 206
207The client can read back its settled plays:
208
209```
210HISTORY [count]
211HISTORY <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,
216newest first. `HISTORY` without a session returns `ERROR Not logged in`.
217
205Blackjack is a multi-step session: 218Blackjack 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.
981. `play roulette` draws the full felt: 0, the 3x12 numbers, dozens, columns, 981. `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.
1002. Arrows move the cursor. On the numbers the highlighted cells show the bet: 1002. 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.
1033. ENTER places the chip where the cursor is; `C` clears; `+`/`=`/`-` change the 1063. 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.
1054. Space scrolls the wheel under a fixed ball marker and ends on the winning 1084. 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.
1136. The result line names the number, color and parity, the total returned and 1166. 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
1211. Play a few rounds across games, then run `history`.
1222. 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`).
1243. `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
1181. Start the client in a terminal smaller than 80x24: it must print 1291. 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 @@
1use crate::client::games::manager::GameManager; 1use crate::client::games::manager::GameManager;
2use crate::client::games::response::{ 2use 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};
5use crate::client::games::roulette::Direction; 6use crate::client::games::roulette::Direction;
6use crate::client::ui::{ 7use 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};
10use crate::crash::{CrashState, multiplier_at}; 11use crate::crash::{CrashState, multiplier_at};
12use crate::history::MAX_HISTORY;
11use crate::roulette::Bets; 13use crate::roulette::Bets;
12use std::time::{Duration, Instant}; 14use 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
188pub 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)]
37pub enum Cursor { 44pub 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.
53const FELT_X_MAX: usize = 2 * NUM_COLUMNS - 1;
54const FELT_Y_MAX: usize = 2 * 3 - 1;
55
45impl Default for Cursor { 56impl 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).
150fn lattice_target(x: usize, y: usize) -> Option<BetTarget> { 165fn 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.
173fn 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.
276pub 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
3use serde::{Deserialize, Serialize};
4
5pub const DEFAULT_HISTORY: u32 = 20;
6pub 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)]
10pub 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
21impl 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}
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 @@
8pub mod blackjack; 8pub mod blackjack;
9pub mod client; 9pub mod client;
10pub mod crash; 10pub mod crash;
11pub mod history;
11pub mod protocol; 12pub mod protocol;
12pub mod roulette; 13pub mod roulette;
13pub mod server; 14pub 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.
404pub 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]
1154fn 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]
1179fn 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]
1191fn 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]
41fn cashout_value_is_floored_to_whole_Kr() { 41fn 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]
237fn lattice_points_become_straights_splits_and_corners() { 237fn 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]
262fn 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]
266fn the_cursor_walks_the_felt_and_the_outside_rows() { 289fn 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]
876fn 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]
903fn 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 &lt;file&gt;</code>.</p> 321<p>On Linux use <code>sha256sum &lt;file&gt;</code>. On Windows use <code>Get-FileHash &lt;file&gt;</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 = '';
324foreach ($all as $release) { 325foreach ($all as $release) {