summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 22:55:49 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 22:55:49 +0100
commit49df948de25d28601469e9ca993f33530989ebd0 (patch)
tree291aae0f57d0d7d12a27eb28038e43f942d6b991
parentv2.4.0: key stored credentials by certificate fingerprint (diff)
downloadzagan-bet-49df948de25d28601469e9ca993f33530989ebd0.tar.gz
zagan-bet-49df948de25d28601469e9ca993f33530989ebd0.zip
v3.0.0: cursor-driven roulette felt with splits and corners; drop schema migrations; require 80x24
-rw-r--r--AGENTS.md8
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GAMES.md43
-rw-r--r--docs/SERVER_SETUP.md5
-rw-r--r--docs/TESTING.md37
-rw-r--r--src/bin/client/connection.rs2
-rw-r--r--src/bin/client/main.rs15
-rw-r--r--src/bin/client/repl.rs2
-rw-r--r--src/client/core.rs102
-rw-r--r--src/client/database.rs41
-rw-r--r--src/client/games/manager.rs17
-rw-r--r--src/client/games/roulette.rs346
-rw-r--r--src/client/ui.rs17
-rw-r--r--src/roulette.rs215
-rw-r--r--src/server/database.rs6
-rw-r--r--src/server/games/roulette.rs2
-rw-r--r--tests/client_core.rs86
-rw-r--r--tests/client_ui.rs52
-rw-r--r--tests/roulette.rs334
-rw-r--r--tests/server_core.rs10
-rw-r--r--tests/storage.rs49
21 files changed, 957 insertions, 434 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 2a70ba2..8de7a2d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -56,6 +56,14 @@
56 count lines against the same string the cursor will erase. 56 count lines against the same string the cursor will erase.
57 Prefer returning a fixed known count for the game's static layout and 57 Prefer returning a fixed known count for the game's static layout and
58 letting dynamic content fill it, rather than inferring the count. 58 letting dynamic content fill it, rather than inferring the count.
59- The client refuses to start below 80x24 (`terminal_too_small`). Every game
60 board must fit in that size; a new board wider or taller than the minimum
61 must raise the minimum in the same change.
62
63## Schema
64- The app is in alpha: do not write schema migrations. `init_db` creates the
65 current shape; a schema change means deleting `server.db` and `client.db`.
66 Files written by an older build are not supported.
59 67
60## Error Paths 68## Error Paths
61- When a game action fails after a balance deduction, the refund must happen 69- When a game action fails after a balance deduction, the refund must happen
diff --git a/Cargo.toml b/Cargo.toml
index e0e17a7..69df089 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "2.4.0" 3version = "3.0.0"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
diff --git a/docs/GAMES.md b/docs/GAMES.md
index 2b8adf5..364cfef 100644
--- a/docs/GAMES.md
+++ b/docs/GAMES.md
@@ -79,19 +79,32 @@ Q forfeit an active hand (bet is lost), or leave when no hand is active
79## Roulette 79## Roulette
80 80
81- European wheel: 0-36, one green zero. 81- European wheel: 0-36, one green zero.
82- Bets: red, black, even, odd, and straight numbers. 82- The felt is drawn in full and bet with a cursor. Over the numbers the cursor
83- Bets can be split across all of those; every winning bet is added together. 83 sits on a *lattice point*, the way a chip sits between numbers on a real
84- Straight pays 35:1 (36x returned), outside bets pay 1:1 (2x returned). 84 felt: an inner point covers four numbers (corner), an edge point covers two
85- 0 loses all outside bets. RTP is 36/37 = 97.30%. 85 (split), and an outer corner is a straight.
86- Numbers are color-coded in the TUI: red numbers are red, 0 is green, and 86- Bet types and returns (per unit staked, stake included):
87 black numbers stay at the default terminal color. Bet amounts and balances 87
88 are not colored. 88 | Bet | Covers | Return |
89 | --- | --- | --- |
90 | Straight | one number | 36x |
91 | Split | two adjacent numbers | 18x |
92 | Corner | four numbers | 9x |
93 | Dozen | 1-12, 13-24, 25-36 | 3x |
94 | Column | 1,4,...,34 / 2,5,...,35 / 3,6,...,36 | 3x |
95 | Outside | 1-18, 19-36, EVEN, ODD, RED, BLACK | 2x |
96
97- 0 loses every outside bet, both dozens and every column. It pays a straight,
98 and split/corner bets containing 0 are not offered.
99- Every chip is settled independently and added together. RTP is 36/37 =
100 97.30% because every bet uses the same European odds.
101- Bet amounts and balances are not colored in the TUI.
89 102
90Client controls inside roulette: 103Client controls inside roulette:
91 104
92``` 105```
93R / B / E / O add the current chip to red/black/even/odd 106arrows move the cursor over the felt, including between numbers
94digits + Enter add the current chip to that number 107ENTER place the current chip on the cursor
95C clear all bets 108C clear all bets
96+ or = / - change the chip size 109+ or = / - change the chip size
97SPACE spin 110SPACE spin
@@ -125,7 +138,7 @@ plays (
125{"reels":"CCCCLLCLODGSDGS","win_type":"win","win_lines":"1","win_cells":"0,3,6"} 138{"reels":"CCCCLLCLODGSDGS","win_type":"win","win_lines":"1","win_cells":"0,3,6"}
126 139
127// roulette 140// roulette
128{"number":"17","color":"black","bets":"{\"red\":10,\"black\":10,\"even\":10,\"odd\":10,\"numbers\":[{\"number\":17,\"amount\":5}]}"} 141{"number":"17","color":"black","bets":"{\"chips\":[{\"target\":{\"bet\":\"outside\",\"kind\":\"red\"},\"amount\":10}]}"}
129 142
130// blackjack 143// blackjack
131{"player":["10S","6H","5D"],"dealer":["9D","8C"],"player_total":21,"dealer_total":17,"bet":10,"result":"win","winnings":20} 144{"player":["10S","6H","5D"],"dealer":["9D","8C"],"player_total":21,"dealer_total":17,"bet":10,"result":"win","winnings":20}
@@ -174,13 +187,21 @@ PLAY_START slots <bet>
174GAME_RESULT <session> <winnings> <bet> <balance> <reels> <win_type> <win_lines> <win_cells> 187GAME_RESULT <session> <winnings> <bet> <balance> <reels> <win_type> <win_lines> <win_cells>
175``` 188```
176 189
177Roulette adds a compact bets payload and a roulette-specific result: 190Roulette sends the chips and gets a roulette-specific result:
178 191
179``` 192```
180PLAY_START roulette <total> <bets-json> 193PLAY_START roulette <total> <bets-json>
181ROULETTE_RESULT <session> <winnings> <bet> <balance> <number> <color> <bets-json> 194ROULETTE_RESULT <session> <winnings> <bet> <balance> <number> <color> <bets-json>
182``` 195```
183 196
197`bets-json` is `{"chips":[{"target":{...},"amount":N}]}`. A target is one of
198`{"bet":"straight","number":17}`, `{"bet":"split","a":1,"b":2}`,
199`{"bet":"corner","numbers":[1,2,4,5]}`, `{"bet":"dozen","index":1}`,
200`{"bet":"column","index":0}` or
201`{"bet":"outside","kind":"red"}` (`low`, `high`, `even`, `odd`, `red`,
202`black`). The server rejects unknown, out-of-range or duplicate-number
203targets, and requires the total to equal the sum of the chips.
204
184Blackjack is a multi-step session: 205Blackjack is a multi-step session:
185 206
186``` 207```
diff --git a/docs/SERVER_SETUP.md b/docs/SERVER_SETUP.md
index 37d55b1..9eac7a7 100644
--- a/docs/SERVER_SETUP.md
+++ b/docs/SERVER_SETUP.md
@@ -79,9 +79,8 @@ last address for that key is forgotten.
79 79
80The server creates `server.db` in the current directory on first use. It holds 80The server creates `server.db` in the current directory on first use. It holds
81users, balances, and the per-game `plays` audit. The client keeps its own 81users, balances, and the per-game `plays` audit. The client keeps its own
82`client.db` for host fingerprints and stored credentials. Databases from before 82`client.db` for host fingerprints and stored credentials. There are no schema
83the fingerprint-keyed credentials are migrated on startup; rows whose host was 83migrations while the app is in alpha: a schema change means deleting both files.
84already forgotten cannot be re-keyed and require one fresh login.
85 84
86During development, delete both files to reset users, balances, history, and 85During development, delete both files to reset users, balances, history, and
87trust: 86trust:
diff --git a/docs/TESTING.md b/docs/TESTING.md
index fe302a9..7ec5b60 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -16,7 +16,7 @@ shared global database. Each test owns its state.
16| `tests/blackjack.rs` | Card totals, naturals, hit/stand/double, dealer bust, S17, state parsing. | 16| `tests/blackjack.rs` | Card totals, naturals, hit/stand/double, dealer bust, S17, state parsing. |
17| `tests/crash.rs` | Multiplier curve, payout floor, crash-point distribution, house edge, cap. | 17| `tests/crash.rs` | Multiplier curve, payout floor, crash-point distribution, house edge, cap. |
18| `tests/protocol.rs` | Newline buffering and timeout/error classification. | 18| `tests/protocol.rs` | Newline buffering and timeout/error classification. |
19| `tests/roulette.rs` | European wheel, split-bet payouts, zero handling, result parser. | 19| `tests/roulette.rs` | Wheel, every bet type and payout, zero handling, cursor lattice, result parser. |
20| `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | 20| `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. |
21| `tests/server_setup.rs` | `--init` identity generation, PKCS#12 loadability, overwrite refusal. | 21| `tests/server_setup.rs` | `--init` identity generation, PKCS#12 loadability, overwrite refusal. |
22| `tests/slots.rs` | Server slots payout rules and overflow handling. | 22| `tests/slots.rs` | Server slots payout rules and overflow handling. |
@@ -71,18 +71,31 @@ Use an 80-column terminal because several output lines are width-sensitive.
71 71
72## Manual roulette checklist 72## Manual roulette checklist
73 73
741. `play roulette` shows the balance, chip size, empty bets, and controls. 741. `play roulette` draws the full felt: 0, the 3x12 numbers, dozens, columns,
752. `R`/`B`/`E`/`O` add one chip; digits + Enter add a number; `C` clears; 75 and the six outside cells, with the cursor on number 1.
76 `+`/`=`/`-` change the chip size. 762. Arrows move the cursor. On the numbers the highlighted cells show the bet:
773. Space scrolls the wheel under a fixed ball marker and ends on the winning 77 one number (straight), two (split), four (corner). Down leaves the felt into
78 the dozens, then columns, then outside; Up walks back.
793. ENTER places the chip where the cursor is; `C` clears; `+`/`=`/`-` change the
80 chip size. The bets line lists every chip and the total.
814. Space scrolls the wheel under a fixed ball marker and ends on the winning
78 number. 82 number.
794. Check a split result: 835. Check the payouts:
80 - `17 black odd`: black+odd pay, red+even lose, straight 17 pays 36x. 84 - a straight pays 36x, a split 18x, a corner 9x;
81 - `0`: all outside bets lose; a straight 0 pays 36x. 85 - a dozen or column pays 3x;
825. Numbers are color-coded: red numbers red, 0 green, black default. Check the 86 - an outside bet pays 2x;
83 bets line, the rolling strip, and the winning number. 87 - `0` loses every outside bet, both dozens and every column, and pays a
846. The result board shows the winning number/color/parity, the payout, and the 88 straight 0.
85 new balance. The next spin reuses the placed bets. 896. The result line names the number, color and parity, the total returned and
90 the profit. The next spin reuses the placed chips.
91
92## Manual terminal size check
93
941. Start the client in a terminal smaller than 80x24: it must print
95 `zagan.bet needs a terminal of at least 80x24 cells` and exit without
96 switching to the alternate screen or raw mode.
972. At 80x24 every game board (slots 10 lines, roulette 12, blackjack 6, crash 6)
98 must fit without wrapping.
86 99
87## Manual blackjack checklist 100## Manual blackjack checklist
88 101
diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs
index ec5642c..7516836 100644
--- a/src/bin/client/connection.rs
+++ b/src/bin/client/connection.rs
@@ -49,7 +49,7 @@ pub fn connect(
49 Ok(Ok((tls, fp))) => (tls, fp), 49 Ok(Ok((tls, fp))) => (tls, fp),
50 Ok(Err(e)) => return Err(Box::new(TrustError(format!("{e}")))), 50 Ok(Err(e)) => return Err(Box::new(TrustError(format!("{e}")))),
51 Err(mpsc::RecvTimeoutError::Timeout) => { 51 Err(mpsc::RecvTimeoutError::Timeout) => {
52 return Err("Connection timed out after 3 seconds".into()); 52 return Err("Connection timed out after 10 seconds".into());
53 } 53 }
54 Err(_) => return Err("Connection attempt failed".into()), 54 Err(_) => return Err("Connection attempt failed".into()),
55 }; 55 };
diff --git a/src/bin/client/main.rs b/src/bin/client/main.rs
index 1b701d8..9018026 100644
--- a/src/bin/client/main.rs
+++ b/src/bin/client/main.rs
@@ -10,6 +10,21 @@ fn main() {
10 let args: Vec<String> = std::env::args().collect(); 10 let args: Vec<String> = std::env::args().collect();
11 let unsafe_mode = args.iter().any(|arg| arg == "--unsafe"); 11 let unsafe_mode = args.iter().any(|arg| arg == "--unsafe");
12 12
13 // Every game board is drawn in place, so refuse a terminal that cannot fit
14 // them before touching the network or the terminal mode.
15 let size = crossterm::terminal::size();
16 let (columns, rows) = match &size {
17 Ok((columns, rows)) => (*columns, *rows),
18 Err(_) => (0, 0),
19 };
20 if let Some(message) = zagan_bet::client::ui::terminal_too_small(columns, rows) {
21 eprintln!("{message}");
22 if size.is_err() {
23 eprintln!("Could not read the terminal size (is this a real terminal?).");
24 }
25 std::process::exit(1);
26 }
27
13 if unsafe_mode { 28 if unsafe_mode {
14 println!("WARNING: Running in UNSAFE mode - certificate verification disabled!"); 29 println!("WARNING: Running in UNSAFE mode - certificate verification disabled!");
15 } 30 }
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs
index d973596..2cf121d 100644
--- a/src/bin/client/repl.rs
+++ b/src/bin/client/repl.rs
@@ -712,6 +712,8 @@ fn map_key(key: crossterm::event::KeyEvent) -> Option<Key> {
712 KeyCode::Backspace => Some(Key::Backspace), 712 KeyCode::Backspace => Some(Key::Backspace),
713 KeyCode::Up => Some(Key::Up), 713 KeyCode::Up => Some(Key::Up),
714 KeyCode::Down => Some(Key::Down), 714 KeyCode::Down => Some(Key::Down),
715 KeyCode::Left => Some(Key::Left),
716 KeyCode::Right => Some(Key::Right),
715 KeyCode::Tab => Some(Key::Tab), 717 KeyCode::Tab => Some(Key::Tab),
716 KeyCode::Esc => Some(Key::Esc), 718 KeyCode::Esc => Some(Key::Esc),
717 _ => None, 719 _ => None,
diff --git a/src/client/core.rs b/src/client/core.rs
index 06d9c42..8411d47 100644
--- a/src/client/core.rs
+++ b/src/client/core.rs
@@ -7,7 +7,8 @@ use crate::client::ui::{
7 record_history, 7 record_history,
8}; 8};
9use crate::crash::{multiplier_at, CrashState}; 9use crate::crash::{multiplier_at, CrashState};
10use crate::roulette::{Bets, NumberBet}; 10use crate::client::games::roulette::Direction;
11use crate::roulette::Bets;
11use std::time::{Duration, Instant}; 12use std::time::{Duration, Instant};
12 13
13#[derive(Debug, Clone, Copy, PartialEq, Eq)] 14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -17,6 +18,8 @@ pub enum Key {
17 Backspace, 18 Backspace,
18 Up, 19 Up,
19 Down, 20 Down,
21 Left,
22 Right,
20 Tab, 23 Tab,
21 Esc, 24 Esc,
22 CtrlC, 25 CtrlC,
@@ -76,14 +79,6 @@ enum Pending {
76 Info, 79 Info,
77} 80}
78 81
79#[derive(Debug, Clone, Copy)]
80enum RouletteSide {
81 Red,
82 Black,
83 Even,
84 Odd,
85}
86
87pub struct ClientCore { 82pub struct ClientCore {
88 input: String, 83 input: String,
89 history: Vec<String>, 84 history: Vec<String>,
@@ -171,6 +166,7 @@ impl ClientCore {
171 } 166 }
172 Key::Up => self.history_previous(), 167 Key::Up => self.history_previous(),
173 Key::Down => self.history_next(), 168 Key::Down => self.history_next(),
169 Key::Left | Key::Right => Vec::new(),
174 Key::Tab => self.complete(), 170 Key::Tab => self.complete(),
175 Key::Esc => Vec::new(), 171 Key::Esc => Vec::new(),
176 } 172 }
@@ -355,6 +351,19 @@ impl ClientCore {
355 } 351 }
356 352
357 fn handle_roulette_key(&mut self, key: Key) -> Vec<Effect> { 353 fn handle_roulette_key(&mut self, key: Key) -> Vec<Effect> {
354 let direction = match key {
355 Key::Up => Some(Direction::Up),
356 Key::Down => Some(Direction::Down),
357 Key::Left => Some(Direction::Left),
358 Key::Right => Some(Direction::Right),
359 _ => None,
360 };
361 if let Some(direction) = direction {
362 let cursor = self.game.roulette_cursor().moved(direction);
363 self.game.set_roulette_cursor(&cursor);
364 return self.board_effects();
365 }
366
358 match key { 367 match key {
359 Key::CtrlC => vec![Effect::Quit], 368 Key::CtrlC => vec![Effect::Quit],
360 Key::Char('q') => { 369 Key::Char('q') => {
@@ -364,18 +373,9 @@ impl ClientCore {
364 Effect::Print(vec!["Game exited.".to_string()]), 373 Effect::Print(vec!["Game exited.".to_string()]),
365 ] 374 ]
366 } 375 }
367 Key::Esc => {
368 self.game.set_roulette_number_input("");
369 self.board_effects()
370 }
371 Key::Char(' ') => self.spin_roulette(), 376 Key::Char(' ') => self.spin_roulette(),
372 Key::Char('r') => self.add_roulette_side(RouletteSide::Red),
373 Key::Char('b') => self.add_roulette_side(RouletteSide::Black),
374 Key::Char('e') => self.add_roulette_side(RouletteSide::Even),
375 Key::Char('o') => self.add_roulette_side(RouletteSide::Odd),
376 Key::Char('c') => { 377 Key::Char('c') => {
377 self.game.set_roulette_bets(&Bets::default()); 378 self.game.set_roulette_bets(&Bets::default());
378 self.game.set_roulette_number_input("");
379 self.game.set_roulette_status("place"); 379 self.game.set_roulette_status("place");
380 self.board_effects() 380 self.board_effects()
381 } 381 }
@@ -389,63 +389,17 @@ impl ClientCore {
389 self.game.set_roulette_chip(chip); 389 self.game.set_roulette_chip(chip);
390 self.board_effects() 390 self.board_effects()
391 } 391 }
392 Key::Char(c) if c.is_ascii_digit() => { 392 Key::Enter => self.place_roulette_chip(),
393 let mut input = self.roulette_input();
394 if input.len() < 2 {
395 input.push(c);
396 self.game.set_roulette_number_input(&input);
397 }
398 self.board_effects()
399 }
400 Key::Backspace => {
401 let mut input = self.roulette_input();
402 input.pop();
403 self.game.set_roulette_number_input(&input);
404 self.board_effects()
405 }
406 Key::Enter => {
407 let input = self.roulette_input();
408 if !input.is_empty() {
409 match input.parse::<u8>() {
410 Ok(number) if number <= 36 => {
411 self.add_roulette_number(number);
412 self.game.set_roulette_number_input("");
413 }
414 _ => {
415 self.game.set_roulette_number_input("");
416 self.game.set_error("Number must be 0-36");
417 }
418 }
419 }
420 self.board_effects()
421 }
422 _ => Vec::new(), 393 _ => Vec::new(),
423 } 394 }
424 } 395 }
425 396
426 fn add_roulette_side(&mut self, side: RouletteSide) -> Vec<Effect> { 397 fn place_roulette_chip(&mut self) -> Vec<Effect> {
427 let chip = self.game.roulette_chip(); 398 let Some(target) = self.game.roulette_cursor().target() else {
428 let mut bets = self.game.roulette_bets(); 399 return Vec::new();
429 match side { 400 };
430 RouletteSide::Red => bets.red = bets.red.saturating_add(chip),
431 RouletteSide::Black => bets.black = bets.black.saturating_add(chip),
432 RouletteSide::Even => bets.even = bets.even.saturating_add(chip),
433 RouletteSide::Odd => bets.odd = bets.odd.saturating_add(chip),
434 }
435 self.place_roulette_bets(bets)
436 }
437
438 fn add_roulette_number(&mut self, number: u8) -> Vec<Effect> {
439 let chip = self.game.roulette_chip();
440 let mut bets = self.game.roulette_bets(); 401 let mut bets = self.game.roulette_bets();
441 if let Some(existing) = bets.numbers.iter_mut().find(|bet| bet.number == number) { 402 bets.add(target, self.game.roulette_chip());
442 existing.amount = existing.amount.saturating_add(chip);
443 } else {
444 bets.numbers.push(NumberBet {
445 number,
446 amount: chip,
447 });
448 }
449 self.place_roulette_bets(bets) 403 self.place_roulette_bets(bets)
450 } 404 }
451 405
@@ -487,14 +441,6 @@ impl ClientCore {
487 effects 441 effects
488 } 442 }
489 443
490 fn roulette_input(&self) -> String {
491 self.game
492 .get_current_state()
493 .and_then(|state| state.data.get("number_input"))
494 .cloned()
495 .unwrap_or_default()
496 }
497
498 fn handle_blackjack_key(&mut self, key: Key) -> Vec<Effect> { 444 fn handle_blackjack_key(&mut self, key: Key) -> Vec<Effect> {
499 let status = self 445 let status = self
500 .game 446 .game
diff --git a/src/client/database.rs b/src/client/database.rs
index 04a271a..c4601b5 100644
--- a/src/client/database.rs
+++ b/src/client/database.rs
@@ -20,14 +20,9 @@ pub fn init_db(conn: &Connection) {
20 [], 20 [],
21 ) 21 )
22 .unwrap(); 22 .unwrap();
23 // Migration for databases created before TOFU trust.
24 let _ = conn.execute("ALTER TABLE hosts ADD COLUMN fingerprint TEXT", []);
25 23
26 // Credentials belong to a server identity (the certificate key), not to a 24 // Credentials belong to a server identity (the certificate key), not to a
27 // single address, so every address that presents the same key shares them. 25 // single address, so every address that presents the same key shares them.
28 if has_column(conn, "credentials", "host") {
29 migrate_credentials_to_fingerprints(conn);
30 }
31 conn.execute( 26 conn.execute(
32 "CREATE TABLE IF NOT EXISTS credentials ( 27 "CREATE TABLE IF NOT EXISTS credentials (
33 fingerprint TEXT PRIMARY KEY, 28 fingerprint TEXT PRIMARY KEY,
@@ -39,42 +34,6 @@ pub fn init_db(conn: &Connection) {
39 .unwrap(); 34 .unwrap();
40} 35}
41 36
42fn has_column(conn: &Connection, table: &str, column: &str) -> bool {
43 let Ok(mut statement) = conn.prepare(&format!("PRAGMA table_info({table})")) else {
44 return false;
45 };
46 let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(1)) else {
47 return false;
48 };
49 rows.flatten().any(|name| name == column)
50}
51
52/// Re-key host-bound credentials by the fingerprint of their host.
53///
54/// Rows whose host was already forgotten have no identity to attach to and are
55/// dropped; those addresses have to re-authenticate once.
56fn migrate_credentials_to_fingerprints(conn: &Connection) {
57 let tx = conn.unchecked_transaction().unwrap();
58 tx.execute_batch(
59 "DROP TABLE IF EXISTS credentials_by_identity;
60 CREATE TABLE credentials_by_identity (
61 fingerprint TEXT PRIMARY KEY,
62 username TEXT NOT NULL,
63 password TEXT NOT NULL
64 );
65 INSERT OR REPLACE INTO credentials_by_identity (fingerprint, username, password)
66 SELECT h.fingerprint, c.username, c.password
67 FROM credentials c
68 JOIN hosts h ON h.host = c.host
69 WHERE h.fingerprint IS NOT NULL
70 ORDER BY h.last_used ASC, h.host ASC;
71 DROP TABLE credentials;
72 ALTER TABLE credentials_by_identity RENAME TO credentials;",
73 )
74 .unwrap();
75 tx.commit().unwrap();
76}
77
78pub fn get_last_host(conn: &Connection) -> Option<String> { 37pub fn get_last_host(conn: &Connection) -> Option<String> {
79 let mut stmt = conn 38 let mut stmt = conn
80 .prepare("SELECT host FROM hosts ORDER BY last_used DESC LIMIT 1") 39 .prepare("SELECT host FROM hosts ORDER BY last_used DESC LIMIT 1")
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs
index b20ff44..bb2a8b5 100644
--- a/src/client/games/manager.rs
+++ b/src/client/games/manager.rs
@@ -55,7 +55,7 @@ impl GameManager {
55 ); 55 );
56 data.insert("chip".to_string(), "10".to_string()); 56 data.insert("chip".to_string(), "10".to_string());
57 data.insert("status".to_string(), "place".to_string()); 57 data.insert("status".to_string(), "place".to_string());
58 data.insert("number_input".to_string(), String::new()); 58 data.insert("cursor".to_string(), "lattice:0:3".to_string());
59 0 59 0
60 } else if game_type == "blackjack" { 60 } else if game_type == "blackjack" {
61 data.insert("status".to_string(), "ready".to_string()); 61 data.insert("status".to_string(), "ready".to_string());
@@ -234,11 +234,19 @@ impl GameManager {
234 } 234 }
235 } 235 }
236 236
237 pub fn set_roulette_number_input(&mut self, value: &str) { 237 pub fn roulette_cursor(&self) -> crate::client::games::roulette::Cursor {
238 self.current_session
239 .as_ref()
240 .and_then(|session| session.data.get("cursor"))
241 .map(|raw| crate::client::games::roulette::Cursor::parse(raw))
242 .unwrap_or_default()
243 }
244
245 pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) {
238 if let Some(session) = self.current_session.as_mut() { 246 if let Some(session) = self.current_session.as_mut() {
239 session 247 session
240 .data 248 .data
241 .insert("number_input".to_string(), value.to_string()); 249 .insert("cursor".to_string(), cursor.encoded());
242 } 250 }
243 } 251 }
244 252
@@ -274,9 +282,6 @@ impl GameManager {
274 session 282 session
275 .data 283 .data
276 .insert("status".to_string(), "result".to_string()); 284 .insert("status".to_string(), "result".to_string());
277 session
278 .data
279 .insert("number_input".to_string(), String::new());
280 } 285 }
281 286
282 pub fn apply_blackjack_state(&mut self, state: BlackjackState) { 287 pub fn apply_blackjack_state(&mut self, state: BlackjackState) {
diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs
index e80eca8..3cb1c58 100644
--- a/src/client/games/roulette.rs
+++ b/src/client/games/roulette.rs
@@ -1,10 +1,178 @@
1use super::traits::{Game, GameState}; 1use super::traits::{Game, GameState};
2use crate::roulette::{is_even, is_odd, Bets}; 2use crate::roulette::{is_even, is_odd, BetTarget, Bets, Outside};
3 3
4pub const ROULETTE_BOARD_LINES: usize = 8; 4pub const ROULETTE_BOARD_LINES: usize = 12;
5
6/// Width of one number cell on the felt.
7const CELL: usize = 5;
8const NUM_COLUMNS: usize = 12;
9const NUM_WIDTH: usize = CELL * NUM_COLUMNS;
10const THIRD_WIDTH: usize = NUM_WIDTH / 3;
11const OUTSIDE_WIDTH: usize = NUM_WIDTH / 6;
12const OUTSIDE_ORDER: [Outside; 6] = [
13 Outside::Low,
14 Outside::Even,
15 Outside::Red,
16 Outside::Black,
17 Outside::Odd,
18 Outside::High,
19];
5 20
6pub struct RouletteGame; 21pub struct RouletteGame;
7 22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Direction {
25 Up,
26 Down,
27 Left,
28 Right,
29}
30
31/// Where the betting cursor is.
32///
33/// Over the numbers the cursor sits on a *lattice point*, the way a chip sits
34/// between numbers on a real felt: an inner point covers four numbers (corner),
35/// an edge point covers two (split), and an outer corner is a straight number.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Cursor {
38 Zero,
39 Lattice { x: usize, y: usize },
40 Dozen(usize),
41 Column(usize),
42 Outside(usize),
43}
44
45impl Default for Cursor {
46 fn default() -> Self {
47 // Bottom-left number (1).
48 Cursor::Lattice { x: 0, y: 3 }
49 }
50}
51
52impl Cursor {
53 pub fn parse(raw: &str) -> Self {
54 let parts: Vec<&str> = raw.split(':').collect();
55 match parts.as_slice() {
56 ["zero"] => Cursor::Zero,
57 ["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 },
59 _ => Cursor::default(),
60 },
61 ["dozen", index] => parse_index(index).map(Cursor::Dozen).unwrap_or_default(),
62 ["column", index] => parse_index(index).map(Cursor::Column).unwrap_or_default(),
63 ["outside", index] => match index.parse::<usize>() {
64 Ok(index) if index < OUTSIDE_ORDER.len() => Cursor::Outside(index),
65 _ => Cursor::default(),
66 },
67 _ => Cursor::default(),
68 }
69 }
70
71 pub fn encoded(&self) -> String {
72 match self {
73 Cursor::Zero => "zero".to_string(),
74 Cursor::Lattice { x, y } => format!("lattice:{x}:{y}"),
75 Cursor::Dozen(index) => format!("dozen:{index}"),
76 Cursor::Column(index) => format!("column:{index}"),
77 Cursor::Outside(index) => format!("outside:{index}"),
78 }
79 }
80
81 pub fn target(&self) -> Option<BetTarget> {
82 match self {
83 Cursor::Zero => Some(BetTarget::Straight { number: 0 }),
84 Cursor::Lattice { x, y } => lattice_target(*x, *y),
85 Cursor::Dozen(index) => Some(BetTarget::Dozen {
86 index: *index as u8,
87 }),
88 Cursor::Column(index) => Some(BetTarget::Column {
89 index: *index as u8,
90 }),
91 Cursor::Outside(index) => Some(BetTarget::Outside {
92 kind: OUTSIDE_ORDER[*index],
93 }),
94 }
95 }
96
97 /// Numbers the cursor would cover, for highlighting the felt.
98 pub fn numbers(&self) -> Vec<u8> {
99 self.target().map(|target| target.numbers()).unwrap_or_default()
100 }
101
102 pub fn moved(&self, direction: Direction) -> Cursor {
103 match (*self, direction) {
104 (Cursor::Zero, Direction::Down) => Cursor::Lattice { x: 0, y: 0 },
105 (Cursor::Zero, _) => Cursor::Zero,
106
107 (Cursor::Lattice { x, y }, Direction::Left) => Cursor::Lattice {
108 x: x.saturating_sub(1),
109 y,
110 },
111 (Cursor::Lattice { x, y }, Direction::Right) => Cursor::Lattice {
112 x: (x + 1).min(NUM_COLUMNS),
113 y,
114 },
115 (Cursor::Lattice { x: _, y: 0 }, Direction::Up) => Cursor::Zero,
116 (Cursor::Lattice { x, y }, Direction::Up) => Cursor::Lattice { x, y: y - 1 },
117 (Cursor::Lattice { x, y: 3 }, Direction::Down) => Cursor::Dozen((x / 4).min(2)),
118 (Cursor::Lattice { x, y }, Direction::Down) => Cursor::Lattice { x, y: y + 1 },
119
120 (Cursor::Dozen(index), Direction::Left) => Cursor::Dozen(index.saturating_sub(1)),
121 (Cursor::Dozen(index), Direction::Right) => Cursor::Dozen((index + 1).min(2)),
122 (Cursor::Dozen(index), Direction::Up) => Cursor::Lattice {
123 x: 4 * index + 1,
124 y: 3,
125 },
126 (Cursor::Dozen(index), Direction::Down) => Cursor::Column(index),
127
128 (Cursor::Column(index), Direction::Left) => Cursor::Column(index.saturating_sub(1)),
129 (Cursor::Column(index), Direction::Right) => Cursor::Column((index + 1).min(2)),
130 (Cursor::Column(index), Direction::Up) => Cursor::Dozen(index),
131 (Cursor::Column(index), Direction::Down) => Cursor::Outside(index * 2),
132
133 (Cursor::Outside(index), Direction::Left) => {
134 Cursor::Outside(index.saturating_sub(1))
135 }
136 (Cursor::Outside(index), Direction::Right) => {
137 Cursor::Outside((index + 1).min(OUTSIDE_ORDER.len() - 1))
138 }
139 (Cursor::Outside(index), Direction::Up) => Cursor::Column(index / 2),
140 (Cursor::Outside(index), Direction::Down) => Cursor::Outside(index),
141 }
142 }
143}
144
145fn parse_index(raw: &str) -> Option<usize> {
146 raw.parse::<usize>().ok().filter(|index| *index < 3)
147}
148
149/// Numbers under a lattice point: 1 (outer corner), 2 (edge) or 4 (inner).
150fn lattice_target(x: usize, y: usize) -> Option<BetTarget> {
151 let cell = |column: isize, row: isize| -> Option<u8> {
152 if !(0..NUM_COLUMNS as isize).contains(&column) || !(0..3isize).contains(&row) {
153 return None;
154 }
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 }
174}
175
8impl RouletteGame { 176impl RouletteGame {
9 pub fn new() -> Self { 177 pub fn new() -> Self {
10 Self 178 Self
@@ -14,40 +182,99 @@ impl RouletteGame {
14 state 182 state
15 .data 183 .data
16 .get("bets") 184 .get("bets")
17 .and_then(|raw| serde_json::from_str(raw).ok()) 185 .and_then(|raw| Bets::from_json(raw).ok())
18 .unwrap_or_default() 186 .unwrap_or_default()
19 } 187 }
20 188
21 fn bets_line(bets: &Bets) -> String { 189 fn cursor(state: &GameState) -> Cursor {
22 let mut parts = Vec::new(); 190 state
23 if bets.red > 0 { 191 .data
24 parts.push(format!("RED {}", bets.red)); 192 .get("cursor")
25 } 193 .map(|raw| Cursor::parse(raw))
26 if bets.black > 0 { 194 .unwrap_or_default()
27 parts.push(format!("BLACK {}", bets.black)); 195 }
28 }
29 if bets.even > 0 {
30 parts.push(format!("EVEN {}", bets.even));
31 }
32 if bets.odd > 0 {
33 parts.push(format!("ODD {}", bets.odd));
34 }
35 for bet in &bets.numbers {
36 parts.push(format!("#{} {}", bet.number, bet.amount));
37 }
38 196
39 let list = if parts.is_empty() { 197 fn zero_cell(cursor: Cursor) -> String {
40 "(none)".to_string() 198 let cell = if cursor == Cursor::Zero {
199 "[ 0 ]".to_string()
41 } else { 200 } else {
42 parts.join(" ") 201 format!("{:^CELL$}", 0)
43 }; 202 };
44 format!("Bets: {} Total: {} PLN", list, bets.total()) 203 format!("{cell:<NUM_WIDTH$}")
204 }
205
206 fn number_row(row: usize, covered: &[u8]) -> String {
207 (0..NUM_COLUMNS)
208 .map(|column| {
209 let number = (3 * column + (3 - row)) as u8;
210 if covered.contains(&number) {
211 format!("[{number:^3}]")
212 } else {
213 format!("{number:^CELL$}")
214 }
215 })
216 .collect()
217 }
218
219 fn wide_cell(label: &str, width: usize, active: bool) -> String {
220 if active {
221 format!("[{:^inner$}]", label, inner = width - 2)
222 } else {
223 format!("{label:^width$}")
224 }
225 }
226
227 fn dozens_row(cursor: Cursor) -> String {
228 ["1st 12", "2nd 12", "3rd 12"]
229 .iter()
230 .enumerate()
231 .map(|(index, label)| {
232 Self::wide_cell(label, THIRD_WIDTH, cursor == Cursor::Dozen(index))
233 })
234 .collect()
235 }
236
237 fn columns_row(cursor: Cursor) -> String {
238 ["col 1 2:1", "col 2 2:1", "col 3 2:1"]
239 .iter()
240 .enumerate()
241 .map(|(index, label)| {
242 Self::wide_cell(label, THIRD_WIDTH, cursor == Cursor::Column(index))
243 })
244 .collect()
245 }
246
247 fn outside_row(cursor: Cursor) -> String {
248 OUTSIDE_ORDER
249 .iter()
250 .enumerate()
251 .map(|(index, kind)| {
252 Self::wide_cell(kind.label(), OUTSIDE_WIDTH, cursor == Cursor::Outside(index))
253 })
254 .collect()
45 } 255 }
46 256
47 fn result_lines(state: &GameState) -> (String, String) { 257 fn bets_line(bets: &Bets) -> String {
258 let mut parts: Vec<String> = bets
259 .chips
260 .iter()
261 .filter(|chip| chip.amount > 0)
262 .map(|chip| format!("{} {}", chip.target.label(), chip.amount))
263 .collect();
264 if parts.is_empty() {
265 parts.push("(none)".to_string());
266 }
267 let mut list = parts.join(" ");
268 if list.chars().count() > 40 {
269 list = list.chars().take(39).collect::<String>() + "…";
270 }
271 format!("Bets: {list} Total: {} PLN", bets.total())
272 }
273
274 fn status_line(state: &GameState) -> String {
48 if let Some(error) = state.data.get("error") { 275 if let Some(error) = state.data.get("error") {
49 if !error.is_empty() { 276 if !error.is_empty() {
50 return (format!("ERROR: {error}"), String::new()); 277 return format!("ERROR: {error}");
51 } 278 }
52 } 279 }
53 280
@@ -77,15 +304,16 @@ impl RouletteGame {
77 .get("winnings") 304 .get("winnings")
78 .map(String::as_str) 305 .map(String::as_str)
79 .unwrap_or("0"); 306 .unwrap_or("0");
80 let result = if winnings == "0" { 307 let stake = state.bet_amount;
81 "No win.".to_string() 308 if winnings == "0" {
309 format!("No win at {number} {color}{parity}")
82 } else { 310 } else {
83 format!("Won {winnings} PLN") 311 let profit = winnings.parse::<u64>().unwrap_or(0).saturating_sub(stake);
84 }; 312 format!("Won {winnings} PLN at {number} {color}{parity} (+{profit})")
85 (format!("Winning: {number} {color}{parity}"), result) 313 }
86 } 314 }
87 Some("spinning") => ("Spinning...".to_string(), String::new()), 315 Some("spinning") => "Spinning...".to_string(),
88 _ => ("Place bets, then SPACE to spin.".to_string(), String::new()), 316 _ => "Arrows move, ENTER places the chip, SPACE spins.".to_string(),
89 } 317 }
90 } 318 }
91} 319}
@@ -99,27 +327,25 @@ impl Game for RouletteGame {
99 let bets = Self::bets(state); 327 let bets = Self::bets(state);
100 let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); 328 let chip = state.data.get("chip").map(String::as_str).unwrap_or("10");
101 let balance = state.data.get("balance").map(String::as_str).unwrap_or("?"); 329 let balance = state.data.get("balance").map(String::as_str).unwrap_or("?");
102 let input = state 330 let cursor = Self::cursor(state);
103 .data 331 let covered = cursor.numbers();
104 .get("number_input")
105 .map(String::as_str)
106 .unwrap_or("");
107 let input_line = if input.is_empty() {
108 String::new()
109 } else {
110 format!("Number: {input}_")
111 };
112 let (status, result) = Self::result_lines(state);
113 332
114 format!( 333 let lines = [
115 "=== ROULETTE ===\r\nBalance: {} PLN Chip: {} PLN\r\n{}\r\n{}\r\n{}\r\n{}\r\n[R]ed [B]lack [E]ven [O]dd [#]Number [C]lear\r\n[+/-] Chip [SPACE] Spin [Q] Quit", 334 "=== ROULETTE ===".to_string(),
116 balance, 335 format!("Balance: {balance} PLN Chip: {chip} PLN"),
117 chip, 336 Self::zero_cell(cursor),
337 Self::number_row(0, &covered),
338 Self::number_row(1, &covered),
339 Self::number_row(2, &covered),
340 Self::dozens_row(cursor),
341 Self::columns_row(cursor),
342 Self::outside_row(cursor),
118 Self::bets_line(&bets), 343 Self::bets_line(&bets),
119 input_line, 344 Self::status_line(state),
120 status, 345 "[ARROWS] Move [ENTER] Bet [C]lear [+/-] Chip [SPACE] Spin [Q] Quit"
121 result 346 .to_string(),
122 ) 347 ];
348 lines.join("\r\n")
123 } 349 }
124 350
125 fn render_lines(&self, _state: &GameState) -> usize { 351 fn render_lines(&self, _state: &GameState) -> usize {
@@ -129,20 +355,20 @@ impl Game for RouletteGame {
129 fn get_help(&self) -> String { 355 fn get_help(&self) -> String {
130 "ROULETTE (European)\n\ 356 "ROULETTE (European)\n\
131 ==================\n\ 357 ==================\n\
132 37 pockets: 0 plus 1-36. 0 is green and loses all outside bets.\n\ 358 37 pockets: 0 plus 1-36. 0 is green and loses every outside bet.\n\
133 \n\ 359 \n\
134 Betting:\n\ 360 Betting with the cursor:\n\
135 R / B / E / O - add the current chip to red/black/even/odd\n\ 361 arrows - move over the felt; between numbers picks a split or corner\n\
136 digits + Enter - add the current chip to that number\n\ 362 ENTER - place the current chip where the cursor is\n\
137 C - clear all bets\n\ 363 C - clear all bets\n\
138 + or = / - - change the chip size\n\ 364 + or = / - - change the chip size\n\
139 SPACE - spin when at least one bet is placed\n\ 365 SPACE - spin when at least one bet is placed\n\
140 Q - exit\n\ 366 Q - exit\n\
141 \n\ 367 \n\
142 Payouts:\n\ 368 Payouts (returned per unit staked):\n\
143 straight number: 35:1 (36x returned)\n\ 369 straight 36x, split 18x, corner 9x\n\
144 red/black: 1:1 (2x returned)\n\ 370 dozen 3x, column 3x\n\
145 even/odd: 1:1 (2x returned)\n\ 371 red/black, even/odd, 1-18/19-36: 2x\n\
146 Multiple bets are settled independently and added together." 372 Multiple bets are settled independently and added together."
147 .to_string() 373 .to_string()
148 } 374 }
diff --git a/src/client/ui.rs b/src/client/ui.rs
index 9d65087..6161166 100644
--- a/src/client/ui.rs
+++ b/src/client/ui.rs
@@ -244,6 +244,23 @@ pub fn first_contact_lines(addr: &str, fingerprint: &str) -> Vec<String> {
244 244
245/// Format remembered hosts so a 64-character fingerprint does not wrap on an 245/// Format remembered hosts so a 64-character fingerprint does not wrap on an
246/// 80-column terminal. 246/// 80-column terminal.
247/// Smallest terminal every game fits in. Roulette's felt is 12 lines and the
248/// controls are ~72 columns, so anything smaller would wrap or hide the board.
249pub const MIN_COLUMNS: u16 = 80;
250pub const MIN_ROWS: u16 = 24;
251
252/// Refuse to start when the terminal cannot show every game.
253pub fn terminal_too_small(columns: u16, rows: u16) -> Option<String> {
254 if columns >= MIN_COLUMNS && rows >= MIN_ROWS {
255 return None;
256 }
257 Some(format!(
258 "zagan.bet needs a terminal of at least {MIN_COLUMNS}x{MIN_ROWS} cells, \
259 but this one is {columns}x{rows}.\n\
260 Resize it and start again."
261 ))
262}
263
247/// Render remembered hosts. 264/// Render remembered hosts.
248/// 265///
249/// Two addresses that present the same certificate key are the same server, so 266/// Two addresses that present the same certificate key are the same server, so
diff --git a/src/roulette.rs b/src/roulette.rs
index 426ecc3..e7b3aad 100644
--- a/src/roulette.rs
+++ b/src/roulette.rs
@@ -14,6 +14,12 @@ pub const RED_NUMBERS: [u8; 18] = [
14 14
15/// Total return on a winning straight (35:1 plus the stake). 15/// Total return on a winning straight (35:1 plus the stake).
16pub const STRAIGHT_RETURN: u64 = 36; 16pub const STRAIGHT_RETURN: u64 = 36;
17/// Total return on a winning split (17:1 plus the stake).
18pub const SPLIT_RETURN: u64 = 18;
19/// Total return on a winning corner (8:1 plus the stake).
20pub const CORNER_RETURN: u64 = 9;
21/// Total return on a winning dozen or column (2:1 plus the stake).
22pub const THIRD_RETURN: u64 = 3;
17/// Total return on a winning outside bet (1:1 plus the stake). 23/// Total return on a winning outside bet (1:1 plus the stake).
18pub const EVEN_MONEY_RETURN: u64 = 2; 24pub const EVEN_MONEY_RETURN: u64 = 2;
19 25
@@ -47,67 +53,176 @@ pub fn rtp() -> f64 {
47 36.0 / 37.0 53 36.0 / 37.0
48} 54}
49 55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum Outside {
59 Low,
60 High,
61 Even,
62 Odd,
63 Red,
64 Black,
65}
66
67impl Outside {
68 pub fn wins(self, number: u8) -> bool {
69 match self {
70 Outside::Low => (1..=18).contains(&number),
71 Outside::High => (19..=36).contains(&number),
72 Outside::Even => is_even(number),
73 Outside::Odd => is_odd(number),
74 Outside::Red => is_red(number),
75 Outside::Black => is_black(number),
76 }
77 }
78
79 pub fn label(self) -> &'static str {
80 match self {
81 Outside::Low => "1-18",
82 Outside::High => "19-36",
83 Outside::Even => "EVEN",
84 Outside::Odd => "ODD",
85 Outside::Red => "RED",
86 Outside::Black => "BLACK",
87 }
88 }
89}
90
91/// One place on the felt where a chip can sit.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(tag = "bet", rename_all = "snake_case")]
94pub enum BetTarget {
95 Straight { number: u8 },
96 Split { a: u8, b: u8 },
97 Corner { numbers: [u8; 4] },
98 /// 0 = 1-12, 1 = 13-24, 2 = 25-36.
99 Dozen { index: u8 },
100 /// 0 = 1,4,...,34; 1 = 2,5,...,35; 2 = 3,6,...,36.
101 Column { index: u8 },
102 Outside { kind: Outside },
103}
104
105impl BetTarget {
106 /// Numbers this target covers on the number grid (empty for outside bets).
107 pub fn numbers(&self) -> Vec<u8> {
108 match self {
109 BetTarget::Straight { number } => vec![*number],
110 BetTarget::Split { a, b } => vec![*a, *b],
111 BetTarget::Corner { numbers } => numbers.to_vec(),
112 _ => Vec::new(),
113 }
114 }
115
116 /// Total returned per unit staked, or 0 when the bet loses.
117 pub fn return_multiplier(&self, number: u8) -> u64 {
118 match self {
119 BetTarget::Straight { number: straight } => {
120 u64::from(*straight == number) * STRAIGHT_RETURN
121 }
122 BetTarget::Split { a, b } => u64::from(*a == number || *b == number) * SPLIT_RETURN,
123 BetTarget::Corner { numbers } => {
124 u64::from(numbers.contains(&number)) * CORNER_RETURN
125 }
126 BetTarget::Dozen { index } => {
127 let wins = number != 0 && (number - 1) / 12 == *index;
128 u64::from(wins) * THIRD_RETURN
129 }
130 BetTarget::Column { index } => {
131 let wins = match index {
132 0 => number % 3 == 1,
133 1 => number % 3 == 2,
134 2 => number != 0 && number % 3 == 0,
135 _ => false,
136 };
137 u64::from(wins) * THIRD_RETURN
138 }
139 BetTarget::Outside { kind } => u64::from(kind.wins(number)) * EVEN_MONEY_RETURN,
140 }
141 }
142
143 pub fn label(&self) -> String {
144 match self {
145 BetTarget::Straight { number } => number.to_string(),
146 BetTarget::Split { a, b } => format!("{a}/{b}"),
147 BetTarget::Corner { numbers } => numbers
148 .iter()
149 .map(u8::to_string)
150 .collect::<Vec<_>>()
151 .join("/"),
152 BetTarget::Dozen { index } => match index {
153 0 => "1st12".to_string(),
154 1 => "2nd12".to_string(),
155 _ => "3rd12".to_string(),
156 },
157 BetTarget::Column { index } => format!("col{}", index + 1),
158 BetTarget::Outside { kind } => kind.label().to_string(),
159 }
160 }
161
162 fn validates(&self) -> bool {
163 match self {
164 BetTarget::Straight { number } => *number <= 36,
165 BetTarget::Split { a, b } => *a <= 36 && *b <= 36 && a != b,
166 BetTarget::Corner { numbers } => {
167 numbers.iter().all(|number| *number <= 36)
168 && numbers
169 .iter()
170 .enumerate()
171 .all(|(index, number)| !numbers[..index].contains(number))
172 }
173 BetTarget::Dozen { index } | BetTarget::Column { index } => *index < 3,
174 BetTarget::Outside { .. } => true,
175 }
176 }
177}
178
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct NumberBet { 180pub struct Chip {
52 pub number: u8, 181 pub target: BetTarget,
53 pub amount: u64, 182 pub amount: u64,
54} 183}
55 184
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] 185#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Bets { 186pub struct Bets {
58 #[serde(default)] 187 #[serde(default)]
59 pub red: u64, 188 pub chips: Vec<Chip>,
60 #[serde(default)]
61 pub black: u64,
62 #[serde(default)]
63 pub even: u64,
64 #[serde(default)]
65 pub odd: u64,
66 #[serde(default)]
67 pub numbers: Vec<NumberBet>,
68} 189}
69 190
70impl Bets { 191impl Bets {
71 pub fn total(&self) -> u64 { 192 pub fn total(&self) -> u64 {
72 let mut total = self 193 self.chips
73 .red 194 .iter()
74 .saturating_add(self.black) 195 .fold(0u64, |total, chip| total.saturating_add(chip.amount))
75 .saturating_add(self.even)
76 .saturating_add(self.odd);
77 for bet in &self.numbers {
78 total = total.saturating_add(bet.amount);
79 }
80 total
81 } 196 }
82 197
83 pub fn is_empty(&self) -> bool { 198 pub fn is_empty(&self) -> bool {
84 self.total() == 0 199 self.chips.iter().all(|chip| chip.amount == 0)
85 } 200 }
86 201
87 /// Total returned for a winning number, including returned stakes. 202 pub fn amount_on(&self, target: &BetTarget) -> u64 {
88 pub fn winnings(&self, number: u8) -> u64 { 203 self.chips
89 let mut winnings = 0u64; 204 .iter()
90 205 .filter(|chip| &chip.target == target)
91 if number != 0 { 206 .fold(0u64, |total, chip| total.saturating_add(chip.amount))
92 if is_red(number) { 207 }
93 winnings = winnings.saturating_add(self.red.saturating_mul(EVEN_MONEY_RETURN));
94 } else {
95 winnings = winnings.saturating_add(self.black.saturating_mul(EVEN_MONEY_RETURN));
96 }
97 if is_even(number) {
98 winnings = winnings.saturating_add(self.even.saturating_mul(EVEN_MONEY_RETURN));
99 } else {
100 winnings = winnings.saturating_add(self.odd.saturating_mul(EVEN_MONEY_RETURN));
101 }
102 }
103 208
104 for bet in &self.numbers { 209 /// Put a chip on a target, merging with an existing one.
105 if bet.number == number { 210 pub fn add(&mut self, target: BetTarget, amount: u64) {
106 winnings = winnings.saturating_add(bet.amount.saturating_mul(STRAIGHT_RETURN)); 211 if let Some(chip) = self.chips.iter_mut().find(|chip| chip.target == target) {
107 } 212 chip.amount = chip.amount.saturating_add(amount);
213 } else if amount > 0 {
214 self.chips.push(Chip { target, amount });
108 } 215 }
216 }
109 217
110 winnings 218 /// Total returned for a winning number, including returned stakes.
219 pub fn winnings(&self, number: u8) -> u64 {
220 self.chips.iter().fold(0u64, |total, chip| {
221 total.saturating_add(
222 chip.amount
223 .saturating_mul(chip.target.return_multiplier(number)),
224 )
225 })
111 } 226 }
112 227
113 pub fn validate(&self, balance: u64) -> Result<(), String> { 228 pub fn validate(&self, balance: u64) -> Result<(), String> {
@@ -117,14 +232,22 @@ impl Bets {
117 if self.total() > balance { 232 if self.total() > balance {
118 return Err("Insufficient balance".to_string()); 233 return Err("Insufficient balance".to_string());
119 } 234 }
120 for bet in &self.numbers { 235 for chip in &self.chips {
121 if bet.number > 36 { 236 if !chip.target.validates() {
122 return Err(format!("Invalid number: {}", bet.number)); 237 return Err(format!("Invalid bet: {}", chip.target.label()));
123 } 238 }
124 if bet.amount == 0 { 239 if chip.amount == 0 {
125 return Err("A bet amount must be positive".to_string()); 240 return Err("A bet amount must be positive".to_string());
126 } 241 }
127 } 242 }
128 Ok(()) 243 Ok(())
129 } 244 }
245
246 pub fn to_json(&self) -> String {
247 serde_json::to_string(self).unwrap_or_else(|_| "{\"chips\":[]}".to_string())
248 }
249
250 pub fn from_json(raw: &str) -> Result<Self, serde_json::Error> {
251 serde_json::from_str(raw)
252 }
130} 253}
diff --git a/src/server/database.rs b/src/server/database.rs
index 3cd86e4..6dd71a3 100644
--- a/src/server/database.rs
+++ b/src/server/database.rs
@@ -11,12 +11,6 @@ pub fn init_db(conn: &Connection) {
11 [], 11 [],
12 ) 12 )
13 .unwrap(); 13 .unwrap();
14 // Add column if not exists (for migration)
15 let _ = conn.execute("ALTER TABLE users ADD COLUMN account_id INTEGER UNIQUE", []);
16 let _ = conn.execute(
17 "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000",
18 [],
19 );
20 conn.execute( 14 conn.execute(
21 "CREATE TABLE IF NOT EXISTS plays ( 15 "CREATE TABLE IF NOT EXISTS plays (
22 session_id TEXT PRIMARY KEY, 16 session_id TEXT PRIMARY KEY,
diff --git a/src/server/games/roulette.rs b/src/server/games/roulette.rs
index 0a58323..de47dee 100644
--- a/src/server/games/roulette.rs
+++ b/src/server/games/roulette.rs
@@ -36,7 +36,7 @@ impl Game for RouletteGame {
36 } 36 }
37 37
38 fn get_description(&self) -> &'static str { 38 fn get_description(&self) -> &'static str {
39 "European roulette. Bet on red/black, even/odd, or a straight number." 39 "European roulette. Straights, splits, corners, dozens, columns and outside bets."
40 } 40 }
41 41
42 fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState { 42 fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState {
diff --git a/tests/client_core.rs b/tests/client_core.rs
index 176791e..dea6808 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -591,26 +591,34 @@ fn rejects_a_grid_with_the_wrong_size() {
591} 591}
592 592
593#[test] 593#[test]
594fn roulette_split_bets_spin_and_show_the_result() { 594fn roulette_cursor_places_bets_and_spins() {
595 let mut h = Harness::new(); 595 let mut h = Harness::new();
596 h.enter_roulette(); 596 h.enter_roulette();
597 597
598 assert_eq!(h.board().len(), 8); 598 assert_eq!(h.board().len(), 12);
599 assert_eq!(h.board()[0], "=== ROULETTE ==="); 599 assert_eq!(h.board()[0], "=== ROULETTE ===");
600 assert!(h.board()[1].contains("Balance: 1000 PLN")); 600 assert!(h.board()[1].contains("Balance: 1000 PLN"));
601 assert!(h.board()[1].contains("Chip: 10 PLN")); 601 assert!(h.board()[1].contains("Chip: 10 PLN"));
602 assert!(h.board()[2].contains("Bets: (none)")); 602 assert!(h.board()[9].contains("Bets: (none)"));
603 // The cursor starts on number 1, the bottom-left corner.
604 assert!(h.board()[5].contains("[ 1 ]"));
605
606 // Up twice: straight 2, then the split between 2 and 3.
607 h.key(Key::Up);
608 h.key(Key::Up);
609 assert!(h.board()[4].contains("[ 2 ]"));
610 assert!(h.board()[3].contains("[ 3 ]"));
603 611
604 h.key(Key::Char('r'));
605 h.key(Key::Char('e'));
606 h.key(Key::Char('1'));
607 h.key(Key::Char('7'));
608 h.key(Key::Enter); 612 h.key(Key::Enter);
613 assert!(h.board()[9].contains("2/3 10"));
614 assert!(h.board()[9].contains("Total: 10 PLN"));
609 615
610 assert!(h.board()[2].contains("RED 10")); 616 // Back down to number 1 and place a straight.
611 assert!(h.board()[2].contains("EVEN 10")); 617 h.key(Key::Down);
612 assert!(h.board()[2].contains("#17 10")); 618 h.key(Key::Down);
613 assert!(h.board()[2].contains("Total: 30 PLN")); 619 h.key(Key::Enter);
620 assert!(h.board()[9].contains("1 10"));
621 assert!(h.board()[9].contains("Total: 20 PLN"));
614 622
615 h.key(Key::Char(' ')); 623 h.key(Key::Char(' '));
616 let request = h 624 let request = h
@@ -620,18 +628,41 @@ fn roulette_split_bets_spin_and_show_the_result() {
620 .expect("roulette request"); 628 .expect("roulette request");
621 assert_eq!( 629 assert_eq!(
622 request, 630 request,
623 "PLAY_START roulette 30 {\"red\":10,\"black\":0,\"even\":10,\"odd\":0,\"numbers\":[{\"number\":17,\"amount\":10}]}" 631 "PLAY_START roulette 20 {\"chips\":[{\"target\":{\"bet\":\"split\",\"a\":2,\"b\":3},\"amount\":10},{\"target\":{\"bet\":\"straight\",\"number\":1},\"amount\":10}]}"
624 ); 632 );
625 633
626 let bets = r#"{"red":10,"black":0,"even":10,"odd":0,"numbers":[{"number":17,"amount":10}]}"#; 634 let bets = r#"{"chips":[{"target":{"bet":"split","a":2,"b":3},"amount":10},{"target":{"bet":"straight","number":1},"amount":10}]}"#;
627 h.reply(&format!( 635 h.reply(&format!(
628 "ROULETTE_RESULT session-1 360 30 1330 17 black {bets}" 636 "ROULETTE_RESULT session-1 180 20 1160 3 red {bets}"
629 )); 637 ));
630 638
631 assert_eq!(h.board().len(), 8); 639 assert_eq!(h.board().len(), 12);
632 assert!(h.board()[1].contains("Balance: 1330 PLN")); 640 assert!(h.board()[1].contains("Balance: 1160 PLN"));
633 assert!(h.board()[4].contains("Winning: 17 BLACK ODD")); 641 assert!(h.board()[10].contains("Won 180 PLN at 3 RED ODD"));
634 assert!(h.board()[5].contains("Won 360 PLN")); 642 assert!(h.board()[10].contains("+160"));
643}
644
645#[test]
646fn roulette_cursor_reaches_the_outside_rows() {
647 let mut h = Harness::new();
648 h.enter_roulette();
649
650 h.key(Key::Down);
651 assert!(h.board()[6].starts_with("[") && h.board()[6].contains("1st 12"));
652 h.key(Key::Down);
653 assert!(h.board()[7].starts_with("[") && h.board()[7].contains("2:1"));
654 h.key(Key::Down);
655 assert!(h.board()[8].starts_with("[") && h.board()[8].contains("1-18"));
656
657 h.key(Key::Enter);
658 assert!(h.board()[9].contains("1-18 10"));
659
660 // Back up to the felt: the bottom edge of the first dozen is the 1/4 split.
661 h.key(Key::Up);
662 h.key(Key::Up);
663 h.key(Key::Up);
664 assert!(h.board()[5].contains("[ 1 ]"));
665 assert!(h.board()[5].contains("[ 4 ]"));
635} 666}
636 667
637#[test] 668#[test]
@@ -640,7 +671,7 @@ fn roulette_rejects_an_empty_spin_and_accepts_the_equals_chip_key() {
640 h.enter_roulette(); 671 h.enter_roulette();
641 672
642 h.key(Key::Char(' ')); 673 h.key(Key::Char(' '));
643 assert!(h.board()[4].contains("ERROR: Place at least one bet")); 674 assert!(h.board()[10].contains("ERROR: Place at least one bet"));
644 assert!(!h 675 assert!(!h
645 .sent 676 .sent
646 .iter() 677 .iter()
@@ -648,22 +679,9 @@ fn roulette_rejects_an_empty_spin_and_accepts_the_equals_chip_key() {
648 679
649 h.key(Key::Char('=')); 680 h.key(Key::Char('='));
650 assert!(h.board()[1].contains("Chip: 20 PLN")); 681 assert!(h.board()[1].contains("Chip: 20 PLN"));
651 h.key(Key::Char('r'));
652 assert!(h.board()[2].contains("RED 20"));
653 assert!(h.board()[2].contains("Total: 20 PLN"));
654}
655
656#[test]
657fn roulette_rejects_an_out_of_range_number() {
658 let mut h = Harness::new();
659 h.enter_roulette();
660
661 h.key(Key::Char('9'));
662 h.key(Key::Char('9'));
663 h.key(Key::Enter); 682 h.key(Key::Enter);
664 683 assert!(h.board()[9].contains("1 20"));
665 assert!(h.board()[4].contains("ERROR: Number must be 0-36")); 684 assert!(h.board()[9].contains("Total: 20 PLN"));
666 assert!(h.board()[2].contains("Bets: (none)"));
667} 685}
668 686
669fn bj_state( 687fn bj_state(
diff --git a/tests/client_ui.rs b/tests/client_ui.rs
index 44febde..7969139 100644
--- a/tests/client_ui.rs
+++ b/tests/client_ui.rs
@@ -2,7 +2,7 @@ use std::time::Duration;
2use zagan_bet::client::ui::{ 2use zagan_bet::client::ui::{
3 command_names, completion_lines, display_width, first_contact_lines, format_pong, help_lines, 3 command_names, completion_lines, display_width, first_contact_lines, format_pong, help_lines,
4 host_lines, input_cursor_column, lines_to_move_up, parse_bet_arg, record_history, 4 host_lines, input_cursor_column, lines_to_move_up, parse_bet_arg, record_history,
5 visual_line_count, 5 terminal_too_small, visual_line_count, MIN_COLUMNS, MIN_ROWS,
6}; 6};
7 7
8#[test] 8#[test]
@@ -255,3 +255,53 @@ fn hosts_without_a_fingerprint_are_never_merged() {
255 2 255 2
256 ); 256 );
257} 257}
258
259#[test]
260fn a_terminal_smaller_than_every_game_needs_is_refused() {
261 assert!(terminal_too_small(80, 24).is_none());
262 assert!(terminal_too_small(120, 40).is_none());
263
264 assert!(terminal_too_small(79, 24).is_some());
265 assert!(terminal_too_small(80, 23).is_some());
266
267 let message = terminal_too_small(40, 10).unwrap();
268 assert!(message.contains("80x24"));
269 assert!(message.contains("40x10"));
270}
271
272#[test]
273fn the_roulette_felt_fits_the_minimum_terminal() {
274 use zagan_bet::client::games::roulette::{Cursor, RouletteGame, ROULETTE_BOARD_LINES};
275 use zagan_bet::client::games::traits::{Game, GamePhase, GameState};
276 use zagan_bet::roulette::Bets;
277 use std::collections::HashMap;
278
279 let mut data = HashMap::new();
280 data.insert("balance".to_string(), "1000".to_string());
281 data.insert("chip".to_string(), "10".to_string());
282 data.insert("status".to_string(), "place".to_string());
283 data.insert("bets".to_string(), Bets::default().to_json());
284 data.insert("cursor".to_string(), Cursor::Zero.encoded());
285 let state = GameState {
286 game_type: "roulette".to_string(),
287 bet_amount: 0,
288 phase: GamePhase::WaitingToStart,
289 data,
290 };
291
292 let board = RouletteGame::new().render(&state);
293 let lines: Vec<&str> = board.split("\r\n").collect();
294 assert_eq!(lines.len(), ROULETTE_BOARD_LINES);
295 assert_eq!(RouletteGame::new().render_lines(&state), ROULETTE_BOARD_LINES);
296 for line in lines {
297 assert!(
298 display_width(line) <= MIN_COLUMNS as usize,
299 "roulette line is {} wide: {line:?}",
300 display_width(line)
301 );
302 }
303 assert!(
304 ROULETTE_BOARD_LINES + 4 <= MIN_ROWS as usize,
305 "the board must leave room for the prompt and a few transcript lines"
306 );
307}
diff --git a/tests/roulette.rs b/tests/roulette.rs
index 932bace..8f9155c 100644
--- a/tests/roulette.rs
+++ b/tests/roulette.rs
@@ -1,4 +1,7 @@
1use zagan_bet::roulette::{color, is_black, is_even, is_odd, is_red, rtp, Bets, NumberBet, WHEEL}; 1use zagan_bet::client::games::roulette::{Cursor, Direction};
2use zagan_bet::roulette::{
3 color, is_black, is_even, is_odd, is_red, rtp, BetTarget, Bets, Outside, WHEEL,
4};
2use zagan_bet::server::games::roulette::RouletteGame; 5use zagan_bet::server::games::roulette::RouletteGame;
3use zagan_bet::server::games::traits::Game; 6use zagan_bet::server::games::traits::Game;
4 7
@@ -31,102 +34,184 @@ fn european_rtp_is_36_over_37() {
31} 34}
32 35
33#[test] 36#[test]
34fn bets_total_and_winning_number_payouts() { 37fn inside_bets_pay_their_true_odds() {
35 let bets = Bets { 38 let straight = BetTarget::Straight { number: 17 };
36 red: 10, 39 assert_eq!(straight.return_multiplier(17), 36);
37 black: 10, 40 assert_eq!(straight.return_multiplier(18), 0);
38 even: 10, 41
39 odd: 10, 42 let split = BetTarget::Split { a: 1, b: 2 };
40 numbers: vec![NumberBet { 43 assert_eq!(split.return_multiplier(1), 18);
41 number: 17, 44 assert_eq!(split.return_multiplier(2), 18);
42 amount: 5, 45 assert_eq!(split.return_multiplier(3), 0);
43 }], 46 assert_eq!(split.numbers(), vec![1, 2]);
47
48 let corner = BetTarget::Corner {
49 numbers: [1, 2, 4, 5],
44 }; 50 };
51 for number in [1, 2, 4, 5] {
52 assert_eq!(corner.return_multiplier(number), 9);
53 }
54 assert_eq!(corner.return_multiplier(3), 0);
55 assert_eq!(corner.numbers(), vec![1, 2, 4, 5]);
56}
57
58#[test]
59fn dozens_and_columns_pay_three_for_one_and_zero_loses() {
60 let first_dozen = BetTarget::Dozen { index: 0 };
61 assert_eq!(first_dozen.return_multiplier(1), 3);
62 assert_eq!(first_dozen.return_multiplier(12), 3);
63 assert_eq!(first_dozen.return_multiplier(13), 0);
64 assert_eq!(first_dozen.return_multiplier(0), 0);
65
66 let second_dozen = BetTarget::Dozen { index: 1 };
67 assert_eq!(second_dozen.return_multiplier(13), 3);
68 assert_eq!(second_dozen.return_multiplier(24), 3);
69
70 let first_column = BetTarget::Column { index: 0 };
71 assert_eq!(first_column.return_multiplier(1), 3);
72 assert_eq!(first_column.return_multiplier(34), 3);
73 assert_eq!(first_column.return_multiplier(2), 0);
74
75 let third_column = BetTarget::Column { index: 2 };
76 assert_eq!(third_column.return_multiplier(36), 3);
77 assert_eq!(third_column.return_multiplier(0), 0, "0 is not in a column");
45 78
46 assert_eq!(bets.total(), 45); 79 for index in 0..3 {
47 // 17 is black and odd: 10*2 + 10*2 + 5*36. 80 let dozen = BetTarget::Dozen { index };
48 assert_eq!(bets.winnings(17), 220); 81 let winning = index as u8 * 12 + 1;
49 // 18 is red and even. 82 assert_eq!(dozen.return_multiplier(winning), 3);
50 assert_eq!(bets.winnings(18), 40); 83 }
51} 84}
52 85
53#[test] 86#[test]
54fn zero_loses_all_outside_bets_but_pays_a_straight_zero() { 87fn outside_bets_pay_even_money_and_zero_loses_everything() {
55 let outside = Bets { 88 let cases = [
56 red: 10, 89 (Outside::Low, 18u8, 19u8),
57 black: 10, 90 (Outside::High, 19, 18),
58 even: 10, 91 (Outside::Even, 2, 3),
59 odd: 10, 92 (Outside::Odd, 3, 2),
60 numbers: vec![], 93 (Outside::Red, 1, 2),
61 }; 94 (Outside::Black, 2, 1),
62 assert_eq!(outside.winnings(0), 0); 95 ];
63 96 for (kind, winning, losing) in cases {
64 let straight = Bets { 97 let bet = BetTarget::Outside { kind };
65 red: 10, 98 assert_eq!(bet.return_multiplier(winning), 2, "{kind:?} should win");
66 black: 10, 99 assert_eq!(bet.return_multiplier(losing), 0, "{kind:?} should lose");
67 even: 10, 100 assert_eq!(bet.return_multiplier(0), 0, "0 loses every outside bet");
68 odd: 10, 101 }
69 numbers: vec![NumberBet { 102}
70 number: 0, 103
71 amount: 5, 104#[test]
72 }], 105fn chips_on_the_same_target_merge() {
73 }; 106 let mut bets = Bets::default();
74 assert_eq!(straight.total(), 45); 107 assert!(bets.is_empty());
75 assert_eq!(straight.winnings(0), 180); 108
109 bets.add(BetTarget::Straight { number: 17 }, 10);
110 bets.add(BetTarget::Straight { number: 17 }, 5);
111 bets.add(BetTarget::Outside { kind: Outside::Red }, 10);
112
113 assert_eq!(bets.chips.len(), 2);
114 assert_eq!(
115 bets.amount_on(&BetTarget::Straight { number: 17 }),
116 15
117 );
118 assert_eq!(bets.total(), 25);
119 assert_eq!(bets.winnings(17), 15 * 36);
76} 120}
77 121
78#[test] 122#[test]
79fn bets_validate_empty_balance_and_invalid_numbers() { 123fn bets_settle_every_chip_independently() {
124 let mut bets = Bets::default();
125 bets.add(BetTarget::Split { a: 1, b: 2 }, 10);
126 bets.add(BetTarget::Outside { kind: Outside::Red }, 10);
127 bets.add(BetTarget::Straight { number: 1 }, 5);
128
129 // 2 is black, so the red chip loses; the split pays 18x.
130 assert_eq!(bets.winnings(2), 180);
131 // 3 is red, but the straight is on 1, so only the red chip pays.
132 assert_eq!(bets.winnings(3), 20);
133 // 1 is red, matches the straight, and is half of the split.
134 assert_eq!(bets.winnings(1), 5 * 36 + 10 * 2 + 10 * 18);
135}
136
137#[test]
138fn a_straight_zero_is_paid_while_outside_bets_lose() {
139 let mut bets = Bets::default();
140 bets.add(BetTarget::Straight { number: 0 }, 5);
141 bets.add(BetTarget::Outside { kind: Outside::Red }, 10);
142 bets.add(BetTarget::Dozen { index: 0 }, 10);
143
144 assert_eq!(bets.total(), 25);
145 assert_eq!(bets.winnings(0), 180);
146}
147
148#[test]
149fn bets_validate_balance_and_targets() {
80 assert!(Bets::default().validate(1000).is_err()); 150 assert!(Bets::default().validate(1000).is_err());
81 151
82 let too_big = Bets { 152 let mut too_big = Bets::default();
83 red: 2000, 153 too_big.add(BetTarget::Outside { kind: Outside::Black }, 2000);
84 ..Bets::default()
85 };
86 assert!(too_big.validate(1000).is_err()); 154 assert!(too_big.validate(1000).is_err());
87 155
88 let bad_number = Bets { 156 let bad_number = Bets {
89 numbers: vec![NumberBet { 157 chips: vec![zagan_bet::roulette::Chip {
90 number: 37, 158 target: BetTarget::Straight { number: 37 },
91 amount: 10, 159 amount: 10,
92 }], 160 }],
93 ..Bets::default()
94 }; 161 };
95 assert!(bad_number.validate(1000).is_err()); 162 assert!(bad_number.validate(1000).is_err());
96}
97 163
98#[test] 164 let bad_split = Bets {
99fn the_server_settles_a_split_bet() { 165 chips: vec![zagan_bet::roulette::Chip {
100 let bets = Bets { 166 target: BetTarget::Split { a: 4, b: 4 },
101 red: 10, 167 amount: 10,
102 black: 10,
103 even: 10,
104 odd: 10,
105 numbers: vec![NumberBet {
106 number: 17,
107 amount: 5,
108 }], 168 }],
109 }; 169 };
110 let payload = serde_json::to_string(&bets).unwrap(); 170 assert!(bad_split.validate(1000).is_err());
111 let game = RouletteGame::with_result(17); 171
112 let state = game.prepare_state("alice", bets.total()); 172 let bad_corner = Bets {
173 chips: vec![zagan_bet::roulette::Chip {
174 target: BetTarget::Corner {
175 numbers: [1, 2, 3, 3],
176 },
177 amount: 10,
178 }],
179 };
180 assert!(bad_corner.validate(1000).is_err());
113 181
182 let bad_dozen = Bets {
183 chips: vec![zagan_bet::roulette::Chip {
184 target: BetTarget::Dozen { index: 3 },
185 amount: 10,
186 }],
187 };
188 assert!(bad_dozen.validate(1000).is_err());
189}
190
191#[test]
192fn the_server_settles_split_and_outside_chips() {
193 let mut bets = Bets::default();
194 bets.add(BetTarget::Split { a: 1, b: 2 }, 10);
195 bets.add(BetTarget::Outside { kind: Outside::Even }, 10);
196 let payload = bets.to_json();
197
198 let game = RouletteGame::with_result(2);
199 let state = game.prepare_state("alice", bets.total());
114 let state = game.process_action(&state, "spin", &[&payload]).unwrap(); 200 let state = game.process_action(&state, "spin", &[&payload]).unwrap();
115 let result = game.calculate_result(&state).unwrap(); 201 let result = game.calculate_result(&state).unwrap();
116 202
117 assert_eq!(result.winnings, 220); 203 // 2 is even: 10*18 (split) + 10*2 (even money).
118 assert_eq!(result.result_data.get("number").unwrap(), "17"); 204 assert_eq!(result.winnings, 200);
205 assert_eq!(result.result_data.get("number").unwrap(), "2");
119 assert_eq!(result.result_data.get("color").unwrap(), "black"); 206 assert_eq!(result.result_data.get("color").unwrap(), "black");
120 assert_eq!(result.result_data.get("bets").unwrap(), &payload); 207 assert_eq!(result.result_data.get("bets").unwrap(), &payload);
121} 208}
122 209
123#[test] 210#[test]
124fn the_server_rejects_a_total_that_does_not_match_the_bets() { 211fn the_server_rejects_a_total_that_does_not_match_the_bets() {
125 let payload = serde_json::to_string(&Bets { 212 let mut bets = Bets::default();
126 red: 10, 213 bets.add(BetTarget::Outside { kind: Outside::Red }, 10);
127 ..Bets::default() 214 let payload = bets.to_json();
128 })
129 .unwrap();
130 let game = RouletteGame::with_result(17); 215 let game = RouletteGame::with_result(17);
131 let state = game.prepare_state("alice", 99); 216 let state = game.prepare_state("alice", 99);
132 217
@@ -134,31 +219,116 @@ fn the_server_rejects_a_total_that_does_not_match_the_bets() {
134} 219}
135 220
136#[test] 221#[test]
137fn the_server_rejects_an_invalid_number() { 222fn the_server_rejects_an_invalid_target() {
138 let payload = serde_json::to_string(&Bets { 223 let payload = r#"{"chips":[{"target":{"bet":"straight","number":37},"amount":10}]}"#;
139 numbers: vec![NumberBet {
140 number: 37,
141 amount: 10,
142 }],
143 ..Bets::default()
144 })
145 .unwrap();
146 let game = RouletteGame::with_result(17); 224 let game = RouletteGame::with_result(17);
147 let state = game.prepare_state("alice", 10); 225 let state = game.prepare_state("alice", 10);
148 226
149 assert!(game.process_action(&state, "spin", &[&payload]).is_err()); 227 assert!(game.process_action(&state, "spin", &[payload]).is_err());
228}
229
230// --- betting cursor -------------------------------------------------------
231
232fn target_at(x: usize, y: usize) -> BetTarget {
233 Cursor::Lattice { x, y }.target().unwrap()
234}
235
236#[test]
237fn lattice_points_become_straights_splits_and_corners() {
238 assert_eq!(
239 target_at(0, 3),
240 BetTarget::Straight { number: 1 },
241 "bottom-left corner is a straight"
242 );
243 assert_eq!(target_at(0, 0), BetTarget::Straight { number: 3 });
244 assert_eq!(target_at(12, 0), BetTarget::Straight { number: 36 });
245 assert_eq!(target_at(12, 3), BetTarget::Straight { number: 34 });
246
247 assert_eq!(target_at(0, 1), BetTarget::Split { a: 2, b: 3 });
248 assert_eq!(target_at(12, 1), BetTarget::Split { a: 35, b: 36 });
249 assert_eq!(target_at(1, 0), BetTarget::Split { a: 3, b: 6 });
250
251 assert_eq!(
252 target_at(1, 1),
253 BetTarget::Corner {
254 numbers: [2, 3, 5, 6]
255 }
256 );
257 assert_eq!(
258 target_at(6, 2),
259 BetTarget::Corner {
260 numbers: [16, 17, 19, 20]
261 }
262 );
263}
264
265#[test]
266fn the_cursor_walks_the_felt_and_the_outside_rows() {
267 let bottom = Cursor::Lattice { x: 0, y: 3 };
268 assert_eq!(bottom.moved(Direction::Down), Cursor::Dozen(0));
269 assert_eq!(
270 Cursor::Lattice { x: 12, y: 3 }.moved(Direction::Down),
271 Cursor::Dozen(2)
272 );
273 assert_eq!(
274 Cursor::Lattice { x: 0, y: 0 }.moved(Direction::Up),
275 Cursor::Zero
276 );
277 assert_eq!(Cursor::Zero.moved(Direction::Down), Cursor::Lattice { x: 0, y: 0 });
278
279 assert_eq!(Cursor::Dozen(0).moved(Direction::Up), Cursor::Lattice { x: 1, y: 3 });
280 assert_eq!(Cursor::Dozen(0).moved(Direction::Down), Cursor::Column(0));
281 assert_eq!(Cursor::Column(2).moved(Direction::Up), Cursor::Dozen(2));
282 assert_eq!(Cursor::Column(1).moved(Direction::Down), Cursor::Outside(2));
283 assert_eq!(Cursor::Outside(3).moved(Direction::Up), Cursor::Column(1));
284
285 // 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!(
289 Cursor::Lattice { x: 0, y: 2 }.moved(Direction::Left),
290 Cursor::Lattice { x: 0, y: 2 }
291 );
292}
293
294#[test]
295fn every_cursor_round_trips_through_its_encoded_form() {
296 let cursors = [
297 Cursor::Zero,
298 Cursor::Lattice { x: 7, y: 2 },
299 Cursor::Dozen(1),
300 Cursor::Column(2),
301 Cursor::Outside(4),
302 ];
303 for cursor in cursors {
304 assert_eq!(Cursor::parse(&cursor.encoded()), cursor);
305 }
306 assert_eq!(Cursor::parse("nonsense"), Cursor::default());
307 assert_eq!(Cursor::parse("lattice:99:0"), Cursor::default());
308 assert_eq!(Cursor::parse("dozen:7"), Cursor::default());
309}
310
311#[test]
312fn the_outside_cursor_targets_match_the_labels() {
313 let labels: Vec<String> = (0..6)
314 .map(|index| {
315 let target = Cursor::Outside(index).target().unwrap();
316 target.label()
317 })
318 .collect();
319 assert_eq!(labels, vec!["1-18", "EVEN", "RED", "BLACK", "ODD", "19-36"]);
150} 320}
151 321
152#[test] 322#[test]
153fn parses_a_roulette_result() { 323fn parses_a_roulette_result() {
154 let bets = r#"{"red":10,"black":0,"even":10,"odd":0,"numbers":[{"number":17,"amount":10}]}"#; 324 let bets = r#"{"chips":[{"target":{"bet":"outside","kind":"red"},"amount":30}]}"#;
155 let response = format!("ROULETTE_RESULT session-1 360 30 1330 17 black {bets}"); 325 let response = format!("ROULETTE_RESULT session-1 60 30 1030 17 black {bets}");
156 let result = zagan_bet::client::games::response::parse_roulette_response(&response).unwrap(); 326 let result = zagan_bet::client::games::response::parse_roulette_response(&response).unwrap();
157 327
158 assert_eq!(result.session_id, "session-1"); 328 assert_eq!(result.session_id, "session-1");
159 assert_eq!(result.winnings, 360); 329 assert_eq!(result.winnings, 60);
160 assert_eq!(result.bet_amount, 30); 330 assert_eq!(result.bet_amount, 30);
161 assert_eq!(result.balance, 1330); 331 assert_eq!(result.balance, 1030);
162 assert_eq!(result.number, 17); 332 assert_eq!(result.number, 17);
163 assert_eq!(result.color, "black"); 333 assert_eq!(result.color, "black");
164 assert_eq!(result.bets, bets); 334 assert_eq!(result.bets, bets);
@@ -168,7 +338,7 @@ fn parses_a_roulette_result() {
168fn rejects_bad_roulette_results() { 338fn rejects_bad_roulette_results() {
169 use zagan_bet::client::games::response::parse_roulette_response; 339 use zagan_bet::client::games::response::parse_roulette_response;
170 340
171 let valid_bets = r#"{"red":10}"#; 341 let valid_bets = r#"{"chips":[]}"#;
172 assert!( 342 assert!(
173 parse_roulette_response(&format!("ROULETTE_RESULT s 1 1 1 37 green {valid_bets}")).is_err() 343 parse_roulette_response(&format!("ROULETTE_RESULT s 1 1 1 37 green {valid_bets}")).is_err()
174 ); 344 );
diff --git a/tests/server_core.rs b/tests/server_core.rs
index 6b7b415..47e80e7 100644
--- a/tests/server_core.rs
+++ b/tests/server_core.rs
@@ -4,7 +4,7 @@ use std::sync::Arc;
4use rusqlite::Connection; 4use rusqlite::Connection;
5use zagan_bet::blackjack::BlackjackState; 5use zagan_bet::blackjack::BlackjackState;
6use zagan_bet::crash::CrashState; 6use zagan_bet::crash::CrashState;
7use zagan_bet::roulette::Bets; 7use zagan_bet::roulette::{BetTarget, Bets, Outside};
8use zagan_bet::server::core::{ConnectionState, ServerCore}; 8use zagan_bet::server::core::{ConnectionState, ServerCore};
9use zagan_bet::server::database; 9use zagan_bet::server::database;
10use zagan_bet::server::games::crash::ManualClock; 10use zagan_bet::server::games::crash::ManualClock;
@@ -244,7 +244,13 @@ fn roulette_settles_split_bets_and_records_details() {
244 register(&mut core, &mut state, "alice"); 244 register(&mut core, &mut state, "alice");
245 login(&mut core, &mut state, "alice"); 245 login(&mut core, &mut state, "alice");
246 246
247 let bets = r#"{"red":10,"black":10,"even":10,"odd":10,"numbers":[{"number":17,"amount":5}]}"#; 247 let mut bets = Bets::default();
248 bets.add(BetTarget::Outside { kind: Outside::Red }, 10);
249 bets.add(BetTarget::Outside { kind: Outside::Black }, 10);
250 bets.add(BetTarget::Outside { kind: Outside::Even }, 10);
251 bets.add(BetTarget::Outside { kind: Outside::Odd }, 10);
252 bets.add(BetTarget::Straight { number: 17 }, 5);
253 let bets = bets.to_json();
248 let response = core.handle_message(&mut state, &format!("PLAY_START roulette 45 {bets}")); 254 let response = core.handle_message(&mut state, &format!("PLAY_START roulette 45 {bets}"));
249 255
250 assert!( 256 assert!(
diff --git a/tests/storage.rs b/tests/storage.rs
index 19936c2..e6ad2d1 100644
--- a/tests/storage.rs
+++ b/tests/storage.rs
@@ -89,55 +89,6 @@ fn forgetting_one_address_keeps_the_shared_credentials() {
89} 89}
90 90
91#[test] 91#[test]
92fn legacy_host_keyed_credentials_are_rekeyed_by_fingerprint() {
93 let (_, conn) = tmp_db("creds-migrate");
94 conn.execute_batch(
95 "CREATE TABLE hosts (
96 host TEXT PRIMARY KEY,
97 last_used DATETIME DEFAULT CURRENT_TIMESTAMP,
98 fingerprint TEXT
99 );
100 CREATE TABLE credentials (
101 host TEXT PRIMARY KEY,
102 username TEXT NOT NULL,
103 password TEXT NOT NULL
104 );
105 INSERT INTO hosts (host, last_used, fingerprint)
106 VALUES ('a:1', '2020-01-01 00:00:00', 'server-a');
107 INSERT INTO hosts (host, last_used, fingerprint)
108 VALUES ('b:2', '2021-01-01 00:00:00', 'server-a');
109 INSERT INTO hosts (host, last_used, fingerprint)
110 VALUES ('c:3', '2022-01-01 00:00:00', NULL);
111 INSERT INTO credentials VALUES ('a:1', 'alice', 'old');
112 INSERT INTO credentials VALUES ('b:2', 'alice', 'recent');
113 INSERT INTO credentials VALUES ('c:3', 'carol', 'untrusted');",
114 )
115 .unwrap();
116
117 client_database::init_db(&conn);
118
119 assert_eq!(
120 client_database::get_credentials(&conn, "server-a"),
121 Some(("alice".to_string(), "recent".to_string())),
122 "the most recently used address wins when legacy rows disagree"
123 );
124 assert_eq!(
125 client_database::get_credentials(&conn, "carol"),
126 None,
127 "a host with no trusted key cannot be re-keyed"
128 );
129
130 // The old table is gone: the column set is the fingerprint schema.
131 let mut statement = conn.prepare("PRAGMA table_info(credentials)").unwrap();
132 let columns: Vec<String> = statement
133 .query_map([], |row| row.get(1))
134 .unwrap()
135 .filter_map(|row| row.ok())
136 .collect();
137 assert_eq!(columns, vec!["fingerprint", "username", "password"]);
138}
139
140#[test]
141fn client_last_host_tracking() { 92fn client_last_host_tracking() {
142 let (_, conn) = tmp_db("hosts"); 93 let (_, conn) = tmp_db("hosts");
143 client_database::init_db(&conn); 94 client_database::init_db(&conn);