diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-29 22:55:49 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-29 22:55:49 +0100 |
| commit | 49df948de25d28601469e9ca993f33530989ebd0 (patch) | |
| tree | 291aae0f57d0d7d12a27eb28038e43f942d6b991 | |
| parent | v2.4.0: key stored credentials by certificate fingerprint (diff) | |
| download | zagan-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.md | 8 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GAMES.md | 43 | ||||
| -rw-r--r-- | docs/SERVER_SETUP.md | 5 | ||||
| -rw-r--r-- | docs/TESTING.md | 37 | ||||
| -rw-r--r-- | src/bin/client/connection.rs | 2 | ||||
| -rw-r--r-- | src/bin/client/main.rs | 15 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 2 | ||||
| -rw-r--r-- | src/client/core.rs | 102 | ||||
| -rw-r--r-- | src/client/database.rs | 41 | ||||
| -rw-r--r-- | src/client/games/manager.rs | 17 | ||||
| -rw-r--r-- | src/client/games/roulette.rs | 346 | ||||
| -rw-r--r-- | src/client/ui.rs | 17 | ||||
| -rw-r--r-- | src/roulette.rs | 215 | ||||
| -rw-r--r-- | src/server/database.rs | 6 | ||||
| -rw-r--r-- | src/server/games/roulette.rs | 2 | ||||
| -rw-r--r-- | tests/client_core.rs | 86 | ||||
| -rw-r--r-- | tests/client_ui.rs | 52 | ||||
| -rw-r--r-- | tests/roulette.rs | 334 | ||||
| -rw-r--r-- | tests/server_core.rs | 10 | ||||
| -rw-r--r-- | tests/storage.rs | 49 |
21 files changed, 957 insertions, 434 deletions
| @@ -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 |
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "2.4.0" | 3 | version = "3.0.0" |
| 4 | edition = "2024" | 4 | edition = "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 | ||
| 90 | Client controls inside roulette: | 103 | Client controls inside roulette: |
| 91 | 104 | ||
| 92 | ``` | 105 | ``` |
| 93 | R / B / E / O add the current chip to red/black/even/odd | 106 | arrows move the cursor over the felt, including between numbers |
| 94 | digits + Enter add the current chip to that number | 107 | ENTER place the current chip on the cursor |
| 95 | C clear all bets | 108 | C clear all bets |
| 96 | + or = / - change the chip size | 109 | + or = / - change the chip size |
| 97 | SPACE spin | 110 | SPACE 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> | |||
| 174 | GAME_RESULT <session> <winnings> <bet> <balance> <reels> <win_type> <win_lines> <win_cells> | 187 | GAME_RESULT <session> <winnings> <bet> <balance> <reels> <win_type> <win_lines> <win_cells> |
| 175 | ``` | 188 | ``` |
| 176 | 189 | ||
| 177 | Roulette adds a compact bets payload and a roulette-specific result: | 190 | Roulette sends the chips and gets a roulette-specific result: |
| 178 | 191 | ||
| 179 | ``` | 192 | ``` |
| 180 | PLAY_START roulette <total> <bets-json> | 193 | PLAY_START roulette <total> <bets-json> |
| 181 | ROULETTE_RESULT <session> <winnings> <bet> <balance> <number> <color> <bets-json> | 194 | ROULETTE_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 | ||
| 203 | targets, and requires the total to equal the sum of the chips. | ||
| 204 | |||
| 184 | Blackjack is a multi-step session: | 205 | Blackjack 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 | ||
| 80 | The server creates `server.db` in the current directory on first use. It holds | 80 | The server creates `server.db` in the current directory on first use. It holds |
| 81 | users, balances, and the per-game `plays` audit. The client keeps its own | 81 | users, 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 |
| 83 | the fingerprint-keyed credentials are migrated on startup; rows whose host was | 83 | migrations while the app is in alpha: a schema change means deleting both files. |
| 84 | already forgotten cannot be re-keyed and require one fresh login. | ||
| 85 | 84 | ||
| 86 | During development, delete both files to reset users, balances, history, and | 85 | During development, delete both files to reset users, balances, history, and |
| 87 | trust: | 86 | trust: |
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 | ||
| 74 | 1. `play roulette` shows the balance, chip size, empty bets, and controls. | 74 | 1. `play roulette` draws the full felt: 0, the 3x12 numbers, dozens, columns, |
| 75 | 2. `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. | 76 | 2. Arrows move the cursor. On the numbers the highlighted cells show the bet: |
| 77 | 3. 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. | ||
| 79 | 3. ENTER places the chip where the cursor is; `C` clears; `+`/`=`/`-` change the | ||
| 80 | chip size. The bets line lists every chip and the total. | ||
| 81 | 4. Space scrolls the wheel under a fixed ball marker and ends on the winning | ||
| 78 | number. | 82 | number. |
| 79 | 4. Check a split result: | 83 | 5. 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; |
| 82 | 5. 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 |
| 84 | 6. 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. | 89 | 6. 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 | |||
| 94 | 1. 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. | ||
| 97 | 2. 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 | }; |
| 9 | use crate::crash::{multiplier_at, CrashState}; | 9 | use crate::crash::{multiplier_at, CrashState}; |
| 10 | use crate::roulette::{Bets, NumberBet}; | 10 | use crate::client::games::roulette::Direction; |
| 11 | use crate::roulette::Bets; | ||
| 11 | use std::time::{Duration, Instant}; | 12 | use 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)] | ||
| 80 | enum RouletteSide { | ||
| 81 | Red, | ||
| 82 | Black, | ||
| 83 | Even, | ||
| 84 | Odd, | ||
| 85 | } | ||
| 86 | |||
| 87 | pub struct ClientCore { | 82 | pub 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 | ||
| 42 | fn 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. | ||
| 56 | fn 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 | |||
| 78 | pub fn get_last_host(conn: &Connection) -> Option<String> { | 37 | pub 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 @@ | |||
| 1 | use super::traits::{Game, GameState}; | 1 | use super::traits::{Game, GameState}; |
| 2 | use crate::roulette::{is_even, is_odd, Bets}; | 2 | use crate::roulette::{is_even, is_odd, BetTarget, Bets, Outside}; |
| 3 | 3 | ||
| 4 | pub const ROULETTE_BOARD_LINES: usize = 8; | 4 | pub const ROULETTE_BOARD_LINES: usize = 12; |
| 5 | |||
| 6 | /// Width of one number cell on the felt. | ||
| 7 | const CELL: usize = 5; | ||
| 8 | const NUM_COLUMNS: usize = 12; | ||
| 9 | const NUM_WIDTH: usize = CELL * NUM_COLUMNS; | ||
| 10 | const THIRD_WIDTH: usize = NUM_WIDTH / 3; | ||
| 11 | const OUTSIDE_WIDTH: usize = NUM_WIDTH / 6; | ||
| 12 | const 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 | ||
| 6 | pub struct RouletteGame; | 21 | pub struct RouletteGame; |
| 7 | 22 | ||
| 23 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 24 | pub 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)] | ||
| 37 | pub enum Cursor { | ||
| 38 | Zero, | ||
| 39 | Lattice { x: usize, y: usize }, | ||
| 40 | Dozen(usize), | ||
| 41 | Column(usize), | ||
| 42 | Outside(usize), | ||
| 43 | } | ||
| 44 | |||
| 45 | impl Default for Cursor { | ||
| 46 | fn default() -> Self { | ||
| 47 | // Bottom-left number (1). | ||
| 48 | Cursor::Lattice { x: 0, y: 3 } | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | impl 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 | |||
| 145 | fn 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). | ||
| 150 | fn 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 | |||
| 8 | impl RouletteGame { | 176 | impl 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. | ||
| 249 | pub const MIN_COLUMNS: u16 = 80; | ||
| 250 | pub const MIN_ROWS: u16 = 24; | ||
| 251 | |||
| 252 | /// Refuse to start when the terminal cannot show every game. | ||
| 253 | pub 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). |
| 16 | pub const STRAIGHT_RETURN: u64 = 36; | 16 | pub const STRAIGHT_RETURN: u64 = 36; |
| 17 | /// Total return on a winning split (17:1 plus the stake). | ||
| 18 | pub const SPLIT_RETURN: u64 = 18; | ||
| 19 | /// Total return on a winning corner (8:1 plus the stake). | ||
| 20 | pub const CORNER_RETURN: u64 = 9; | ||
| 21 | /// Total return on a winning dozen or column (2:1 plus the stake). | ||
| 22 | pub 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). |
| 18 | pub const EVEN_MONEY_RETURN: u64 = 2; | 24 | pub 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")] | ||
| 58 | pub enum Outside { | ||
| 59 | Low, | ||
| 60 | High, | ||
| 61 | Even, | ||
| 62 | Odd, | ||
| 63 | Red, | ||
| 64 | Black, | ||
| 65 | } | ||
| 66 | |||
| 67 | impl 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")] | ||
| 94 | pub 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 | |||
| 105 | impl 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)] |
| 51 | pub struct NumberBet { | 180 | pub 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)] |
| 57 | pub struct Bets { | 186 | pub 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 | ||
| 70 | impl Bets { | 191 | impl 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] |
| 594 | fn roulette_split_bets_spin_and_show_the_result() { | 594 | fn 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] | ||
| 646 | fn 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] | ||
| 657 | fn 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 | ||
| 669 | fn bj_state( | 687 | fn 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; | |||
| 2 | use zagan_bet::client::ui::{ | 2 | use 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] | ||
| 260 | fn 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] | ||
| 273 | fn 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 @@ | |||
| 1 | use zagan_bet::roulette::{color, is_black, is_even, is_odd, is_red, rtp, Bets, NumberBet, WHEEL}; | 1 | use zagan_bet::client::games::roulette::{Cursor, Direction}; |
| 2 | use zagan_bet::roulette::{ | ||
| 3 | color, is_black, is_even, is_odd, is_red, rtp, BetTarget, Bets, Outside, WHEEL, | ||
| 4 | }; | ||
| 2 | use zagan_bet::server::games::roulette::RouletteGame; | 5 | use zagan_bet::server::games::roulette::RouletteGame; |
| 3 | use zagan_bet::server::games::traits::Game; | 6 | use 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] |
| 34 | fn bets_total_and_winning_number_payouts() { | 37 | fn 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] | ||
| 59 | fn 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] |
| 54 | fn zero_loses_all_outside_bets_but_pays_a_straight_zero() { | 87 | fn 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 | }], | 105 | fn 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] |
| 79 | fn bets_validate_empty_balance_and_invalid_numbers() { | 123 | fn 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] | ||
| 138 | fn 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] | ||
| 149 | fn 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 { |
| 99 | fn 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] | ||
| 192 | fn 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] |
| 124 | fn the_server_rejects_a_total_that_does_not_match_the_bets() { | 211 | fn 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] |
| 137 | fn the_server_rejects_an_invalid_number() { | 222 | fn 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 | |||
| 232 | fn target_at(x: usize, y: usize) -> BetTarget { | ||
| 233 | Cursor::Lattice { x, y }.target().unwrap() | ||
| 234 | } | ||
| 235 | |||
| 236 | #[test] | ||
| 237 | fn 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] | ||
| 266 | fn 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] | ||
| 295 | fn 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] | ||
| 312 | fn 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] |
| 153 | fn parses_a_roulette_result() { | 323 | fn 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() { | |||
| 168 | fn rejects_bad_roulette_results() { | 338 | fn 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; | |||
| 4 | use rusqlite::Connection; | 4 | use rusqlite::Connection; |
| 5 | use zagan_bet::blackjack::BlackjackState; | 5 | use zagan_bet::blackjack::BlackjackState; |
| 6 | use zagan_bet::crash::CrashState; | 6 | use zagan_bet::crash::CrashState; |
| 7 | use zagan_bet::roulette::Bets; | 7 | use zagan_bet::roulette::{BetTarget, Bets, Outside}; |
| 8 | use zagan_bet::server::core::{ConnectionState, ServerCore}; | 8 | use zagan_bet::server::core::{ConnectionState, ServerCore}; |
| 9 | use zagan_bet::server::database; | 9 | use zagan_bet::server::database; |
| 10 | use zagan_bet::server::games::crash::ManualClock; | 10 | use 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] |
| 92 | fn 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] | ||
| 141 | fn client_last_host_tracking() { | 92 | fn 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); |
